ishaq101's picture
feat/Planner Agent (#2)
81e5fe7
Raw
History Blame
4.97 kB
"""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
# Correlation methods supported by pandas .corr().
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) # type: ignore[arg-type]
return None if math.isnan(f) else f
# Prompt-style description read by the Planner to decide WHEN to pick this tool.
# Final destination is ToolSpec.description once the wrapper layer is built.
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}