Source code for five_safes_tes_analytics.node.local_processing
from sqlalchemy import text
from tdigest import TDigest
import math
from .local_processing_base import BaseLocalProcessing
[docs]
class Mean(BaseLocalProcessing):
"""
Calculate the mean of a numeric column using SQL aggregation.
Returns aggregated statistics (count and sum) that can be used to compute
the mean across multiple TREs. The mean is calculated as sum/n on the client side.
"""
analysis_type = "mean"
@property
def description(self):
return "Calculate mean of a numeric column"
@property
def processing_query(self):
return """
SELECT
COUNT(*) AS n,
SUM(value_as_number) AS total
FROM user_query;"""
@property
def user_query_requirements(self):
return "Must select a single numeric column"
[docs]
class Variance(BaseLocalProcessing):
"""
Calculate the variance of a numeric column using SQL aggregation.
Returns aggregated statistics (count, sum, and sum of squares) that can be used
to compute variance across multiple TREs using the formula: Var = (sum_x2/n) - (sum/n)²
"""
analysis_type = "variance"
@property
def description(self):
return "Calculate variance of a numeric column"
@property
def processing_query(self):
return """
SELECT
COUNT(*) AS n,
SUM(value_as_number * value_as_number) AS sum_x2,
SUM(value_as_number) AS total
FROM user_query;"""
@property
def user_query_requirements(self):
return "Must select a single numeric column"
[docs]
class PMCC(BaseLocalProcessing):
"""
Calculate Pearson's correlation coefficient between two numeric columns.
Returns aggregated statistics (count, sums, and cross-products) needed to compute
PMCC across multiple TREs. The correlation is calculated on the client side after
aggregating results from all TREs.
"""
analysis_type = "PMCC"
@property
def description(self):
return "Calculate Pearson's correlation coefficient between two numeric columns"
@property
def processing_query(self):
return """
SELECT
COUNT(*) AS n,
SUM(x) AS sum_x,
SUM(y) AS sum_y,
SUM(x * x) AS sum_x2,
SUM(y * y) AS sum_y2,
SUM(x * y) AS sum_xy
FROM user_query;"""
@property
def user_query_requirements(self):
return "Must select exactly two numeric columns (x and y)"
[docs]
class ContingencyTable(BaseLocalProcessing):
"""
Build a contingency table from one or more categorical columns.
Dynamically detects columns from the user query and groups by them to count
occurrences. Returns raw counts for each combination of categorical values,
which can be aggregated across multiple TREs.
"""
analysis_type = "contingency_table"
@property
def description(self):
return "Build a contingency table from one or more categorical columns"
[docs]
def get_columns_from_user_query(self):
# Naively append LIMIT 0 if not present
query = self.user_query.strip().rstrip(";")
if "limit" not in query.lower():
query += " LIMIT 0"
with self.engine.connect() as conn:
result = conn.execute(text(query))
columns = result.keys()
if not columns:
raise ValueError("No columns found in user query.")
return list(columns)
@property
def processing_query(self):
categorical_columns = self.get_columns_from_user_query()
group_by = ", ".join(categorical_columns)
select = ", ".join(categorical_columns)
query = f"""
SELECT
{select},
COUNT(*) AS n
FROM user_query
GROUP BY {group_by}
ORDER BY {group_by};"""
return query
@property
def user_query_requirements(self):
return "Must select one or more categorical columns"
[docs]
class PercentileSketch(BaseLocalProcessing):
"""
Calculate percentile sketch using TDigest algorithm.
Uses TDigest (https://github.com/CamDavidsonPilon/tdigest) to create a compact
representation of the data distribution. The sketch is computed in Python after
fetching all data from SQL, and returns a TDigest dictionary that can be merged
across multiple TREs.
"""
analysis_type = "percentile_sketch"
@property
def description(self):
return "Calculate percentile sketch of a numeric column"
@property
def processing_query(self):
return None
@property
def user_query_requirements(self):
return "Must select a numeric column"
[docs]
def python_analysis(self, sql_result):
tdigest = TDigest()
for row in sql_result.fetchall():
## need to filter out missing values, null or NaN. If it's missing, it should only be None, but it's technically possible for NaN to be returned.
if row[0] is not None and not math.isnan(row[0]):
tdigest.update(row[0])
return (
tdigest.to_dict()
) # Return dict, not JSON string - json.dump will handle serialization
[docs]
def get_local_processing_registry():
registry = {}
for cls in BaseLocalProcessing.__subclasses__():
if hasattr(cls, "analysis_type"):
registry[cls.analysis_type] = cls
return registry
LOCAL_PROCESSING_CLASSES = get_local_processing_registry()