glin-ml 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .pytest_cache/
6
+ build/
7
+ dist/
@@ -0,0 +1,13 @@
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml README.md LICENSE ./
6
+ COPY glin/ glin/
7
+
8
+ RUN pip install --no-cache-dir .
9
+
10
+ EXPOSE 8000
11
+
12
+ ENTRYPOINT ["glin"]
13
+ CMD ["serve", "--mode", "http", "--host", "0.0.0.0", "--port", "8000"]
glin_ml-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akash Chatterjee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
glin_ml-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: glin-ml
3
+ Version: 0.1.0
4
+ Summary: Instant, exactly-explainable statistical classifiers for AI agents, over MCP.
5
+ Project-URL: Homepage, https://github.com/AkashChatterjee/glin
6
+ Project-URL: Repository, https://github.com/AkashChatterjee/glin
7
+ Author-email: Akash Chatterjee <akashc2310@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,ebm,explainable-ai,machine-learning,mcp,model-context-protocol
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: click<9,>=8.1
21
+ Requires-Dist: interpret-core<0.8,>=0.7
22
+ Requires-Dist: joblib<2,>=1.3
23
+ Requires-Dist: mcp<2.0,>=1.0.0
24
+ Requires-Dist: numpy<3,>=1.24
25
+ Requires-Dist: pandas<3,>=2
26
+ Requires-Dist: scikit-learn<2,>=1.3
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
+ Requires-Dist: pytest>=7.4; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # glin
33
+
34
+ CLI tool and Python library that equips AI agents with an instant, statistical "gut feeling" (System 1 thinking).
35
+
36
+ `glin` trains an [Explainable Boosting Machine](https://interpret.ml/) on a CSV, then exposes it to LLM agents over [MCP](https://modelcontextprotocol.io/) — locally over stdio, or remotely over MCP's standard `streamable-http` transport. Every prediction comes with an exact, zero-approximation breakdown of which features drove it, straight from the model's own additive structure (no SHAP/LIME approximation).
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ ## Train a model
45
+
46
+ ```bash
47
+ glin train path/to/data.csv --target churn --name churn_v1
48
+ ```
49
+
50
+ Models are saved under `~/.glin/models/<name>/`.
51
+
52
+ ```bash
53
+ glin list
54
+ ```
55
+
56
+ ## Use it locally (Claude Desktop, Cursor, ...)
57
+
58
+ Add to your MCP client's config (e.g. `claude_desktop_config.json`):
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "glin": {
64
+ "command": "glin",
65
+ "args": ["serve", "--mode", "stdio"]
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Deploy it remotely (e.g. one EC2 box, any MCP-aware agent)
72
+
73
+ ```bash
74
+ docker build -t glin .
75
+ docker run -p 8000:8000 -v ~/.glin:/root/.glin glin
76
+ ```
77
+
78
+ Then point any MCP client at the standard streamable-http endpoint:
79
+
80
+ ```bash
81
+ claude mcp add --transport http glin http://<host>:8000/mcp
82
+ ```
83
+
84
+ ## Tools exposed over MCP
85
+
86
+ - `list_models()` — all trained models available.
87
+ - `inspect_model(model_name)` — feature schema and target classes.
88
+ - `predict(model_name, features, top_n=10)` — predicted class and probabilities, plus the full glassbox audit: base rate, every term's contribution (sorted by magnitude), and an explicit additivity check against the model's own predicted probability.
89
+
90
+ ## Data requirements
91
+
92
+ `glin train` validates your CSV before doing any work — hard problems (e.g. a target column with only one class) stop training with a clear error; soft issues (e.g. a date-like column) print a warning and training proceeds anyway. These rules live in `glin/validation.py` as a flat, appendable list, so support for a currently-unsupported shape below can be added by adding one rule and one preprocessing case, without touching the rest of the pipeline.
93
+
94
+ **Feature columns — supported today:**
95
+
96
+ | Data shape | What happens |
97
+ |---|---|
98
+ | Numeric (int/float), including `NaN` | Passed through as-is; EBM natively bins missing values into their own split. |
99
+ | Dirty numeric strings (blanks, e.g. `" "` for a new customer's total charges) | Coerced to `float64` if ≥80% of non-null values parse as numbers; unparseable cells become `NaN`. |
100
+ | Categorical strings, any cardinality up to 250 unique values | Standardized (lowercased, stripped) and one-hot-style binned by EBM; missing/unseen values map to a `__missing__` sentinel. |
101
+ | Boolean columns | Treated as a 0/1 continuous numeric feature. |
102
+
103
+ **Feature columns — not yet supported** (each is flagged by `glin train`'s validator when detected):
104
+
105
+ | Data shape | What actually happens | Why |
106
+ |---|---|---|
107
+ | Dates / timestamps | No date features are extracted. The column is either dropped (if high-cardinality) or kept as a meaningless categorical label — no time-based signal survives either way. | Cut from V1 scope; a real dataset need should drive adding cyclical date-feature extraction. |
108
+ | Free text / natural language | Dropped once it exceeds 250 unique values; below that threshold it becomes a set of (almost certainly useless) categorical labels. | glin doesn't do NLP; a text column isn't a set of classes. |
109
+ | Currency symbols / thousands separators (`$`, `€`, `£`, `,`) | Not stripped. `"$1,234.56"` fails the ≥80% numeric-parse threshold and falls back to categorical (i.e. garbage). Clean these before training. | Cut from V1 scope under an "assume a healthy dataset" simplification. |
110
+ | Lists / dicts / nested JSON in a cell | Not parsed structurally; treated as an opaque string. | No structured extraction implemented. |
111
+ | Row identifiers (sequential IDs, UUIDs, hashes) | Dropped intentionally — not a gap, this is by design. | IDs carry no predictive signal. |
112
+
113
+ **Target column requirements:**
114
+
115
+ - At least 2 distinct non-null values (binary or multiclass) — a single-class target is a hard error.
116
+ - Rows with a missing target value are dropped automatically (with a warning); the rest are unaffected.
117
+ - A numeric target with many distinct values (>20) triggers a warning that it looks like a regression target — `glin` trains classifiers, not regressors.
@@ -0,0 +1,86 @@
1
+ # glin
2
+
3
+ CLI tool and Python library that equips AI agents with an instant, statistical "gut feeling" (System 1 thinking).
4
+
5
+ `glin` trains an [Explainable Boosting Machine](https://interpret.ml/) on a CSV, then exposes it to LLM agents over [MCP](https://modelcontextprotocol.io/) — locally over stdio, or remotely over MCP's standard `streamable-http` transport. Every prediction comes with an exact, zero-approximation breakdown of which features drove it, straight from the model's own additive structure (no SHAP/LIME approximation).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install -e .
11
+ ```
12
+
13
+ ## Train a model
14
+
15
+ ```bash
16
+ glin train path/to/data.csv --target churn --name churn_v1
17
+ ```
18
+
19
+ Models are saved under `~/.glin/models/<name>/`.
20
+
21
+ ```bash
22
+ glin list
23
+ ```
24
+
25
+ ## Use it locally (Claude Desktop, Cursor, ...)
26
+
27
+ Add to your MCP client's config (e.g. `claude_desktop_config.json`):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "glin": {
33
+ "command": "glin",
34
+ "args": ["serve", "--mode", "stdio"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## Deploy it remotely (e.g. one EC2 box, any MCP-aware agent)
41
+
42
+ ```bash
43
+ docker build -t glin .
44
+ docker run -p 8000:8000 -v ~/.glin:/root/.glin glin
45
+ ```
46
+
47
+ Then point any MCP client at the standard streamable-http endpoint:
48
+
49
+ ```bash
50
+ claude mcp add --transport http glin http://<host>:8000/mcp
51
+ ```
52
+
53
+ ## Tools exposed over MCP
54
+
55
+ - `list_models()` — all trained models available.
56
+ - `inspect_model(model_name)` — feature schema and target classes.
57
+ - `predict(model_name, features, top_n=10)` — predicted class and probabilities, plus the full glassbox audit: base rate, every term's contribution (sorted by magnitude), and an explicit additivity check against the model's own predicted probability.
58
+
59
+ ## Data requirements
60
+
61
+ `glin train` validates your CSV before doing any work — hard problems (e.g. a target column with only one class) stop training with a clear error; soft issues (e.g. a date-like column) print a warning and training proceeds anyway. These rules live in `glin/validation.py` as a flat, appendable list, so support for a currently-unsupported shape below can be added by adding one rule and one preprocessing case, without touching the rest of the pipeline.
62
+
63
+ **Feature columns — supported today:**
64
+
65
+ | Data shape | What happens |
66
+ |---|---|
67
+ | Numeric (int/float), including `NaN` | Passed through as-is; EBM natively bins missing values into their own split. |
68
+ | Dirty numeric strings (blanks, e.g. `" "` for a new customer's total charges) | Coerced to `float64` if ≥80% of non-null values parse as numbers; unparseable cells become `NaN`. |
69
+ | Categorical strings, any cardinality up to 250 unique values | Standardized (lowercased, stripped) and one-hot-style binned by EBM; missing/unseen values map to a `__missing__` sentinel. |
70
+ | Boolean columns | Treated as a 0/1 continuous numeric feature. |
71
+
72
+ **Feature columns — not yet supported** (each is flagged by `glin train`'s validator when detected):
73
+
74
+ | Data shape | What actually happens | Why |
75
+ |---|---|---|
76
+ | Dates / timestamps | No date features are extracted. The column is either dropped (if high-cardinality) or kept as a meaningless categorical label — no time-based signal survives either way. | Cut from V1 scope; a real dataset need should drive adding cyclical date-feature extraction. |
77
+ | Free text / natural language | Dropped once it exceeds 250 unique values; below that threshold it becomes a set of (almost certainly useless) categorical labels. | glin doesn't do NLP; a text column isn't a set of classes. |
78
+ | Currency symbols / thousands separators (`$`, `€`, `£`, `,`) | Not stripped. `"$1,234.56"` fails the ≥80% numeric-parse threshold and falls back to categorical (i.e. garbage). Clean these before training. | Cut from V1 scope under an "assume a healthy dataset" simplification. |
79
+ | Lists / dicts / nested JSON in a cell | Not parsed structurally; treated as an opaque string. | No structured extraction implemented. |
80
+ | Row identifiers (sequential IDs, UUIDs, hashes) | Dropped intentionally — not a gap, this is by design. | IDs carry no predictive signal. |
81
+
82
+ **Target column requirements:**
83
+
84
+ - At least 2 distinct non-null values (binary or multiclass) — a single-class target is a hard error.
85
+ - Rows with a missing target value are dropped automatically (with a warning); the rest are unaffected.
86
+ - A numeric target with many distinct values (>20) triggers a warning that it looks like a regression target — `glin` trains classifiers, not regressors.
@@ -0,0 +1,3 @@
1
+ """glin: instant, exactly-explainable statistical classifiers for AI agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,100 @@
1
+ """glin CLI: train, list, serve."""
2
+ from __future__ import annotations
3
+
4
+ import click
5
+ import pandas as pd
6
+
7
+ from glin import engine, server
8
+ from glin.engine import DEFAULT_MODELS_ROOT
9
+ from glin.validation import validate_dataset
10
+
11
+
12
+ @click.group()
13
+ def main() -> None:
14
+ """glin: instant, exactly-explainable statistical classifiers for AI agents."""
15
+
16
+
17
+ @main.command()
18
+ @click.argument("csv_path", type=click.Path(exists=True, dir_okay=False))
19
+ @click.option("--target", required=True, help="Name of the target column to predict.")
20
+ @click.option("--name", required=True, help="Unique name to save this model under.")
21
+ def train(csv_path: str, target: str, name: str) -> None:
22
+ """Train an EBM classifier on CSV_PATH and save it as --name."""
23
+ df = pd.read_csv(csv_path)
24
+
25
+ validation = validate_dataset(df, target)
26
+ for issue in validation.warnings:
27
+ click.echo(f"Warning: {issue.message}")
28
+ if not validation.is_valid:
29
+ for issue in validation.errors:
30
+ click.echo(f"Error: {issue.message}")
31
+ raise SystemExit(1)
32
+
33
+ bundle = engine.train_model(df, target, model_name=name)
34
+ model_dir = engine.save_bundle(bundle, DEFAULT_MODELS_ROOT)
35
+
36
+ preprocessor = bundle["preprocessor"]
37
+ click.echo(f"Trained model '{name}' -> {model_dir}")
38
+ click.echo(f" Classes: {bundle['target_classes']}")
39
+ click.echo(
40
+ f" Features kept: {len(preprocessor.output_columns_)} "
41
+ f"({len(preprocessor.numeric_columns_)} numeric, "
42
+ f"{len(preprocessor.categorical_columns_)} categorical)"
43
+ )
44
+ click.echo(f" Features dropped: {preprocessor.dropped_columns_}")
45
+
46
+
47
+ @main.command(name="list")
48
+ def list_cmd() -> None:
49
+ """List all trained models."""
50
+ if not DEFAULT_MODELS_ROOT.exists():
51
+ click.echo("No models trained yet.")
52
+ return
53
+
54
+ rows = []
55
+ for model_dir in sorted(DEFAULT_MODELS_ROOT.iterdir()):
56
+ metadata_path = model_dir / engine.METADATA_FILENAME
57
+ if not metadata_path.exists():
58
+ continue
59
+ meta = engine.load_metadata(model_dir)
60
+ n_features = len(meta["features"]["numeric"]) + len(meta["features"]["categorical"])
61
+ rows.append(
62
+ (
63
+ meta["model_name"],
64
+ meta["target_column"],
65
+ ", ".join(str(c) for c in meta["target_classes"]),
66
+ str(n_features),
67
+ meta["created_at"],
68
+ )
69
+ )
70
+
71
+ if not rows:
72
+ click.echo("No models trained yet.")
73
+ return
74
+
75
+ headers = ("Model Name", "Target Column", "Classes", "Features Kept", "Created Date")
76
+ widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
77
+
78
+ def fmt_row(values: tuple[str, ...]) -> str:
79
+ return " ".join(v.ljust(w) for v, w in zip(values, widths))
80
+
81
+ click.echo(fmt_row(headers))
82
+ click.echo(fmt_row(tuple("-" * w for w in widths)))
83
+ for row in rows:
84
+ click.echo(fmt_row(row))
85
+
86
+
87
+ @main.command()
88
+ @click.option("--mode", type=click.Choice(["stdio", "http"]), default="stdio")
89
+ @click.option("--host", default="0.0.0.0")
90
+ @click.option("--port", default=8000, type=int)
91
+ def serve(mode: str, host: str, port: int) -> None:
92
+ """Run the glin MCP server (stdio for local clients, http for remote agents)."""
93
+ if mode == "stdio":
94
+ server.run_stdio()
95
+ else:
96
+ server.run_http(host=host, port=port)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,260 @@
1
+ """EBM training, exact local-explanation extraction, and joblib bundle I/O.
2
+
3
+ Glassbox math reference (verified against interpret-core==0.7.8 at
4
+ implementation time, see tests/test_engine_additivity.py):
5
+
6
+ binary: P(y=1|x) = sigmoid(intercept_[0] + sum(term contribution scores))
7
+ multiclass: P(y=k|x) = softmax(intercept_ + sum(term contribution score vectors))[k]
8
+
9
+ Interaction terms are identified via ``model.term_features_`` (index tuples
10
+ into ``feature_names_in_``), never by parsing ``term_names_`` strings — the
11
+ join delimiter is an implementation detail of the library, not a stable
12
+ contract.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import joblib
22
+ import numpy as np
23
+ import pandas as pd
24
+ from interpret.glassbox import ExplainableBoostingClassifier
25
+ from sklearn.preprocessing import LabelEncoder
26
+
27
+ from glin.preprocessor import EBMTabularPreprocessor
28
+ from glin.validation import validate_dataset
29
+
30
+ BUNDLE_FILENAME = "bundle.joblib"
31
+ METADATA_FILENAME = "metadata.json"
32
+ DEFAULT_MODELS_ROOT = Path.home() / ".glin" / "models"
33
+
34
+
35
+ def _to_native(value: Any) -> Any:
36
+ """numpy scalars aren't JSON-serializable; metadata.json needs plain types."""
37
+ if isinstance(value, np.generic):
38
+ return value.item()
39
+ return value
40
+
41
+
42
+ def _sigmoid(x: float) -> float:
43
+ return 1.0 / (1.0 + np.exp(-x))
44
+
45
+
46
+ def _softmax(x: np.ndarray) -> np.ndarray:
47
+ shifted = x - np.max(x)
48
+ exp = np.exp(shifted)
49
+ return exp / exp.sum()
50
+
51
+
52
+ def train_model(
53
+ df: pd.DataFrame,
54
+ target_column: str,
55
+ *,
56
+ model_name: str,
57
+ max_bins: int = 256,
58
+ interactions: int = 10,
59
+ random_state: int = 42,
60
+ ) -> dict[str, Any]:
61
+ """Fits EBMTabularPreprocessor on the feature columns and an
62
+ ExplainableBoostingClassifier on the transformed output. Works for
63
+ binary or multiclass targets (interpret-core automatically strips
64
+ interaction terms for multiclass models). Raises ValueError if the
65
+ dataset fails a hard validation rule (see glin.validation); returns any
66
+ soft warnings alongside the trained bundle."""
67
+ validation = validate_dataset(df, target_column)
68
+ if not validation.is_valid:
69
+ raise ValueError("; ".join(issue.message for issue in validation.errors))
70
+
71
+ df = df.dropna(subset=[target_column])
72
+ X_raw = df.drop(columns=[target_column])
73
+ y_raw = df[target_column]
74
+
75
+ label_encoder = LabelEncoder()
76
+ y = label_encoder.fit_transform(y_raw)
77
+ target_classes = [_to_native(c) for c in label_encoder.classes_]
78
+
79
+ preprocessor = EBMTabularPreprocessor()
80
+ X = preprocessor.fit_transform(X_raw)
81
+
82
+ if X.shape[1] == 0:
83
+ raise ValueError(
84
+ "every feature column was dropped (ID-like, constant, or too "
85
+ "high-cardinality) — nothing left to train on"
86
+ )
87
+
88
+ model = ExplainableBoostingClassifier(
89
+ feature_names=list(X.columns),
90
+ feature_types=preprocessor.get_feature_types(),
91
+ max_bins=max_bins,
92
+ interactions=interactions,
93
+ random_state=random_state,
94
+ )
95
+ model.fit(X, y)
96
+
97
+ return {
98
+ "model_name": model_name,
99
+ "target_column": target_column,
100
+ "preprocessor": preprocessor,
101
+ "model": model,
102
+ "target_classes": target_classes,
103
+ "warnings": [issue.message for issue in validation.warnings],
104
+ }
105
+
106
+
107
+ def save_bundle(bundle: dict[str, Any], models_root: Path) -> Path:
108
+ model_dir = Path(models_root) / bundle["model_name"]
109
+ model_dir.mkdir(parents=True, exist_ok=True)
110
+
111
+ joblib.dump(
112
+ {
113
+ "preprocessor": bundle["preprocessor"],
114
+ "model": bundle["model"],
115
+ "target_classes": bundle["target_classes"],
116
+ },
117
+ model_dir / BUNDLE_FILENAME,
118
+ )
119
+
120
+ preprocessor: EBMTabularPreprocessor = bundle["preprocessor"]
121
+ model = bundle["model"]
122
+ metadata = {
123
+ "model_name": bundle["model_name"],
124
+ "target_column": bundle["target_column"],
125
+ "target_classes": bundle["target_classes"],
126
+ "features": {
127
+ "numeric": preprocessor.numeric_columns_,
128
+ "categorical": preprocessor.categorical_columns_,
129
+ },
130
+ "dropped_columns": preprocessor.dropped_columns_,
131
+ "hyperparameters": {
132
+ "max_bins": _to_native(model.max_bins),
133
+ "interactions": _to_native(model.interactions),
134
+ "random_state": _to_native(model.random_state),
135
+ },
136
+ "created_at": datetime.now(timezone.utc).isoformat(),
137
+ }
138
+ (model_dir / METADATA_FILENAME).write_text(json.dumps(metadata, indent=2))
139
+ return model_dir
140
+
141
+
142
+ def load_bundle(model_dir: Path) -> dict[str, Any]:
143
+ return joblib.load(Path(model_dir) / BUNDLE_FILENAME)
144
+
145
+
146
+ def load_metadata(model_dir: Path) -> dict[str, Any]:
147
+ return json.loads((Path(model_dir) / METADATA_FILENAME).read_text())
148
+
149
+
150
+ def predict_record(bundle: dict[str, Any], features: dict[str, Any]) -> dict[str, Any]:
151
+ X = bundle["preprocessor"].transform(features)
152
+ proba = bundle["model"].predict_proba(X)[0]
153
+ target_classes = bundle["target_classes"]
154
+ predicted_idx = int(np.argmax(proba))
155
+
156
+ return {
157
+ "predicted_class": target_classes[predicted_idx],
158
+ "probabilities": {
159
+ str(target_classes[i]): float(proba[i]) for i in range(len(target_classes))
160
+ },
161
+ }
162
+
163
+
164
+ def _term_contributions(
165
+ model: ExplainableBoostingClassifier, data: dict, features: dict[str, Any]
166
+ ) -> list[dict[str, Any]]:
167
+ feature_names_in = list(model.feature_names_in_)
168
+ contributions = []
169
+
170
+ for i, term_idx_tuple in enumerate(model.term_features_):
171
+ is_interaction = len(term_idx_tuple) > 1
172
+ involved = [feature_names_in[idx] for idx in term_idx_tuple]
173
+ raw_value = (
174
+ [features.get(f) for f in involved] if is_interaction else features.get(involved[0])
175
+ )
176
+ contributions.append(
177
+ {
178
+ "feature": " & ".join(involved),
179
+ "is_interaction": is_interaction,
180
+ "value": raw_value,
181
+ "score": np.asarray(data["scores"][i], dtype=float),
182
+ }
183
+ )
184
+ return contributions
185
+
186
+
187
+ def explain_record(
188
+ bundle: dict[str, Any], features: dict[str, Any], top_n: int = 10
189
+ ) -> dict[str, Any]:
190
+ """Full glassbox audit: intercept, per-term contributions (sorted by
191
+ magnitude, main effects and interactions together), and an explicit
192
+ additivity check against the model's own predict_proba. Binary targets
193
+ use the PRD's exact sigmoid formula; targets with more than two classes
194
+ generalize it via softmax over per-class score vectors."""
195
+ preprocessor = bundle["preprocessor"]
196
+ model = bundle["model"]
197
+ target_classes = bundle["target_classes"]
198
+ n_classes = len(target_classes)
199
+
200
+ X = preprocessor.transform(features)
201
+ data = model.explain_local(X).data(0)
202
+ contributions = _term_contributions(model, data, features)
203
+ proba = model.predict_proba(X)[0]
204
+ intercept = np.asarray(model.intercept_, dtype=float)
205
+ predicted_class = target_classes[int(np.argmax(proba))]
206
+
207
+ if n_classes == 2:
208
+ base_rate = float(intercept[0])
209
+ total_logit = base_rate + sum(float(c["score"]) for c in contributions)
210
+
211
+ for c in contributions:
212
+ c["score"] = float(c["score"])
213
+ c["direction"] = (
214
+ f"favors {target_classes[1]}" if c["score"] > 0 else f"favors {target_classes[0]}"
215
+ )
216
+
217
+ contributions.sort(key=lambda c: abs(c["score"]), reverse=True)
218
+
219
+ reconstructed = float(_sigmoid(total_logit))
220
+ model_probability = float(proba[1])
221
+
222
+ return {
223
+ "predicted_class": predicted_class,
224
+ "base_rate": base_rate,
225
+ "probabilities": {
226
+ str(target_classes[i]): float(proba[i]) for i in range(n_classes)
227
+ },
228
+ "contributions": contributions[:top_n],
229
+ "reconstructed_probability": reconstructed,
230
+ "model_probability": model_probability,
231
+ "additivity_verified": bool(abs(reconstructed - model_probability) < 1e-3),
232
+ }
233
+
234
+ # Multiclass: each term's score is an (n_classes,)-shaped vector; the
235
+ # additive link is softmax rather than sigmoid.
236
+ total_logits = intercept + sum(c["score"] for c in contributions)
237
+
238
+ for c in contributions:
239
+ top_class_idx = int(np.argmax(c["score"]))
240
+ c["score_by_class"] = {
241
+ str(target_classes[k]): float(c["score"][k]) for k in range(n_classes)
242
+ }
243
+ c["direction"] = f"favors {target_classes[top_class_idx]}"
244
+ c["score"] = float(np.max(np.abs(c["score"])))
245
+
246
+ contributions.sort(key=lambda c: abs(c["score"]), reverse=True)
247
+
248
+ reconstructed_vec = _softmax(total_logits)
249
+ reconstructed = {str(target_classes[k]): float(reconstructed_vec[k]) for k in range(n_classes)}
250
+ model_probability = {str(target_classes[k]): float(proba[k]) for k in range(n_classes)}
251
+
252
+ return {
253
+ "predicted_class": predicted_class,
254
+ "base_rate": {str(target_classes[k]): float(intercept[k]) for k in range(n_classes)},
255
+ "probabilities": model_probability,
256
+ "contributions": contributions[:top_n],
257
+ "reconstructed_probability": reconstructed,
258
+ "model_probability": model_probability,
259
+ "additivity_verified": bool(np.all(np.abs(reconstructed_vec - proba) < 1e-3)),
260
+ }