| """analyze_correlation — correlation among numeric columns (KM-608). |
| |
| An analytical "family" tool: in ONE call it measures how strongly numeric |
| columns move together. Returns the full correlation matrix plus a list of |
| column pairs ranked by strength. Answers questions like "does price relate to |
| units sold?". |
| |
| STATUS: compute layer only — the function takes an already-materialized |
| DataFrame. The wrapper layer (fetching data from the catalog via source_id, |
| the ToolOutput envelope, ToolSpec registration) is added once the Planner |
| seam (KM-418) is settled. Keeping compute separate from data-fetching makes |
| this function easy to unit-test in isolation and stable when wrapped. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import pandas as pd |
|
|
| from src.tools.analytics.descriptive import ColumnNotFoundError |
|
|
| |
| SUPPORTED_METHODS = ("pearson", "spearman", "kendall") |
|
|
|
|
| class InvalidMethodError(ValueError): |
| """The requested method is not supported (maps to error_code INVALID_METHOD).""" |
|
|
|
|
| class NonNumericColumnError(ValueError): |
| """A requested column is not numeric (maps to error_code NON_NUMERIC_COLUMN).""" |
|
|
|
|
| class NotEnoughColumnsError(ValueError): |
| """Correlation needs at least two numeric columns (maps to NOT_ENOUGH_COLUMNS).""" |
|
|
|
|
| def _clean(value: object) -> float | None: |
| """Cast to plain float; NaN (e.g. a zero-variance column) -> None.""" |
| if value is None: |
| return None |
| f = float(value) |
| return None if math.isnan(f) else f |
|
|
|
|
| |
| |
| DESCRIPTION = """\ |
| Summary: Pairwise correlation across numeric columns (pearson, spearman, or \ |
| kendall). Returns a correlation matrix plus the strongest pairs ranked by \ |
| absolute strength. |
| |
| USE WHEN the question is about relationship or association between numeric \ |
| variables. Trigger words: "correlation" (korelasi), "related/relationship" \ |
| (hubungan/keterkaitan), "does X affect Y", "move together". |
| |
| DON'T USE WHEN: |
| - it implies causation — correlation is not causality; stay descriptive |
| - it compares two groups of one metric -> analyze_comparison |
| - it summarizes a single column -> analyze_descriptive |
| |
| Example questions: |
| - "is there a correlation between price and quantity sold?" |
| - "which variables are most related to revenue?" |
| - "do age and spending move together?" |
| - "show the correlation matrix for the numeric columns" |
| """ |
|
|
|
|
| def analyze_correlation( |
| df: pd.DataFrame, |
| column_ids: list[str] | None = None, |
| method: str = "pearson", |
| ) -> dict[str, object]: |
| """Pairwise correlation across numeric columns. |
| |
| Args: |
| df: already-materialized data (in the real system the wrapper fetches |
| this from a source_id). |
| column_ids: numeric columns to correlate. If None, every numeric |
| column in df is used. |
| method: "pearson" (linear), "spearman" (rank), or "kendall". |
| |
| Returns: |
| dict with: |
| method — echo of the chosen method |
| columns — the numeric columns actually correlated |
| matrix — { col: { col: corr|None } } full square matrix |
| pairs — [{"a", "b", "corr"}] unique pairs, strongest |corr| first |
| |
| Raises: |
| InvalidMethodError: if method is unknown. |
| ColumnNotFoundError: if an explicit column is absent. |
| NonNumericColumnError: if an explicit column is not numeric. |
| NotEnoughColumnsError: if fewer than two numeric columns remain. |
| """ |
| if method not in SUPPORTED_METHODS: |
| raise InvalidMethodError( |
| f"unknown method '{method}'; supported: {list(SUPPORTED_METHODS)}" |
| ) |
|
|
| if column_ids is None: |
| cols = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])] |
| else: |
| missing = [c for c in column_ids if c not in df.columns] |
| if missing: |
| raise ColumnNotFoundError(f"columns not found: {missing}") |
| non_numeric = [ |
| c for c in column_ids if not pd.api.types.is_numeric_dtype(df[c]) |
| ] |
| if non_numeric: |
| raise NonNumericColumnError(f"columns are not numeric: {non_numeric}") |
| cols = list(column_ids) |
|
|
| if len(cols) < 2: |
| raise NotEnoughColumnsError( |
| f"need >= 2 numeric columns, got {len(cols)}: {cols}" |
| ) |
|
|
| corr = df[cols].corr(method=method) |
| matrix = {a: {b: _clean(corr.loc[a, b]) for b in cols} for a in cols} |
|
|
| pairs = [] |
| for i in range(len(cols)): |
| for j in range(i + 1, len(cols)): |
| val = _clean(corr.iloc[i, j]) |
| if val is not None: |
| pairs.append({"a": cols[i], "b": cols[j], "corr": val}) |
| pairs.sort(key=lambda p: abs(p["corr"]), reverse=True) |
|
|
| return {"method": method, "columns": cols, "matrix": matrix, "pairs": pairs} |
|
|