sofhiaazzhr Claude Opus 5.5 commited on
Commit
1ac2267
Β·
1 Parent(s): 50cae40

[NOTICKET] feat(correlation): pairwise n, p-value and a quality block (P0-8)

Browse files

- Each pair now carries n (rows complete for both columns), p_value (scipy
pearsonr / spearmanr / kendalltau) and weak (n < 10 or p >= 0.05)
- Reserved `quality` block for the strongest pair, so CK7 surfaces a weak correlation;
a caveat says one point can drive r on few rows, or that a large-n r is not significant
- With no column_ids, `n_*` row-count columns (the repair's n_periods) are skipped
- ADT OB HAULER monthly means: PA-MTTR r=-0.42 now reads n=9, p=0.26, weak; unit-month
rows (n=2,379) stay strong; a ranking table no longer correlates n_periods
- Reported by Rifqi (audit P0-8, decision D4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (1) hide show
  1. src/tools/analytics/relationship.py +86 -7
src/tools/analytics/relationship.py CHANGED
@@ -15,6 +15,7 @@ this function easy to unit-test in isolation and stable when wrapped.
15
  from __future__ import annotations
16
 
17
  import math
 
18
 
19
  import pandas as pd
20
  from pydantic import BaseModel, Field
@@ -24,6 +25,11 @@ from src.tools.analytics.descriptive import ColumnNotFoundError
24
  # Correlation methods supported by pandas .corr().
25
  SUPPORTED_METHODS = ("pearson", "spearman", "kendall")
26
 
 
 
 
 
 
27
 
28
  class InvalidMethodError(ValueError):
29
  """The requested method is not supported (maps to error_code INVALID_METHOD)."""
@@ -41,10 +47,48 @@ def _clean(value: object) -> float | None:
41
  """Cast to plain float; NaN (e.g. a zero-variance column) -> None."""
42
  if value is None:
43
  return None
44
- f = float(value) # type: ignore[arg-type]
45
  return None if math.isnan(f) else f
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  # Prompt-style description read by the Planner to decide WHEN to pick this tool.
49
  # Final destination is ToolSpec.description once the wrapper layer is built.
50
 
@@ -94,7 +138,7 @@ def analyze_correlation(
94
  df: already-materialized data (in the real system the wrapper fetches
95
  this from a source_id).
96
  column_ids: numeric columns to correlate. If None, every numeric
97
- column in df is used.
98
  method: "pearson" (linear), "spearman" (rank), or "kendall".
99
 
100
  Returns:
@@ -102,7 +146,11 @@ def analyze_correlation(
102
  method β€” echo of the chosen method
103
  columns β€” the numeric columns actually correlated
104
  matrix β€” { col: { col: corr|None } } full square matrix
105
- pairs β€” [{"a", "b", "corr"}] unique pairs, strongest |corr| first
 
 
 
 
106
 
107
  Raises:
108
  InvalidMethodError: if method is unknown.
@@ -116,7 +164,12 @@ def analyze_correlation(
116
  )
117
 
118
  if column_ids is None:
119
- cols = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
 
 
 
 
 
120
  else:
121
  missing = [c for c in column_ids if c not in df.columns]
122
  if missing:
@@ -136,12 +189,38 @@ def analyze_correlation(
136
  corr = df[cols].corr(method=method)
137
  matrix = {a: {b: _clean(corr.loc[a, b]) for b in cols} for a in cols}
138
 
139
- pairs = []
140
  for i in range(len(cols)):
141
  for j in range(i + 1, len(cols)):
142
  val = _clean(corr.iloc[i, j])
143
  if val is not None:
144
- pairs.append({"a": cols[i], "b": cols[j], "corr": val})
 
 
 
 
145
  pairs.sort(key=lambda p: abs(p["corr"]), reverse=True)
146
 
147
- return {"method": method, "columns": cols, "matrix": matrix, "pairs": pairs}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  from __future__ import annotations
16
 
17
  import math
18
+ from typing import Any
19
 
20
  import pandas as pd
21
  from pydantic import BaseModel, Field
 
25
  # Correlation methods supported by pandas .corr().
26
  SUPPORTED_METHODS = ("pearson", "spearman", "kendall")
27
 
28
+ # A pair is weak evidence below this many complete rows or at/above this p-value.
29
+ # Audit P0-8: an r of 0.953 came from only 8 monthly totals and read as strong.
30
+ _WEAK_MIN_N = 10
31
+ _ALPHA = 0.05
32
+
33
 
34
  class InvalidMethodError(ValueError):
35
  """The requested method is not supported (maps to error_code INVALID_METHOD)."""
 
47
  """Cast to plain float; NaN (e.g. a zero-variance column) -> None."""
48
  if value is None:
49
  return None
50
+ f = float(value)
51
  return None if math.isnan(f) else f
52
 
53
 
54
+ def _p_value(a: pd.Series, b: pd.Series, method: str) -> float | None:
55
+ """Two-sided p-value of the pair's correlation on its complete rows; None if < 3."""
56
+ from scipy import stats # lazy β€” keep module import light
57
+
58
+ if a.shape[0] < 3:
59
+ return None
60
+ test = {"pearson": stats.pearsonr, "spearman": stats.spearmanr,
61
+ "kendall": stats.kendalltau}[method]
62
+ return _clean(test(a, b).pvalue)
63
+
64
+
65
+ def _is_weak(n: int, p_value: float | None) -> bool:
66
+ return n < _WEAK_MIN_N or p_value is None or p_value >= _ALPHA
67
+
68
+
69
+ def _quality(top: dict[str, Any] | None) -> dict[str, object]:
70
+ """Reserved `quality` block (conventions Β§3) for the strongest pair; CK7 surfaces it."""
71
+ if top is None:
72
+ return {"metric": "p_value", "value": None, "floor": _ALPHA, "weak": True,
73
+ "note": "no pair had a defined correlation"}
74
+ n, p = top["n"], top["p_value"]
75
+ reasons = []
76
+ if n < _WEAK_MIN_N:
77
+ reasons.append(f"only {n} rows")
78
+ if p is None or p >= _ALPHA:
79
+ reasons.append("not significant at alpha=0.05")
80
+ return {
81
+ "metric": "p_value",
82
+ "value": None if p is None else round(p, 4),
83
+ "floor": _ALPHA,
84
+ "weak": top["weak"],
85
+ "note": (
86
+ f"strongest pair {top['a']}–{top['b']} (r={top['corr']:.3f}, n={n}): "
87
+ + ("; ".join(reasons) if reasons else f"significant at alpha={_ALPHA}")
88
+ ),
89
+ }
90
+
91
+
92
  # Prompt-style description read by the Planner to decide WHEN to pick this tool.
93
  # Final destination is ToolSpec.description once the wrapper layer is built.
94
 
 
138
  df: already-materialized data (in the real system the wrapper fetches
139
  this from a source_id).
140
  column_ids: numeric columns to correlate. If None, every numeric
141
+ column in df is used except `n_*` row-count columns.
142
  method: "pearson" (linear), "spearman" (rank), or "kendall".
143
 
144
  Returns:
 
146
  method β€” echo of the chosen method
147
  columns β€” the numeric columns actually correlated
148
  matrix β€” { col: { col: corr|None } } full square matrix
149
+ pairs β€” [{"a", "b", "corr", "n", "p_value", "weak"}] unique pairs,
150
+ strongest |corr| first; n = rows complete for both columns,
151
+ weak = n < 10 or p >= 0.05
152
+ quality β€” reserved block for the strongest pair (CK7 surfaces `weak`)
153
+ caveats β€” a note when the strongest pair is weak evidence
154
 
155
  Raises:
156
  InvalidMethodError: if method is unknown.
 
164
  )
165
 
166
  if column_ids is None:
167
+ # `n_*` columns are row counts the IR repair adds to rankings (n_periods), not
168
+ # measures β€” correlating against them is noise.
169
+ cols = [
170
+ c for c in df.columns
171
+ if pd.api.types.is_numeric_dtype(df[c]) and not str(c).startswith("n_")
172
+ ]
173
  else:
174
  missing = [c for c in column_ids if c not in df.columns]
175
  if missing:
 
189
  corr = df[cols].corr(method=method)
190
  matrix = {a: {b: _clean(corr.loc[a, b]) for b in cols} for a in cols}
191
 
192
+ pairs: list[dict[str, Any]] = []
193
  for i in range(len(cols)):
194
  for j in range(i + 1, len(cols)):
195
  val = _clean(corr.iloc[i, j])
196
  if val is not None:
197
+ both = df[[cols[i], cols[j]]].dropna()
198
+ n = int(both.shape[0])
199
+ p = _p_value(both.iloc[:, 0], both.iloc[:, 1], method)
200
+ pairs.append({"a": cols[i], "b": cols[j], "corr": val, "n": n,
201
+ "p_value": p, "weak": _is_weak(n, p)})
202
  pairs.sort(key=lambda p: abs(p["corr"]), reverse=True)
203
 
204
+ top = pairs[0] if pairs else None
205
+ caveats: list[str] = []
206
+ if top is not None and top["weak"]:
207
+ if top["n"] < _WEAK_MIN_N:
208
+ caveats.append(
209
+ f"The strongest correlation ({top['a']}–{top['b']}, r={top['corr']:.3f}) "
210
+ f"rests on {top['n']} rows β€” on so few points a single value can drive r, "
211
+ "so treat it as a lead to check, not an established relationship."
212
+ )
213
+ else:
214
+ caveats.append(
215
+ f"The strongest correlation ({top['a']}–{top['b']}, r={top['corr']:.3f}) "
216
+ f"is not significant (p >= {_ALPHA}) on {top['n']} rows β€” it may be noise."
217
+ )
218
+
219
+ return {
220
+ "method": method,
221
+ "columns": cols,
222
+ "matrix": matrix,
223
+ "pairs": pairs,
224
+ "quality": _quality(top),
225
+ "caveats": caveats,
226
+ }