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.
- glin_ml-0.1.0/.gitignore +7 -0
- glin_ml-0.1.0/Dockerfile +13 -0
- glin_ml-0.1.0/LICENSE +21 -0
- glin_ml-0.1.0/PKG-INFO +117 -0
- glin_ml-0.1.0/README.md +86 -0
- glin_ml-0.1.0/glin/__init__.py +3 -0
- glin_ml-0.1.0/glin/cli.py +100 -0
- glin_ml-0.1.0/glin/engine.py +260 -0
- glin_ml-0.1.0/glin/preprocessor.py +159 -0
- glin_ml-0.1.0/glin/server.py +72 -0
- glin_ml-0.1.0/glin/validation.py +161 -0
- glin_ml-0.1.0/pyproject.toml +53 -0
- glin_ml-0.1.0/tests/__init__.py +0 -0
- glin_ml-0.1.0/tests/test_engine_additivity.py +124 -0
- glin_ml-0.1.0/tests/test_preprocessor.py +186 -0
- glin_ml-0.1.0/tests/test_server_transports.py +144 -0
- glin_ml-0.1.0/tests/test_validation.py +102 -0
glin_ml-0.1.0/.gitignore
ADDED
glin_ml-0.1.0/Dockerfile
ADDED
|
@@ -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.
|
glin_ml-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
}
|