File size: 16,647 Bytes
e201ae4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | """analyze_importance β rank which columns most explain a target (Β§0.9).
Third ML analytical tool. Fits a standardised linear model (Ridge for a numeric
target, Logistic for a categorical one) on a train split, scores it on a holdout, and
ranks the features by the magnitude of their standardised coefficient β "what drives
downtime?".
Named `importance`, not `driver`: "driver" was rejected at the 2026-09-15 discussion as
ambiguous, and "feature importance" as too technical. The thing the user wants is
*"kolom mana yang paling berdampak"*.
**Standardised coefficients, not permutation importance.** For a linear model the
standardised coefficient IS the importance, and it comes free with the fit. Permutation
importance re-scores the model once per feature, which is the latency tall pole
identified in the plan (Β§0.9 A8 / E4) β it buys nothing here because the model is
linear by design. If a non-linear model is ever added, permutation becomes necessary
and belongs behind an explicit argument.
**Leakage-safe by construction.** The scaler is fitted on the TRAIN split only and then
applied to the holdout, so no test-set statistic reaches the fit β the one thing the
plan (R-3) and the survey literature both single out. It also reports its holdout
metric rather than a training score: a ranking from a model that cannot predict is not
a finding, and the checkpoint's CK7 needs that number to flag the case.
Follows docs/tools/ANALYTICAL_TOOLS_CONVENTIONS.md: compute is synchronous except for
the `await run_fit` offload; `data` is never self-fetched (Pattern A); errors escape to
the invoker's never-throw seam; output is deterministic (fixed random_state). Mirrors
analyze_anomaly's shape so the ML tools stay uniform.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from pydantic import BaseModel, Field
from src.tools.analytics.charts import pareto_chart
from src.tools.analytics.descriptive import ColumnNotFoundError
from src.tools.ml.executor import run_fit
from src.tools.spec_builder import build_description
# A supervised fit with a holdout needs a sane floor; below this the ranking is noise.
_MIN_ROWS = 20
# Feature cap (Β§0.9 R-5): bounds the fit AND keeps the ranking readable.
_MAX_FEATURES = 20
# A numeric integer target with more distinct values than this is always regression.
_MAX_CLASSES = 20
# An integer target whose distinct values fill at least this fraction of its
# [min, max] span reads as ordinal (regression), not as unrelated classes (D-4).
_ORDINAL_DENSITY = 0.5
# Fixed so the same data always ranks the same (family-B determinism promise).
_RANDOM_STATE = 42
_TEST_SIZE = 0.25
# Below this holdout score the ranking is noise, not a finding β surfaced as the
# reserved `quality` block the checkpoint's CK7 reads.
_WEAK_FIT = 0.3
# Chart column names (internal; never collide with user data).
_FEATURE_COL = "feature"
_WEIGHT_COL = "importance"
class NotEnoughDataError(ValueError):
"""Too few complete rows to fit an importance model (error_code NOT_ENOUGH_DATA)."""
class NoUsableFeaturesError(ValueError):
"""No usable feature column survived cleaning (error_code NO_USABLE_FEATURES)."""
class ConstantTargetError(ValueError):
"""The target never varies, so nothing can explain it (error_code CONSTANT_TARGET)."""
class ImportanceInput(BaseModel):
# `data` FIRST β property order follows field order (planner-prompt readability).
data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).")
target_column: str = Field(
..., description="The column to be explained β the outcome the user cares about."
)
feature_columns: list[str] | None = Field(
None,
description="Candidate explanatory NUMERIC columns; omit to use every other "
"numeric column in the table.",
)
top_n: int | None = Field(
None, description="Return only the top N features by importance; omit for all."
)
DESCRIPTION = build_description(
summary="Rank which columns most explain a target, with a holdout score.",
use_when="the user asks what drives, explains, influences or affects a metric β "
"'apa yang paling mempengaruhi downtime', 'what drives churn', "
"'faktor apa yang bikin PA turun'.",
dont_use_when=[
"the user asks how two specific columns move together -> analyze_correlation "
"(that is a pairwise relationship, this ranks many columns against one target)",
"the user wants which CATEGORIES make up most of a total -> analyze_pareto "
"(that is arithmetic share, this is statistical explanation)",
"the user wants a future value -> analyze_forecast",
"the target column does not exist in the data -> report it as infeasible",
],
output="a ranked list of features with signed standardised coefficients and "
"normalised importance, the model's holdout score, and a pareto-style bar chart.",
examples=[
"apa yang paling mempengaruhi downtime?",
"what drives low availability?",
"faktor apa yang berpengaruh ke MTTR?",
],
)
def _infer_task(raw_target: pd.Series) -> tuple[bool, str]:
"""Decide classification vs regression for the target, and say WHY (D-4).
A numeric target used to become classification whenever it had <= _MAX_CLASSES
distinct values β so a 15-distinct integer downtime column was factorized into 15
unrelated classes, destroying its order and silently flipping the metric from r2 to
accuracy. Rules, in order (returns `classify`):
- non-numeric -> classification (true categories)
- float (any non-integer value) -> regression (continuous, any distinct count)
- integer, <= 2 distinct -> classification (a binary flag)
- integer, > _MAX_CLASSES distinct -> regression (too many for classes)
- integer densely filling its range -> regression (ordinal β order preserved)
- integer, sparse codes over a range -> classification (coded categories)
"""
numeric = pd.to_numeric(raw_target, errors="coerce")
non_null = numeric.dropna()
if non_null.empty:
# An all-null column is not "non-numeric" β the coercion dropped nothing, the
# column was simply empty. Only call it categorical when it actually holds
# non-null values that aren't numeric. (Either way the fit fails downstream on
# emptiness; this only keeps task_reason honest.)
if raw_target.dropna().empty:
return True, "target has no non-null values"
return True, "target is non-numeric β treated as categorical classes"
is_integer_valued = bool((non_null == non_null.round()).all())
if not is_integer_valued:
return False, "target is continuous numeric β regression"
distinct = int(non_null.nunique())
if distinct <= 2:
return True, f"target is a {distinct}-value integer flag β classification"
if distinct > _MAX_CLASSES:
return False, f"target is an integer with {distinct} distinct values β regression"
span = float(non_null.max() - non_null.min()) + 1.0
density = distinct / span if span > 0 else 1.0
if density >= _ORDINAL_DENSITY:
return False, (
f"target is an integer with {distinct} values densely filling its range "
"β treated as ordinal (regression), order preserved"
)
return True, (
f"target is an integer with {distinct} sparse codes over a wide range "
"β treated as categorical classes (classification)"
)
def _fit_linear(x: np.ndarray, y: np.ndarray, classify: bool, random_state: int):
"""Split β scale on TRAIN only β fit β score on the holdout.
Imported lazily so app startup doesn't pay for scikit-learn unless an importance
analysis runs. Runs inside run_fit's thread.
Returns (coefficients, metric_name, metric_value). Fitting the scaler inside the
train split is what keeps the holdout score honest β see the module docstring.
"""
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
stratify = y if classify and np.min(np.bincount(y.astype(int))) >= 2 else None
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=_TEST_SIZE, random_state=random_state, stratify=stratify
)
scaler = StandardScaler().fit(x_train) # TRAIN ONLY β no test statistic leaks in
x_train_s, x_test_s = scaler.transform(x_train), scaler.transform(x_test)
if classify:
model = LogisticRegression(max_iter=1000, random_state=random_state)
model.fit(x_train_s, y_train)
# Multi-class gives one row of coefficients per class; average their magnitude.
coefs = model.coef_[0] if model.coef_.shape[0] == 1 else model.coef_.mean(axis=0)
return coefs, "accuracy", float(model.score(x_test_s, y_test))
model = Ridge(random_state=random_state)
model.fit(x_train_s, y_train)
return model.coef_, "r2", float(model.score(x_test_s, y_test))
def _importance_chart(rows: list[dict], target_column: str) -> dict[str, object]:
"""Pareto of the ranked features.
Reuses `pareto_chart` deliberately: bars descending plus a cumulative line answers
"which few columns account for most of the explanatory weight", which is exactly how
an importance ranking is read. The cumulative share is share of *explanatory
weight*, not a causal contribution β the caveat says so.
"""
chart_df = pd.DataFrame(
{
_FEATURE_COL: [r["feature"] for r in rows],
_WEIGHT_COL: [r["importance"] for r in rows],
}
)
return pareto_chart(
chart_df, _FEATURE_COL, _WEIGHT_COL, title=f"What explains {target_column}"
)
async def analyze_importance(
df: pd.DataFrame,
target_column: str,
feature_columns: list[str] | None = None,
top_n: int | None = None,
) -> dict[str, object]:
"""Rank `feature_columns` by how much they explain `target_column`.
Async ONLY so the model fit can be offloaded via `run_fit`; everything else is
synchronous compute. `data` is materialised upstream (Pattern A) and arrives as
`df`.
Returns a dict with: task ("regression"/"classification"), task_reason (why that
task was chosen β D-4), target_column, n_rows, n_features, `quality` (holdout
metric + floor + weak flag), `features`
(ranked, each with a signed coefficient and a normalised importance share),
`caveats`, and `charts` (the reserved auto-chart key).
Raises (all wrapped by the invoker's never-throw seam):
ColumnNotFoundError, NotEnoughDataError, NoUsableFeaturesError,
ConstantTargetError.
"""
if target_column not in df.columns:
raise ColumnNotFoundError(f"target column not found: {target_column!r}")
if feature_columns:
missing = [c for c in feature_columns if c not in df.columns]
if missing:
raise ColumnNotFoundError(f"feature columns not found: {missing}")
candidates = [c for c in feature_columns if c != target_column]
else:
candidates = [c for c in df.columns if c != target_column]
# Numeric features only; a column that is entirely non-numeric is dropped, not an
# error β the planner picking a text column shouldn't fail the whole analysis.
features = df[candidates].apply(pd.to_numeric, errors="coerce")
usable = [c for c in features.columns if features[c].notna().any()]
dropped = [c for c in candidates if c not in usable]
# Drop zero-variance features BEFORE the cap. A column that never changes cannot
# explain one that does β its standardised coefficient is zero by construction β
# so keeping it is not merely useless: `capped` takes the first _MAX_FEATURES in
# COLUMN ORDER, so every constant kept silently evicts a real candidate further
# right. The demo file has 8 constant columns among 36 and lost 3 genuine ones
# that way ("only the first 20 of 23"). Same blindness as A28, one layer down.
constant = [c for c in usable if features[c].nunique(dropna=True) <= 1]
usable = [c for c in usable if c not in constant]
if not usable:
raise NoUsableFeaturesError(
f"no numeric feature column among {candidates} varies; nothing to rank"
)
capped = usable[:_MAX_FEATURES]
target = df[target_column]
# D-4: distinguish a true categorical target from a low-cardinality ORDINAL integer
# so an integer metric isn't silently factorized into unrelated classes. The reason
# is surfaced in the output (`task_reason`) and, when it's a numeric-turned-class, as
# a caveat β so the choice is never invisible.
classify, task_reason = _infer_task(target)
frame = features[capped].join(target.rename("__target__"))
frame = frame.dropna()
if frame.shape[0] < _MIN_ROWS:
raise NotEnoughDataError(
f"need >= {_MIN_ROWS} rows with a value in {target_column!r} and every "
f"feature to rank importance, got {frame.shape[0]}"
)
y_raw = frame["__target__"]
if y_raw.nunique() <= 1:
raise ConstantTargetError(
f"{target_column!r} never varies in these rows, so nothing can explain it"
)
x = frame[capped].to_numpy(dtype=float)
if classify:
y = pd.factorize(y_raw)[0].astype(float)
else:
y = pd.to_numeric(y_raw, errors="coerce").to_numpy(dtype=float)
coefs, metric, metric_value = await run_fit(
_fit_linear, x, y, classify, _RANDOM_STATE, label="analyze_importance"
)
magnitudes = np.abs(coefs)
total = float(magnitudes.sum())
ranked = sorted(
(
{
"feature": col,
"coefficient": round(float(coef), 4),
"direction": "increases" if coef > 0 else "decreases",
"importance": round(float(mag / total) if total else 0.0, 4),
}
for col, coef, mag in zip(capped, coefs, magnitudes, strict=True)
),
key=lambda r: r["importance"],
reverse=True,
)
if top_n:
ranked = ranked[:top_n]
caveats = [
"Association, not causation β a high-importance column moves with the target, "
"which is not evidence that changing it would change the target.",
f"Ranking is from a linear model scored on a {int(_TEST_SIZE * 100)}% holdout; "
f"{metric} = {metric_value:.3f}.",
]
if metric_value < _WEAK_FIT:
caveats.append(
f"The model barely explains {target_column!r} ({metric} = {metric_value:.3f}), "
"so this ranking is weak evidence β treat the order as tentative."
)
# D-4: a numeric target that still ends up as classification is the surprising case
# β make it loud, not just present in task_reason.
if classify and pd.to_numeric(target, errors="coerce").notna().any():
caveats.append(
f"Target {target_column!r} is numeric but treated as classification "
f"({task_reason}); if it should be a continuous outcome, pass it as such."
)
if dropped:
caveats.append(f"Non-numeric columns skipped: {dropped}.")
if constant:
caveats.append(
f"Columns with the same value in every row were skipped (they cannot "
f"explain anything): {constant}."
)
if len(usable) > _MAX_FEATURES:
caveats.append(
f"Only the first {_MAX_FEATURES} of {len(usable)} numeric columns were used."
)
return {
"task": "classification" if classify else "regression",
"task_reason": task_reason,
"target_column": target_column,
"n_rows": int(frame.shape[0]),
"n_features": len(capped),
# Reserved `quality` block (conventions Β§3): every ML tool reports its own
# score and whether it clears its own floor. The tool owns that judgement β
# it knows what "good" means for its method β and the checkpoint's CK7 just
# surfaces `weak` deterministically, so a new tool needs no checkpoint change.
"quality": {
"metric": metric,
"value": round(metric_value, 4),
"floor": _WEAK_FIT,
"weak": metric_value < _WEAK_FIT,
},
"features": ranked,
"caveats": caveats,
"charts": [_importance_chart(ranked, target_column)],
}
|