Download src/tools/analytics/anomaly.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/tools/analytics/anomaly.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/src/tools/analytics/anomaly.py
-
curl -L -o anomaly.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/src/tools/analytics/anomaly.py
10.6 kB
| """analyze_anomaly — flag unusual rows with Isolation Forest (§0.9). | |
| Second ML analytical tool. Scores rows across one or more numeric columns with a | |
| scikit-learn IsolationForest (fit OFF the event loop via `run_fit`), flags the | |
| outliers, and returns a summary + the flagged rows + a scatter with the anomaly | |
| points highlighted ("sepaket"). | |
| 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_forecast'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 anomaly_scatter_chart | |
| from src.tools.analytics.descriptive import ColumnNotFoundError | |
| from src.tools.analytics.temporal import _clean | |
| from src.tools.ml.executor import run_fit | |
| from src.tools.spec_builder import build_description | |
| # IsolationForest is meaningless on a handful of rows; below this we raise. | |
| _MIN_ROWS = 10 | |
| # Cap the returned anomaly SAMPLE (the count stays exact) so a tool output can't | |
| # balloon to tens of thousands of tokens — the list is illustrative, not the result. | |
| # The full total is reported alongside as `anomalies_total`. A23: was 200; a bounded | |
| # top-by-score sample is the right payload for an LLM-facing tool (defense-in-depth | |
| # with the assembler's own cap). | |
| _MAX_ANOMALY_ROWS = 50 | |
| # Fixed so the same data always flags the same rows (family-B determinism promise). | |
| _RANDOM_STATE = 42 | |
| # Above this share of rows flagged, the result is not "a few anomalies" — the notion | |
| # has collapsed and the answer is noise. The reserved `quality` block reads `weak` off | |
| # it. NOT a naive low ceiling: A20 reverted one because `contamination="auto"` flags a | |
| # large slice of CLEAN data. Re-measured 2026-09-21 — clean gaussian noise flags 0.14–0.22 | |
| # (mean 0.17, p95 0.22), a genuine 30%-outlier frame flags 0.30; the demo's degenerate | |
| # run flagged 0.86. 0.5 clears both the noise floor and a real high-outlier finding while | |
| # still catching "most rows flagged". | |
| _ANOMALY_RATE_CEILING = 0.5 | |
| # Internal column names for the highlighted scatter (never collide with user data). | |
| _FLAG_COL = "__is_anomaly__" | |
| _INDEX_COL = "__row__" | |
| class NotEnoughDataError(ValueError): | |
| """Too few valid rows to fit an anomaly model (error_code NOT_ENOUGH_DATA).""" | |
| class EmptyColumnSelectionError(ValueError): | |
| """column_ids was empty (error_code EMPTY_COLUMN_SELECTION).""" | |
| class AnomalyInput(BaseModel): | |
| # `data` FIRST — property order follows field order (planner-prompt readability). | |
| data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).") | |
| column_ids: list[str] = Field( | |
| ..., description="One or more NUMERIC columns to score rows for anomalies." | |
| ) | |
| label_columns: list[str] | None = Field( | |
| None, description="Identifier columns (e.g. unit/equipment no.) to carry into each " | |
| "flagged record so the anomaly can be NAMED. Never fed to the model — pass the id " | |
| "columns whenever the question asks 'which X is unusual'." | |
| ) | |
| contamination: float | None = Field( | |
| None, description="Expected fraction of anomalies (0-0.5); omit for automatic." | |
| ) | |
| DESCRIPTION = build_description( | |
| summary="Flag unusual/outlier rows across numeric columns with an Isolation Forest.", | |
| use_when="the user asks to find anomalies, outliers, or 'weird'/'unusual' records " | |
| "— 'ada anomali', 'outlier', 'data aneh', 'yang nggak wajar'.", | |
| dont_use_when=[ | |
| "the user wants a plain distribution/spread of one column -> analyze_descriptive", | |
| "the user wants movement over time or a projection -> analyze_trend / analyze_forecast", | |
| "the user wants what DRIVES a metric -> analyze_importance", | |
| ], | |
| output="a summary (row/anomaly counts, rate) + the flagged rows (named by any " | |
| "label_columns given) + a quality verdict on the flag rate + a scatter with " | |
| "anomaly points highlighted.", | |
| examples=[ | |
| "ada anomali nggak di data bulan ini?", | |
| "detect outliers in the sensor readings", | |
| "unit mana yang angkanya nggak wajar?", | |
| ], | |
| ) | |
| def _fit_iforest(x: np.ndarray, contamination, random_state: int): | |
| """Fit an IsolationForest. Imported lazily so app startup doesn't pay for | |
| scikit-learn unless an anomaly analysis runs. Runs inside run_fit's thread.""" | |
| from sklearn.ensemble import IsolationForest | |
| model = IsolationForest(contamination=contamination, random_state=random_state) | |
| model.fit(x) | |
| return model | |
| def _flag_rate_quality(anomaly_rate: float) -> dict[str, object]: | |
| """Reserved `quality` block from the flag rate (conventions §3). | |
| Split out so both branches are unit-testable without a model run — the fit can't be | |
| coaxed above a 0.5 flag rate through `contamination` (sklearn caps it there), so the | |
| weak branch has no organic fixture. | |
| """ | |
| weak = anomaly_rate > _ANOMALY_RATE_CEILING | |
| return { | |
| "metric": "anomaly_rate", | |
| "value": round(anomaly_rate, 4), | |
| "floor": _ANOMALY_RATE_CEILING, | |
| "weak": weak, | |
| "note": ( | |
| f"{anomaly_rate:.0%} of rows flagged — above half the data, so this is " | |
| "noise, not a few outliers" | |
| if weak | |
| else f"{anomaly_rate:.0%} of rows flagged as anomalous" | |
| ), | |
| } | |
| def _anomaly_chart( | |
| df: pd.DataFrame, column_ids: list[str], is_anomaly: pd.Series | |
| ) -> dict[str, object]: | |
| """Scatter with anomalies highlighted: first two numeric columns, or row-index | |
| vs the single column when only one was scored.""" | |
| if len(column_ids) >= 2: | |
| chart_df = df.assign(**{_FLAG_COL: is_anomaly.to_numpy()}) | |
| return anomaly_scatter_chart(chart_df, column_ids[0], column_ids[1], _FLAG_COL) | |
| col = column_ids[0] | |
| chart_df = pd.DataFrame( | |
| {_INDEX_COL: range(len(df)), col: df[col].to_numpy(), _FLAG_COL: is_anomaly.to_numpy()} | |
| ) | |
| return anomaly_scatter_chart(chart_df, _INDEX_COL, col, _FLAG_COL) | |
| async def analyze_anomaly( | |
| df: pd.DataFrame, | |
| column_ids: list[str], | |
| contamination: float | None = None, | |
| label_columns: list[str] | None = None, | |
| ) -> dict[str, object]: | |
| """Flag anomalous rows across `column_ids` with an Isolation Forest. | |
| 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`. | |
| `label_columns` are identifier columns carried into each flagged record so the | |
| anomaly can be NAMED (e.g. `eqm_no`). They are NEVER fed to the model — only the | |
| numeric `column_ids` are scored. | |
| Returns a dict with: method, column_ids, contamination, n_rows, n_anomalies, | |
| anomaly_rate, anomalies_total (= n_anomalies) / anomalies_shown (how many the | |
| capped sample actually carries), `anomalies` (the flagged rows, top by score, | |
| capped at _MAX_ANOMALY_ROWS), a reserved `quality` block (flag-rate verdict), and | |
| `charts` (the reserved auto-chart key). | |
| Raises (all wrapped by the invoker's never-throw seam): | |
| ColumnNotFoundError, EmptyColumnSelectionError, NotEnoughDataError. | |
| """ | |
| if not column_ids: | |
| raise EmptyColumnSelectionError("column_ids must name at least one numeric column") | |
| labels = list(label_columns) if label_columns else [] | |
| missing = [c for c in column_ids + labels if c not in df.columns] | |
| if missing: | |
| raise ColumnNotFoundError(f"columns not found: {missing}") | |
| # Coerce to numeric; a row is scorable only if every selected column is numeric. | |
| features = df[column_ids].apply(pd.to_numeric, errors="coerce") | |
| valid = features.notna().all(axis=1) | |
| scored = features[valid] | |
| if scored.shape[0] < _MIN_ROWS: | |
| raise NotEnoughDataError( | |
| f"need >= {_MIN_ROWS} rows with numeric values in all of {column_ids} " | |
| f"to detect anomalies, got {scored.shape[0]}" | |
| ) | |
| contam = "auto" if contamination is None else contamination | |
| x = scored.to_numpy(dtype=float) | |
| model = await run_fit(_fit_iforest, x, contam, _RANDOM_STATE, label="analyze_anomaly") | |
| flags = model.predict(x) == -1 # sklearn: -1 anomaly, 1 normal | |
| scores = -model.score_samples(x) # higher = more anomalous | |
| is_anomaly = pd.Series(False, index=df.index) | |
| is_anomaly.loc[scored.index] = flags | |
| # Flagged rows, most anomalous first, capped. | |
| flagged_idx = scored.index[flags] | |
| flagged_scores = scores[flags] | |
| order = np.argsort(flagged_scores)[::-1] | |
| anomalies: list[dict[str, object]] = [] | |
| for pos in order[:_MAX_ANOMALY_ROWS]: | |
| idx = flagged_idx[pos] | |
| # Identifiers FIRST so a reader sees WHICH row before its numbers. Pulled from | |
| # the original df (labels are non-numeric and never entered `scored`). | |
| record: dict[str, object] = {c: _clean(df.at[idx, c]) for c in labels} | |
| record.update({c: _clean(scored.at[idx, c]) for c in column_ids}) | |
| record["anomaly_score"] = round(float(flagged_scores[pos]), 4) | |
| anomalies.append(record) | |
| anomaly_rate = float(flags.mean()) if scored.shape[0] else 0.0 | |
| return { | |
| "method": "isolation_forest", | |
| "column_ids": column_ids, | |
| "contamination": contam, | |
| "n_rows": int(scored.shape[0]), | |
| "n_anomalies": int(flags.sum()), | |
| "anomaly_rate": anomaly_rate, | |
| # `anomalies` is a bounded top-by-score SAMPLE, not the full set. These two | |
| # keep it honest: total says how many there really are, shown says how many | |
| # the sample carries. When total > shown the caller knows it's truncated. | |
| "anomalies_total": int(flags.sum()), | |
| "anomalies_shown": len(anomalies), | |
| "anomalies": anomalies, | |
| # Reserved `quality` block (conventions §3): the tool owns its own verdict; CK7 | |
| # surfaces `weak`. Isolation Forest has no accuracy score, so the honest signal | |
| # is the flag RATE — an unsupervised detector that flags most of the data found | |
| # nothing. `weak` fires only ABOVE _ANOMALY_RATE_CEILING (0.5), clear of the | |
| # ~0.22 clean-data floor A20 measured, so a normal run is never marked weak; the | |
| # note reports the rate plainly either way (A20's fallback, kept as the message). | |
| "quality": _flag_rate_quality(anomaly_rate), | |
| "charts": [_anomaly_chart(df, column_ids, is_anomaly)], | |
| } | |