interox 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.
- interox-0.1.0/.gitignore +18 -0
- interox-0.1.0/PKG-INFO +189 -0
- interox-0.1.0/README.md +163 -0
- interox-0.1.0/interox/__init__.py +37 -0
- interox-0.1.0/interox/explanation_status_widget.py +22 -0
- interox-0.1.0/interox/helpers.py +131 -0
- interox-0.1.0/interox/interox.py +1425 -0
- interox-0.1.0/interox/rule_analyzer.py +300 -0
- interox-0.1.0/interox/rule_builder_widget.py +324 -0
- interox-0.1.0/interox/rule_detail_widget.py +165 -0
- interox-0.1.0/interox/ruleset_table_widget.py +109 -0
- interox-0.1.0/interox/static/explanation_status.js +238 -0
- interox-0.1.0/interox/static/rule_builder.js +1523 -0
- interox-0.1.0/interox/static/rule_detail.js +534 -0
- interox-0.1.0/interox/static/ruleset_table.js +662 -0
- interox-0.1.0/js/build.mjs +30 -0
- interox-0.1.0/js/package-lock.json +630 -0
- interox-0.1.0/js/package.json +21 -0
- interox-0.1.0/js/src/common/data.ts +122 -0
- interox-0.1.0/js/src/common/types.ts +66 -0
- interox-0.1.0/js/src/common/utils.ts +56 -0
- interox-0.1.0/js/src/explanation_status/App.tsx +25 -0
- interox-0.1.0/js/src/explanation_status/ExplanationStatus.tsx +159 -0
- interox-0.1.0/js/src/explanation_status/Flow.tsx +462 -0
- interox-0.1.0/js/src/explanation_status/data.ts +57 -0
- interox-0.1.0/js/src/explanation_status/geometry.ts +200 -0
- interox-0.1.0/js/src/explanation_status/index.tsx +24 -0
- interox-0.1.0/js/src/explanation_status/styles.css +184 -0
- interox-0.1.0/js/src/explanation_status/tokens.ts +27 -0
- interox-0.1.0/js/src/explanation_status/types.ts +20 -0
- interox-0.1.0/js/src/global.d.ts +4 -0
- interox-0.1.0/js/src/rule_builder/App.tsx +324 -0
- interox-0.1.0/js/src/rule_builder/AttrRail.tsx +309 -0
- interox-0.1.0/js/src/rule_builder/AttrRow.tsx +71 -0
- interox-0.1.0/js/src/rule_builder/CatBars.tsx +35 -0
- interox-0.1.0/js/src/rule_builder/EvalFooter.tsx +236 -0
- interox-0.1.0/js/src/rule_builder/ImpactPanel.tsx +83 -0
- interox-0.1.0/js/src/rule_builder/OptimizePanel.tsx +195 -0
- interox-0.1.0/js/src/rule_builder/RailSplitter.tsx +73 -0
- interox-0.1.0/js/src/rule_builder/RangeHist.tsx +170 -0
- interox-0.1.0/js/src/rule_builder/StorePopover.tsx +65 -0
- interox-0.1.0/js/src/rule_builder/atoms.tsx +83 -0
- interox-0.1.0/js/src/rule_builder/data.ts +129 -0
- interox-0.1.0/js/src/rule_builder/engine.ts +0 -0
- interox-0.1.0/js/src/rule_builder/index.tsx +401 -0
- interox-0.1.0/js/src/rule_builder/railGroups.ts +142 -0
- interox-0.1.0/js/src/rule_builder/styles.css +1468 -0
- interox-0.1.0/js/src/rule_builder/tokens.ts +12 -0
- interox-0.1.0/js/src/rule_builder/types.ts +141 -0
- interox-0.1.0/js/src/rule_detail/App.tsx +49 -0
- interox-0.1.0/js/src/rule_detail/ExperimentPanel.tsx +101 -0
- interox-0.1.0/js/src/rule_detail/ExperimentRow.tsx +85 -0
- interox-0.1.0/js/src/rule_detail/FeatureSummaryCell.tsx +145 -0
- interox-0.1.0/js/src/rule_detail/ImpactCell.tsx +31 -0
- interox-0.1.0/js/src/rule_detail/PrecisionRecallCell.tsx +33 -0
- interox-0.1.0/js/src/rule_detail/RuleDetailTable.tsx +206 -0
- interox-0.1.0/js/src/rule_detail/RuleMetrics.tsx +31 -0
- interox-0.1.0/js/src/rule_detail/data.ts +54 -0
- interox-0.1.0/js/src/rule_detail/index.tsx +37 -0
- interox-0.1.0/js/src/rule_detail/styles.css +480 -0
- interox-0.1.0/js/src/rule_detail/tokens.ts +15 -0
- interox-0.1.0/js/src/rule_detail/types.ts +67 -0
- interox-0.1.0/js/src/ruleset_table/App.tsx +38 -0
- interox-0.1.0/js/src/ruleset_table/FeatureSummaryCell.tsx +115 -0
- interox-0.1.0/js/src/ruleset_table/MetricCell.tsx +21 -0
- interox-0.1.0/js/src/ruleset_table/RuleRow.tsx +294 -0
- interox-0.1.0/js/src/ruleset_table/RulesetTable.tsx +218 -0
- interox-0.1.0/js/src/ruleset_table/SplitGlyphCell.tsx +86 -0
- interox-0.1.0/js/src/ruleset_table/data.ts +40 -0
- interox-0.1.0/js/src/ruleset_table/index.tsx +70 -0
- interox-0.1.0/js/src/ruleset_table/styles.css +608 -0
- interox-0.1.0/js/src/ruleset_table/tokens.ts +29 -0
- interox-0.1.0/js/src/ruleset_table/types.ts +56 -0
- interox-0.1.0/js/tsconfig.json +15 -0
- interox-0.1.0/pyproject.toml +63 -0
- interox-0.1.0/tests/conftest.py +64 -0
- interox-0.1.0/tests/test_helpers.py +84 -0
- interox-0.1.0/tests/test_interox.py +288 -0
- interox-0.1.0/tests/test_invariants.py +76 -0
- interox-0.1.0/tests/test_rule_analyzer.py +108 -0
- interox-0.1.0/tests/test_rule_builder_eval.py +35 -0
- interox-0.1.0/tests/test_rule_builder_optimize.py +87 -0
- interox-0.1.0/tests/test_rule_edit_transfer.py +180 -0
- interox-0.1.0/tests/test_spec_rule_evaluation.py +54 -0
interox-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
.python-version
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
|
|
10
|
+
# Test / type-check artefacts
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
|
|
14
|
+
# Frontend
|
|
15
|
+
js/node_modules/
|
|
16
|
+
|
|
17
|
+
# NOTE: interox/static/*.js is committed on purpose -- these are the built
|
|
18
|
+
# bundles the widgets load at import, so `pip install interox` needs no Node.
|
interox-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: interox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive, rule-based outcome explanation for process-mining case logs, as notebook widgets
|
|
5
|
+
Project-URL: Homepage, https://github.com/aaronkurz/interox
|
|
6
|
+
Project-URL: Repository, https://github.com/aaronkurz/interox
|
|
7
|
+
Project-URL: Issues, https://github.com/aaronkurz/interox/issues
|
|
8
|
+
Author: Aaron Kurz
|
|
9
|
+
Keywords: anywidget,case-log,explainability,jupyter,marimo,process-mining,rule-induction
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Jupyter
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Requires-Dist: anywidget<0.12,>=0.11
|
|
18
|
+
Requires-Dist: coolname>=5.0
|
|
19
|
+
Requires-Dist: eclear<0.2,>=0.1.0
|
|
20
|
+
Requires-Dist: numpy>=2.0
|
|
21
|
+
Requires-Dist: pandas<4,>=3.0
|
|
22
|
+
Requires-Dist: rulelearn-ripper-interactive<0.3,>=0.2.3
|
|
23
|
+
Requires-Dist: scikit-learn>=1.5
|
|
24
|
+
Requires-Dist: traitlets>=5.14
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# interox
|
|
28
|
+
|
|
29
|
+
Interactive outcome explanation for process-mining case logs.
|
|
30
|
+
|
|
31
|
+
`interox` helps you find out *why* some cases of a process end the way they do.
|
|
32
|
+
You hand it a case log and a boolean outcome; it suggests DNF rules (via a fork
|
|
33
|
+
of RIPPER rule induction) that tell the outcome cases apart from the rest, and
|
|
34
|
+
gives you notebook widgets to inspect, store, edit and build rules until the
|
|
35
|
+
outcome is explained.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add interox # or: pip install interox
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The widgets ship pre-built inside the package, so using interox needs no Node
|
|
42
|
+
or JavaScript toolchain.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from interox import InterOX
|
|
48
|
+
|
|
49
|
+
iox = InterOX(outcome, case_log) # outcome: bool Series, case_log: DataFrame, same index
|
|
50
|
+
|
|
51
|
+
iox.get_exp_status() # how much of the outcome the stored rules explain
|
|
52
|
+
iox.get_suggested_ruleset() # induced suggestions, as a table widget
|
|
53
|
+
iox.get_stored_ruleset() # the rules you kept
|
|
54
|
+
iox.get_rule_builder() # build a rule from scratch
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Rendering needs a notebook that speaks [anywidget](https://anywidget.dev):
|
|
58
|
+
marimo, Jupyter, VS Code. The API reference is generated from the docstrings:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run pdoc interox
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A complete walkthrough on a public event log lives in
|
|
65
|
+
[interox-nodebook-sepsis](https://github.com/aaronkurz/interox-nodebook-sepsis).
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
Python 3.13+ and pandas 3. Case-log enrichment comes from
|
|
70
|
+
[eclear](https://pypi.org/project/eclear/) and rule induction from
|
|
71
|
+
[rulelearn-ripper-interactive](https://pypi.org/project/rulelearn-ripper-interactive/)
|
|
72
|
+
(imported as `rulelearn_ri`); both install as dependencies.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
Everything Python goes through uv:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv sync # creates .venv with interox (editable) + the dev group
|
|
80
|
+
./code-check.sh # pylint + mypy + pytest
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The widgets are React 18 + TypeScript in `js/`, bundled with esbuild into
|
|
84
|
+
`interox/static/*.js`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cd js
|
|
88
|
+
npm ci # install React, esbuild, TypeScript tooling
|
|
89
|
+
npm run build # minified production bundles → ../interox/static/
|
|
90
|
+
npm run dev # watch mode, unminified, for working on a widget
|
|
91
|
+
npm run typecheck # esbuild does not typecheck; run this separately
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for the architecture,
|
|
95
|
+
[DECISIONS.md](DECISIONS.md) for why it is shaped this way, and
|
|
96
|
+
[ubiquitous-language.md](ubiquitous-language.md) for the agreed vocabulary.
|
|
97
|
+
|
|
98
|
+
### How the compiled widgets get into the package
|
|
99
|
+
|
|
100
|
+
Each widget class points `_esm` at a file next to it, e.g.
|
|
101
|
+
`pathlib.Path(__file__).parent / "static" / "rule_builder.js"`. anywidget reads
|
|
102
|
+
that file when the widget is created, so the bundle has to be *inside* the
|
|
103
|
+
installed package. Two things make sure it is:
|
|
104
|
+
|
|
105
|
+
- `interox/static/*.js` is **committed on purpose** (the same choice as eclear).
|
|
106
|
+
Whatever is in git is what a build from a clean checkout ships, and a notebook
|
|
107
|
+
user never runs npm.
|
|
108
|
+
- `[tool.hatch.build.targets.wheel] packages = ["interox"]` puts the whole
|
|
109
|
+
`interox/` directory, `static/` included, into the wheel. `js/` and `tests/`
|
|
110
|
+
stay out of the wheel; the sdist carries them so it can be rebuilt.
|
|
111
|
+
|
|
112
|
+
So the rule is: **whenever anything under `js/src` changes, run
|
|
113
|
+
`npm run build` and commit the regenerated `interox/static/*.js` together with
|
|
114
|
+
the source change.** A stale bundle is the one packaging mistake that goes
|
|
115
|
+
unnoticed until someone opens a notebook.
|
|
116
|
+
|
|
117
|
+
## Releasing to PyPI
|
|
118
|
+
|
|
119
|
+
Run from the repo root. Nothing here is automated: an upload is irreversible,
|
|
120
|
+
so each step is yours to run.
|
|
121
|
+
|
|
122
|
+
**1. Rebuild the widgets and re-verify** against exactly what you will ship:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
(cd js && npm ci && npm run typecheck && npm run build)
|
|
126
|
+
git status --short interox/static # changed bundles? commit them first
|
|
127
|
+
./code-check.sh
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**2. Bump the version** in `pyproject.toml` (`version = "0.1.0"`). A version
|
|
131
|
+
number can never be reused on PyPI, even after deleting the release.
|
|
132
|
+
|
|
133
|
+
**3. Build the distributions:**
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rm -rf dist/
|
|
137
|
+
uv build
|
|
138
|
+
uvx twine check dist/*
|
|
139
|
+
unzip -l dist/interox-*.whl
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The wheel listing must contain the four bundles
|
|
143
|
+
`interox/static/{rule_detail,ruleset_table,explanation_status,rule_builder}.js`
|
|
144
|
+
and must **not** contain `js/`, `node_modules` or `tests/`.
|
|
145
|
+
|
|
146
|
+
**4. Smoke-test the built wheel** from a directory that is *not* this repo,
|
|
147
|
+
otherwise Python imports the local `interox/` folder and you have tested
|
|
148
|
+
nothing:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
cd /tmp
|
|
152
|
+
uv run --isolated --no-project --with ~/Developer/interox/dist/interox-0.1.0-py3-none-any.whl \
|
|
153
|
+
python -c "import interox, interox.rule_builder_widget as w; print(interox.__file__); print(len(str(w.RuleBuilderWidget._esm)))"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The path must be inside a `site-packages`, and the length must be well above
|
|
157
|
+
zero: that is the bundle anywidget will send to the notebook.
|
|
158
|
+
|
|
159
|
+
**5. Dry run on TestPyPI** (separate site, separate account and token:
|
|
160
|
+
<https://test.pypi.org/manage/account/token/>):
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
164
|
+
cd /tmp
|
|
165
|
+
uv run --isolated --no-project \
|
|
166
|
+
--index https://test.pypi.org/simple/ --index-strategy unsafe-best-match \
|
|
167
|
+
--with interox python -c "import interox; print(interox.__file__)"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The extra index flags are needed because the dependencies come from real PyPI.
|
|
171
|
+
|
|
172
|
+
**6. Publish for real.** Create a token at <https://pypi.org/manage/account/token/>.
|
|
173
|
+
For a project that does not exist yet it must be **account-scoped**; after the
|
|
174
|
+
first upload, replace it with a project-scoped one.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
read -rs UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && uv publish
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**7. Tag the release:**
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
git tag v0.1.0
|
|
184
|
+
git push origin main --tags
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Later, PyPI trusted publishing (a GitHub Actions workflow that uploads on a
|
|
188
|
+
pushed tag, no stored token) removes the token step:
|
|
189
|
+
<https://pypi.org/manage/project/interox/settings/publishing/>.
|
interox-0.1.0/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# interox
|
|
2
|
+
|
|
3
|
+
Interactive outcome explanation for process-mining case logs.
|
|
4
|
+
|
|
5
|
+
`interox` helps you find out *why* some cases of a process end the way they do.
|
|
6
|
+
You hand it a case log and a boolean outcome; it suggests DNF rules (via a fork
|
|
7
|
+
of RIPPER rule induction) that tell the outcome cases apart from the rest, and
|
|
8
|
+
gives you notebook widgets to inspect, store, edit and build rules until the
|
|
9
|
+
outcome is explained.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add interox # or: pip install interox
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The widgets ship pre-built inside the package, so using interox needs no Node
|
|
16
|
+
or JavaScript toolchain.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from interox import InterOX
|
|
22
|
+
|
|
23
|
+
iox = InterOX(outcome, case_log) # outcome: bool Series, case_log: DataFrame, same index
|
|
24
|
+
|
|
25
|
+
iox.get_exp_status() # how much of the outcome the stored rules explain
|
|
26
|
+
iox.get_suggested_ruleset() # induced suggestions, as a table widget
|
|
27
|
+
iox.get_stored_ruleset() # the rules you kept
|
|
28
|
+
iox.get_rule_builder() # build a rule from scratch
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Rendering needs a notebook that speaks [anywidget](https://anywidget.dev):
|
|
32
|
+
marimo, Jupyter, VS Code. The API reference is generated from the docstrings:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run pdoc interox
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A complete walkthrough on a public event log lives in
|
|
39
|
+
[interox-nodebook-sepsis](https://github.com/aaronkurz/interox-nodebook-sepsis).
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
Python 3.13+ and pandas 3. Case-log enrichment comes from
|
|
44
|
+
[eclear](https://pypi.org/project/eclear/) and rule induction from
|
|
45
|
+
[rulelearn-ripper-interactive](https://pypi.org/project/rulelearn-ripper-interactive/)
|
|
46
|
+
(imported as `rulelearn_ri`); both install as dependencies.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
Everything Python goes through uv:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv sync # creates .venv with interox (editable) + the dev group
|
|
54
|
+
./code-check.sh # pylint + mypy + pytest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The widgets are React 18 + TypeScript in `js/`, bundled with esbuild into
|
|
58
|
+
`interox/static/*.js`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd js
|
|
62
|
+
npm ci # install React, esbuild, TypeScript tooling
|
|
63
|
+
npm run build # minified production bundles → ../interox/static/
|
|
64
|
+
npm run dev # watch mode, unminified, for working on a widget
|
|
65
|
+
npm run typecheck # esbuild does not typecheck; run this separately
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for the architecture,
|
|
69
|
+
[DECISIONS.md](DECISIONS.md) for why it is shaped this way, and
|
|
70
|
+
[ubiquitous-language.md](ubiquitous-language.md) for the agreed vocabulary.
|
|
71
|
+
|
|
72
|
+
### How the compiled widgets get into the package
|
|
73
|
+
|
|
74
|
+
Each widget class points `_esm` at a file next to it, e.g.
|
|
75
|
+
`pathlib.Path(__file__).parent / "static" / "rule_builder.js"`. anywidget reads
|
|
76
|
+
that file when the widget is created, so the bundle has to be *inside* the
|
|
77
|
+
installed package. Two things make sure it is:
|
|
78
|
+
|
|
79
|
+
- `interox/static/*.js` is **committed on purpose** (the same choice as eclear).
|
|
80
|
+
Whatever is in git is what a build from a clean checkout ships, and a notebook
|
|
81
|
+
user never runs npm.
|
|
82
|
+
- `[tool.hatch.build.targets.wheel] packages = ["interox"]` puts the whole
|
|
83
|
+
`interox/` directory, `static/` included, into the wheel. `js/` and `tests/`
|
|
84
|
+
stay out of the wheel; the sdist carries them so it can be rebuilt.
|
|
85
|
+
|
|
86
|
+
So the rule is: **whenever anything under `js/src` changes, run
|
|
87
|
+
`npm run build` and commit the regenerated `interox/static/*.js` together with
|
|
88
|
+
the source change.** A stale bundle is the one packaging mistake that goes
|
|
89
|
+
unnoticed until someone opens a notebook.
|
|
90
|
+
|
|
91
|
+
## Releasing to PyPI
|
|
92
|
+
|
|
93
|
+
Run from the repo root. Nothing here is automated: an upload is irreversible,
|
|
94
|
+
so each step is yours to run.
|
|
95
|
+
|
|
96
|
+
**1. Rebuild the widgets and re-verify** against exactly what you will ship:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
(cd js && npm ci && npm run typecheck && npm run build)
|
|
100
|
+
git status --short interox/static # changed bundles? commit them first
|
|
101
|
+
./code-check.sh
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**2. Bump the version** in `pyproject.toml` (`version = "0.1.0"`). A version
|
|
105
|
+
number can never be reused on PyPI, even after deleting the release.
|
|
106
|
+
|
|
107
|
+
**3. Build the distributions:**
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
rm -rf dist/
|
|
111
|
+
uv build
|
|
112
|
+
uvx twine check dist/*
|
|
113
|
+
unzip -l dist/interox-*.whl
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The wheel listing must contain the four bundles
|
|
117
|
+
`interox/static/{rule_detail,ruleset_table,explanation_status,rule_builder}.js`
|
|
118
|
+
and must **not** contain `js/`, `node_modules` or `tests/`.
|
|
119
|
+
|
|
120
|
+
**4. Smoke-test the built wheel** from a directory that is *not* this repo,
|
|
121
|
+
otherwise Python imports the local `interox/` folder and you have tested
|
|
122
|
+
nothing:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
cd /tmp
|
|
126
|
+
uv run --isolated --no-project --with ~/Developer/interox/dist/interox-0.1.0-py3-none-any.whl \
|
|
127
|
+
python -c "import interox, interox.rule_builder_widget as w; print(interox.__file__); print(len(str(w.RuleBuilderWidget._esm)))"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The path must be inside a `site-packages`, and the length must be well above
|
|
131
|
+
zero: that is the bundle anywidget will send to the notebook.
|
|
132
|
+
|
|
133
|
+
**5. Dry run on TestPyPI** (separate site, separate account and token:
|
|
134
|
+
<https://test.pypi.org/manage/account/token/>):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
138
|
+
cd /tmp
|
|
139
|
+
uv run --isolated --no-project \
|
|
140
|
+
--index https://test.pypi.org/simple/ --index-strategy unsafe-best-match \
|
|
141
|
+
--with interox python -c "import interox; print(interox.__file__)"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The extra index flags are needed because the dependencies come from real PyPI.
|
|
145
|
+
|
|
146
|
+
**6. Publish for real.** Create a token at <https://pypi.org/manage/account/token/>.
|
|
147
|
+
For a project that does not exist yet it must be **account-scoped**; after the
|
|
148
|
+
first upload, replace it with a project-scoped one.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
read -rs UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && uv publish
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**7. Tag the release:**
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git tag v0.1.0
|
|
158
|
+
git push origin main --tags
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Later, PyPI trusted publishing (a GitHub Actions workflow that uploads on a
|
|
162
|
+
pushed tag, no stored token) removes the token step:
|
|
163
|
+
<https://pypi.org/manage/project/interox/settings/publishing/>.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# InterOX: Interactive Outcome Explanation for Case Logs
|
|
3
|
+
|
|
4
|
+
`interox` exposes the `InterOX` class, which supports iterative, rule-based
|
|
5
|
+
outcome explanation over a case log. The workflow is typically:
|
|
6
|
+
|
|
7
|
+
1. Inspect explanation progress.
|
|
8
|
+
2. Compare stored and suggested rules.
|
|
9
|
+
3. Store rules that improve coverage.
|
|
10
|
+
4. Optionally hide or unhide features and re-induce suggestions.
|
|
11
|
+
|
|
12
|
+
Start here
|
|
13
|
+
----------
|
|
14
|
+
- `InterOX.get_exp_status` — where the explanation stands.
|
|
15
|
+
- `InterOX.get_stored_ruleset` / `InterOX.get_suggested_ruleset` — the two
|
|
16
|
+
rulesets, with metrics, as table widgets.
|
|
17
|
+
- `InterOX.get_rule_builder` — build a rule from scratch instead of taking a
|
|
18
|
+
suggested one.
|
|
19
|
+
- `InterOX.send_rule_to_builder` — take a suggested rule into that builder to
|
|
20
|
+
adjust it before storing (the ✎ button on a suggested row).
|
|
21
|
+
- `InterOX.store_rule_by_id` / `InterOX.remove_rule_by_id` — change the stored
|
|
22
|
+
ruleset; both re-induce suggestions.
|
|
23
|
+
|
|
24
|
+
A rule is addressed by its **stable id**, which survives re-induction and
|
|
25
|
+
reordering: prefer the `*_by_id` methods over the positional ones of the same
|
|
26
|
+
name. `InterOX.get_stored_rule_options` maps rule names to those ids.
|
|
27
|
+
|
|
28
|
+
The rest of the class -- rule detail widgets, annotations, hidden features, and
|
|
29
|
+
the `get_*_case_log` / `get_data_*` accessors for analysis outside the provided
|
|
30
|
+
widgets -- is documented on the methods themselves, below.
|
|
31
|
+
"""
|
|
32
|
+
import logging
|
|
33
|
+
from .interox import InterOX
|
|
34
|
+
|
|
35
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
36
|
+
|
|
37
|
+
__all__ = ["InterOX"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# pylint: disable=missing-module-docstring
|
|
2
|
+
import pathlib
|
|
3
|
+
import anywidget
|
|
4
|
+
import traitlets
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExplanationStatusWidget(anywidget.AnyWidget): # pylint: disable=too-few-public-methods, abstract-method
|
|
8
|
+
"""A widget for displaying the status of an outcome explanation
|
|
9
|
+
in terms of remaining (FN), explained (TP) and wrongly claimed (FP) cases,
|
|
10
|
+
the negatives the explanation leaves alone (TN), and the number of rules the
|
|
11
|
+
explanation is currently made of.
|
|
12
|
+
|
|
13
|
+
The four case counts partition the case log, which lets the frontend draw
|
|
14
|
+
the flow from the case log through the rule set to the prediction. All
|
|
15
|
+
traits are Python-owned; the frontend never writes back."""
|
|
16
|
+
_esm = pathlib.Path(__file__).parent / "static" / "explanation_status.js"
|
|
17
|
+
|
|
18
|
+
cases_tbe = traitlets.Int(default_value=0).tag(sync=True)
|
|
19
|
+
exp_tp = traitlets.Int(default_value=0).tag(sync=True)
|
|
20
|
+
exp_fp = traitlets.Int(default_value=0).tag(sync=True)
|
|
21
|
+
exp_tn = traitlets.Int(default_value=0).tag(sync=True)
|
|
22
|
+
stored_rules = traitlets.Int(default_value=0).tag(sync=True)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Supporting functions for InterOX."""
|
|
2
|
+
import operator
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Any, Callable, Dict, List, Tuple
|
|
5
|
+
import logging
|
|
6
|
+
from rulelearn_ri.trxf.core import Conjunction, DnfRuleSet, Feature
|
|
7
|
+
from rulelearn_ri.trxf.core.predicate import Relation
|
|
8
|
+
from pandas import Series
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
#: The comparison each trxf relation stands for. Rule evaluation compares values
|
|
13
|
+
#: directly rather than rendering them into an expression to `eval`, so a value
|
|
14
|
+
#: is only ever compared, never parsed.
|
|
15
|
+
_RELATION_OPS = {
|
|
16
|
+
Relation.EQ: operator.eq,
|
|
17
|
+
Relation.NEQ: operator.ne,
|
|
18
|
+
Relation.LT: operator.lt,
|
|
19
|
+
Relation.LE: operator.le,
|
|
20
|
+
Relation.GT: operator.gt,
|
|
21
|
+
Relation.GE: operator.ge,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_usable_feature_name(name) -> bool:
|
|
26
|
+
"""Whether a column name can carry a rule condition.
|
|
27
|
+
|
|
28
|
+
`trxf` parses a feature name as an arithmetic expression, so a name is only
|
|
29
|
+
usable if it happens to be a valid one. Most punctuation survives -- `.`,
|
|
30
|
+
`:` and `::` are absorbed into a single token -- but `+ - * /` and
|
|
31
|
+
parentheses are read as operators, and a name that then fails to parse
|
|
32
|
+
(`*.concept:name::append`, `Payment_(late)::count`) cannot become a
|
|
33
|
+
`Feature` at all.
|
|
34
|
+
|
|
35
|
+
The failure modes are varied enough (`ValueError` from the expression
|
|
36
|
+
validator, `TokenError` from the tokenizer, `IndexError` from an unbalanced
|
|
37
|
+
parenthesis) that the only useful question is whether construction works.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
Feature(str(name))
|
|
41
|
+
return True
|
|
42
|
+
except Exception: # pylint: disable=broad-except
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rule_to_lambda(rule: Conjunction,
|
|
47
|
+
skip_feature=None,
|
|
48
|
+
only_feature=None) -> Callable[[Series], bool]:
|
|
49
|
+
"""Convert a rule (conjunction of predicates) to a lambda function
|
|
50
|
+
that evaluates the rule on a case.
|
|
51
|
+
|
|
52
|
+
A predicate becomes a column lookup and a comparison against the value the
|
|
53
|
+
predicate carries, whatever that value is. Nothing is rendered into source
|
|
54
|
+
text, so a category that looks like a number (`'nan'`, `'1e3'`), a value
|
|
55
|
+
holding a quote, and a column name holding an apostrophe all compare as
|
|
56
|
+
themselves. An empty conjunction -- every predicate filtered out, or none to
|
|
57
|
+
begin with -- matches every case.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
KeyError: When evaluating a case that has no column for a predicate's
|
|
61
|
+
feature.
|
|
62
|
+
"""
|
|
63
|
+
if skip_feature is not None and only_feature is not None:
|
|
64
|
+
raise ValueError("skip_feature and only_feature are mutually exclusive")
|
|
65
|
+
tests: List[Tuple[str, Callable[[Any, Any], bool], Any]] = []
|
|
66
|
+
for predicate in rule.predicates:
|
|
67
|
+
feature = str(predicate.feature)
|
|
68
|
+
if skip_feature is not None and feature == skip_feature:
|
|
69
|
+
continue
|
|
70
|
+
if only_feature is not None and feature != only_feature:
|
|
71
|
+
continue
|
|
72
|
+
logger.debug("Processing predicate: %s %s %s",
|
|
73
|
+
feature, predicate.relation.value, predicate.value)
|
|
74
|
+
try:
|
|
75
|
+
op = _RELATION_OPS[predicate.relation]
|
|
76
|
+
except KeyError as exc:
|
|
77
|
+
raise ValueError(f'Unknown relation {predicate.relation!r}') from exc
|
|
78
|
+
tests.append((feature, op, predicate.value))
|
|
79
|
+
|
|
80
|
+
return lambda case: all(op(case[feature], value) for feature, op, value in tests)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def conjunction_to_conditions(conjunction: Conjunction) -> List[Dict[str, Any]]:
|
|
84
|
+
"""Convert a rule into the condition list the rule builder canvas speaks.
|
|
85
|
+
|
|
86
|
+
The builder holds at most one condition per attribute, so the predicates are
|
|
87
|
+
grouped by feature first: a GE/LE pair on the same feature is the two sides
|
|
88
|
+
of one `range` condition, and a lone bound keeps its own operator. Predicates
|
|
89
|
+
beyond the first of each relation on a feature are dropped -- the canvas has
|
|
90
|
+
nowhere to put them. So are whole relations the canvas cannot express: NEQ,
|
|
91
|
+
LT and GT have no branch here, because a categorical condition is `==` only
|
|
92
|
+
(RIPPER induces no negation) and a numeric one is an inclusive bound. Note
|
|
93
|
+
this is narrower than `_RELATION_OPS` above, which still evaluates all six --
|
|
94
|
+
a rule can carry a relation the builder would not let you author.
|
|
95
|
+
|
|
96
|
+
This is the inverse of `RuleBuilderWidget._conditions_to_predicates`, and it
|
|
97
|
+
is what lets an already-induced rule be loaded back into the builder (see
|
|
98
|
+
`InterOX.send_rule_to_builder`).
|
|
99
|
+
"""
|
|
100
|
+
by_attr: Dict[str, List[Any]] = defaultdict(list)
|
|
101
|
+
for predicate in conjunction.predicates:
|
|
102
|
+
# `str`, as in `rule_to_lambda`: a predicate's feature is a `Feature` when
|
|
103
|
+
# it comes from the inducer but a bare column name when it was built by
|
|
104
|
+
# hand, and both render as the column name.
|
|
105
|
+
by_attr[str(predicate.feature)].append(predicate)
|
|
106
|
+
|
|
107
|
+
conditions: List[Dict[str, Any]] = []
|
|
108
|
+
for attr_name, preds in by_attr.items():
|
|
109
|
+
ge = next((p for p in preds if p.relation == Relation.GE), None)
|
|
110
|
+
le = next((p for p in preds if p.relation == Relation.LE), None)
|
|
111
|
+
eq = next((p for p in preds if p.relation == Relation.EQ), None)
|
|
112
|
+
if ge and le:
|
|
113
|
+
conditions.append({'attr': attr_name, 'op': 'range',
|
|
114
|
+
'lo': float(ge.value), 'hi': float(le.value)})
|
|
115
|
+
elif ge:
|
|
116
|
+
conditions.append({'attr': attr_name, 'op': '>=', 'value': float(ge.value)})
|
|
117
|
+
elif le:
|
|
118
|
+
conditions.append({'attr': attr_name, 'op': '<=', 'value': float(le.value)})
|
|
119
|
+
elif eq:
|
|
120
|
+
conditions.append({'attr': attr_name, 'op': '==', 'value': str(eq.value)})
|
|
121
|
+
return conditions
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ruleset_to_lambda(ruleset: DnfRuleSet, skip_feature=None) -> Callable[[Series], bool]:
|
|
125
|
+
"""Convert a ruleset (disjunction of conjunctions) to a lambda function
|
|
126
|
+
that evaluates the ruleset on a case."""
|
|
127
|
+
rule_lambdas = []
|
|
128
|
+
for conji in ruleset.conjunctions:
|
|
129
|
+
rule_lambdas.append(rule_to_lambda(conji, skip_feature))
|
|
130
|
+
|
|
131
|
+
return lambda case: any(rule_lambda(case) for rule_lambda in rule_lambdas)
|