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)],
    }