ndscape 0.1.0__tar.gz → 0.1.2__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.
ndscape-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxwell Dix-Matthews
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.
@@ -0,0 +1 @@
1
+ prune tests
@@ -1,21 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndscape
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
5
5
  Author: Maxwell Dix-Matthews
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/res-lucid/ndscape
7
8
  Project-URL: Repository, https://github.com/res-lucid/ndscape
9
+ Project-URL: Issues, https://github.com/res-lucid/ndscape/issues
10
+ Project-URL: Interactive maps, https://res-lucid.github.io/ndscape-plots/
11
+ Project-URL: Data archive, https://doi.org/10.5281/zenodo.20456943
8
12
  Keywords: nested-dichotomy,multi-class,classification,scikit-learn
9
13
  Classifier: Programming Language :: Python :: 3
10
14
  Classifier: Intended Audience :: Science/Research
11
15
  Classifier: Topic :: Scientific/Engineering
12
16
  Requires-Python: >=3.9
13
17
  Description-Content-Type: text/markdown
18
+ License-File: LICENSE
14
19
  Requires-Dist: numpy>=1.24
15
20
  Requires-Dist: scikit-learn>=1.3
21
+ Requires-Dist: joblib>=1.2
16
22
  Provides-Extra: spatial
17
23
  Requires-Dist: esda>=2.6; extra == "spatial"
18
24
  Requires-Dist: libpysal>=4.10; extra == "spatial"
25
+ Provides-Extra: plot
26
+ Requires-Dist: matplotlib>=3.5; extra == "plot"
27
+ Requires-Dist: bokeh>=3.0; extra == "plot"
28
+ Dynamic: license-file
19
29
 
20
30
  # ndscape
21
31
 
@@ -32,6 +42,7 @@ tree structures.
32
42
  ```
33
43
  pip install ndscape
34
44
  pip install ndscape[spatial] # adds Moran's I support (esda, libpysal)
45
+ pip install ndscape[plot] # adds plotting (matplotlib, bokeh)
35
46
  ```
36
47
 
37
48
  ## Quickstart
@@ -96,6 +107,26 @@ rows = nds.analyze(X_train, y_train, classes, X_test=X_test, y_test=y_test, base
96
107
  # [{"tree": ..., "accuracy": ..., "logloss": ..., "coord": array([...])}, ...]
97
108
  ```
98
109
 
110
+ **You want a picture of that tree-space.**
111
+
112
+ ```python
113
+ nds.plot(rows, metric="accuracy", path="tree_space.png") # static PNG/PDF
114
+ nds.plot_interactive(rows, metric="accuracy", path="tree_space.html") # pan/zoom/hover
115
+ ```
116
+
117
+ Both color points by `metric` and mark the best tree with a black x. Needs
118
+ `ndscape[plot]`.
119
+
120
+ **The embedding is slow to recompute and you want to reuse it.**
121
+
122
+ ```python
123
+ rows = nds.analyze(X_train, y_train, classes, cache="embedding.joblib")
124
+ ```
125
+
126
+ The MDS step in `embed_trees`/`analyze` is the slow part. Pass `cache=` a
127
+ `.joblib` path: the first call computes the embedding and saves it there,
128
+ later calls with the same path just load it.
129
+
99
130
  A `tree` is a list of `(left, right)` tuples of class labels, e.g.
100
131
  `[((0, 1), (2, 3)), ((0,), (1,)), ((2,), (3,))]`. Use `nds.all_trees(classes)`
101
132
  (exhaustive, for small C) or `nds.sample_trees(classes, N)` (for larger C)
@@ -0,0 +1,240 @@
1
+ # ndscape: Nested Dichotomy Tree-Space Embedding
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ndscape.svg)](https://pypi.org/project/ndscape/)
4
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20456943.svg)](https://doi.org/10.5281/zenodo.20456943)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ A classification model assigns an observation to one of several classes, and when there are more than two, the task becomes a multiclass problem. A nested dichotomy (ND) gives that multiclass problem a visible structure: it arranges the classes in a binary tree and fits a classifier at each split, so the final prediction is built from a sequence of simpler binary decisions.
8
+
9
+ Any binary classifier can be used at the nodes. With probabilistic classifiers, the probability of each class is the product of the branch probabilities along its path from root to leaf. And because the tree records which classes are grouped together, where they separate, and in what order, you can inspect the fitted model and compare its structure with what you already know about the problem.
10
+
11
+ That structure also matters for prediction. There are (2M−3)!! possible NDs for M classes, each defining a different decomposition of the same classification task, and changing the tree can produce very different fitted models even when the data and binary classifier stay the same. This gives NDs a large, interpretable model space where changes in predictive behaviour can be related directly to changes in structure.
12
+
13
+ `ndscape` embeds ND tree structures using depth-weighted structural tokens and metric MDS, so structurally similar trees sit close together. It can be used as a Python package, and this repository also contains the code needed to reproduce the paper outputs.
14
+
15
+ Interactive maps from the paper: [res-lucid.github.io/ndscape-plots](https://res-lucid.github.io/ndscape-plots/)
16
+
17
+ ## Installation
18
+
19
+ For package use:
20
+
21
+ ```bash
22
+ pip install ndscape
23
+ ```
24
+
25
+ To reproduce the paper outputs, clone the repository and install the pinned requirements:
26
+ ```bash
27
+ python -m venv venv
28
+ source venv/bin/activate # Windows: venv\Scripts\activate
29
+ pip install -r requirements.txt
30
+ ```
31
+
32
+ Versions of `numpy`, `scipy`, and `scikit-learn` are pinned in `requirements.txt`. The MDS coordinates in `cache_nd/` were built with those exact versions, so changing numpy, scipy, or scikit-learn can produce slightly different coordinates.
33
+
34
+ ## How to use this repository
35
+
36
+ | Goal | Command |
37
+ |------|---------|
38
+ | Use `ndscape` as a package | `pip install ndscape` |
39
+ | Test the repository install | `pytest tests/` |
40
+ | Recreate paper figures and tables from archived outputs | `python scripts/download_zenodo.py` then `python reproduce.py --paper` |
41
+ | Run the full reproduction pipeline | `python reproduce.py` |
42
+ | Run only selected steps | `python reproduce.py --steps artifacts` |
43
+ | Score a new dataset | `python run_datasets.py --datasets my_dataset` |
44
+
45
+ ## Reproduce paper outputs from archived results
46
+
47
+ Download the Zenodo deposit, then regenerate the paper figures and tables without re-running the simulations:
48
+
49
+ ```bash
50
+ python scripts/download_zenodo.py
51
+ python reproduce.py --paper
52
+ ```
53
+
54
+ <details>
55
+ <summary>Scripts run by <code>python reproduce.py --paper</code></summary>
56
+
57
+ 1. `scripts/make_dataset_plots.py --cache zenodo --out paper_outputs/figures`
58
+ - PDF and interactive HTML embedding maps for all six real datasets.
59
+ 2. `scripts/make_simulation_plots.py --out paper_outputs/figures`
60
+ - Simulation bias, variance, and depth maps.
61
+ 3. `scripts/make_ablation_summaries.py`
62
+ - Moran spatial analysis for the tokeniser, BoW, and RF ablations.
63
+ 4. `scripts/make_paper_tables.py --all --out paper_outputs/tables`
64
+ - LaTeX tables.
65
+
66
+ Output is saved to `paper_outputs/figures/` and `paper_outputs/tables/`.
67
+ </details>
68
+
69
+ ## Full reproduction pipeline
70
+
71
+ This rebuilds the derived outputs locally, using the archived data and configuration files downloaded into `zenodo/`.
72
+
73
+ ```bash
74
+ python reproduce.py
75
+ ```
76
+
77
+ | Step | Name | Script | Output |
78
+ |------|------|--------|--------|
79
+ | 0 | `download` | `scripts/download_zenodo.py` | `zenodo/` (artifacts, configs, tree\_metrics, simulation\_scores, data, boot results) |
80
+ | 1 | `artifacts` | `scripts/make_artifacts.py --all` | `cache_nd/*.joblib` |
81
+ | 2 | `score` | `run_datasets.py` | `real-data-bias-analysis/<dataset>/v_<dataset>_lr_cluster_tables.xlsx` |
82
+ | 3 | `sim` | `run_simulations.py` | `bias-analysis/raw_simulation_results/` (LR) and `bias-analysis/raw_simulation_results_dt/` (DT) |
83
+ | 4 | `moran` | `scripts/moran_simulation_analysis.py` | `zenodo/simulation_scores/` (LR) and `zenodo/simulation_scores_dt/` (DT) |
84
+ | 5 | `tables` | `scripts/make_paper_tables.py --all` | `paper_outputs/tables/*.tex` |
85
+ | 6 | `plots` | `scripts/make_dataset_plots.py`, `scripts/make_simulation_plots.py` | `paper_outputs/figures/` |
86
+
87
+ Run individual steps or subsets:
88
+
89
+ ```bash
90
+ python reproduce.py --steps artifacts
91
+ python reproduce.py --steps score --bootstrap 20 # quick test with fewer bootstrap reps
92
+ python reproduce.py --steps sim moran --classes 6 7 # 6- and 7-class only
93
+ python reproduce.py --steps plots
94
+ ```
95
+
96
+ **Note on step 2:** `run_datasets.py` scores every ND tree on each real dataset and writes xlsx results. These are also available pre-built in `zenodo/tree_metrics/` and are used automatically by `make_dataset_plots.py` and `make_dataset_tables.py` when the xlsx files are absent.
97
+
98
+ ## Using your own data
99
+
100
+ Drop a CSV in `zenodo/data/` with a `y` column (integer or string class labels) and any number of numeric feature columns. A `split` column (`"train"` / `"test"`) is used if present, otherwise a stratified 70/30 split is applied.
101
+
102
+ ```bash
103
+ python run_datasets.py --datasets my_dataset
104
+ ```
105
+
106
+ Output goes to `real-data-bias-analysis/my_dataset/v_my_dataset_lr_cluster_tables.xlsx`.
107
+
108
+ ## Tests
109
+
110
+ ```bash
111
+ pytest tests/ # unit + snapshot tests (fast)
112
+ pytest tests/ -m integration # integration tests (slow and requires pre-built C=6,7 artifacts)
113
+ ```
114
+
115
+ The `tests/fixtures/` directory holds golden-master fixtures: MD5 hashes for the larger artifacts, and full tree lists and generator outputs for the smaller ones.
116
+
117
+ ## Datasets
118
+
119
+ The six datasets are from the UCI Machine Learning Repository (CC BY 4.0) and are archived on Zenodo. Download:
120
+
121
+ ```bash
122
+ python scripts/download_zenodo.py --what rawdata
123
+ ```
124
+
125
+ Or place CSV files (with a `y` column for the class label) in `zenodo/data/`:
126
+
127
+ | File | Classes | Trees evaluated |
128
+ |------|---------|-----------------|
129
+ | `glass_identification.csv` | 6 | 945 (exhaustive) |
130
+ | `steel_plates_faults.csv` | 7 | 10 395 (exhaustive) |
131
+ | `mice_protein.csv` | 8 | 15 000 (sampled) |
132
+ | `urban_land_cover.csv` | 9 | 15 000 (sampled) |
133
+ | `pen_based_recognition_of_handwritten_digits_81.csv` | 10 | 15 000 (sampled) |
134
+ | `soybean_large_122.csv` | 19 | 15 000 (sampled) |
135
+
136
+ ### Data sources
137
+
138
+ | Dataset | UCI URL |
139
+ |---------|---------|
140
+ | Glass Identification | https://archive.ics.uci.edu/dataset/42 |
141
+ | Steel Plates Faults | https://archive.ics.uci.edu/dataset/198 |
142
+ | Mice Protein Expression | https://archive.ics.uci.edu/dataset/342 |
143
+ | Urban Land Cover | https://archive.ics.uci.edu/dataset/295 |
144
+ | Pen-Based Digit Recognition | https://archive.ics.uci.edu/dataset/81 |
145
+ | Soybean (Large) | https://archive.ics.uci.edu/dataset/90 |
146
+
147
+ ## Key files
148
+
149
+ ```text
150
+ reproduce.py Main reproduction entry point
151
+ core.py Tree enumeration, embedding, models, artifact building
152
+ run_datasets.py Score all trees on real datasets
153
+ run_simulations.py Bias-variance simulation
154
+ nd/ Nested dichotomy implementations
155
+ scripts/ Figure, table, artifact, download, and analysis scripts
156
+ configs/artifacts.yml Artifact specifications
157
+ cache_nd/ Cached embedding artifacts
158
+ zenodo/ Downloaded Zenodo archive
159
+ real-data-bias-analysis/ Real-data scoring outputs
160
+ bias-analysis/raw_simulation_results/ Simulation bootstrap outputs
161
+ ```
162
+
163
+ ## Zenodo
164
+
165
+ Pre-built data is archived on Zenodo (doi:10.5281/zenodo.20456943). The deposit maps to the local `zenodo/` directory:
166
+
167
+ | Zenodo path | Contents | Download group |
168
+ |-------------|----------|----------------|
169
+ | `*.joblib` | Embedding artifacts for all class counts + ablations | `artifacts` |
170
+ | `simulation_scores/` | LR Moran summary CSVs (one per experiment) | `simulation_scores` |
171
+ | `simulation_scores_dt/` | DT Moran summary CSVs (one per experiment) | `simulation_scores_dt` |
172
+ | `tree_metrics/` | Per-tree scores for all 6 real datasets | `tree_metrics` |
173
+ | `raw_simulation_results/` | LR bootstrap CSVs (415 files, ~1 GB) | `boot` |
174
+ | `raw_simulation_results_dt/` | DT bootstrap CSVs (150 files) | `boot_dt` |
175
+ | `data/` | Raw UCI dataset CSVs | `rawdata` |
176
+ | `simulation_configs.csv` | 415 LR-generator simulation configurations | `sims` |
177
+ | `simulation_configs_dt.csv` | Decision-tree generator simulation configurations | `sims_dt` |
178
+
179
+ Download everything (all groups included by default):
180
+
181
+ ```bash
182
+ python scripts/download_zenodo.py
183
+ ```
184
+
185
+ Download specific groups:
186
+
187
+ ```bash
188
+ python scripts/download_zenodo.py --what artifacts
189
+ python scripts/download_zenodo.py --what rawdata
190
+ python scripts/download_zenodo.py --what tree_metrics
191
+ python scripts/download_zenodo.py --what simulation_scores
192
+ python scripts/download_zenodo.py --what simulation_scores_dt
193
+ python scripts/download_zenodo.py --what sims
194
+ python scripts/download_zenodo.py --what sims_dt
195
+ python scripts/download_zenodo.py --what boot
196
+ python scripts/download_zenodo.py --what boot_dt
197
+ ```
198
+
199
+ See `zenodo/manifest.csv` for a full listing of every file and what uses it.
200
+
201
+ ## Simulation input
202
+
203
+ Steps 3 and 4 read from `zenodo/simulation_configs.csv` (LR-generator configs) and, if present, `zenodo/simulation_configs_dt.csv` (decision-tree generator configs). Both are processed automatically:
204
+
205
+ ```bash
206
+ python reproduce.py --steps sim moran # runs LR and DT sims if configs present
207
+ python reproduce.py --steps sim --classes 6 7 # 6- and 7-class only
208
+ ```
209
+
210
+ LR boot results land in `bias-analysis/raw_simulation_results/` and Moran scores in `zenodo/simulation_scores/`.
211
+ DT boot results land in `bias-analysis/raw_simulation_results_dt/` and Moran scores in `zenodo/simulation_scores_dt/`.
212
+
213
+ To convert the LR CSV to JSON:
214
+
215
+ ```bash
216
+ python scripts/csv_to_configs.py --output simulation_configs.json
217
+ ```
218
+
219
+ `zenodo/simulation_configs_dt.csv` is committed as a static artifact and is not regenerated as part of this pipeline.
220
+
221
+ ## Artifacts (cache_nd/)
222
+
223
+ The `cache_nd/` directory holds `.joblib` artifacts for C=4 through C=19. It is not tracked in git — the artifacts are downloaded from Zenodo into `zenodo/` (tracked with Git LFS, see `.gitattributes`) or rebuilt locally:
224
+
225
+ ```bash
226
+ python scripts/download_zenodo.py --what artifacts
227
+ ```
228
+
229
+ To rebuild from scratch (re-runs MDS, requires pinned package versions):
230
+
231
+ ```bash
232
+ python scripts/make_artifacts.py --all
233
+ ```
234
+
235
+ Artifact specs (class count, tree count, seed) are declared in `configs/artifacts.yml`.
236
+ Use `scripts/verify_artifacts.py` to check a built artifact against its spec.
237
+
238
+ ## Citation
239
+
240
+ Metadata is in [`CITATION.cff`](CITATION.cff); GitHub renders it under "Cite this repository". The archived data and artifacts have their own DOI, [10.5281/zenodo.20456943](https://doi.org/10.5281/zenodo.20456943).
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ndscape
3
- Version: 0.1.0
4
- Summary: Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
5
- Author: Maxwell Dix-Matthews
6
- License-Expression: MIT
7
- Project-URL: Repository, https://github.com/res-lucid/ndscape
8
- Keywords: nested-dichotomy,multi-class,classification,scikit-learn
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Intended Audience :: Science/Research
11
- Classifier: Topic :: Scientific/Engineering
12
- Requires-Python: >=3.9
13
- Description-Content-Type: text/markdown
14
- Requires-Dist: numpy>=1.24
15
- Requires-Dist: scikit-learn>=1.3
16
- Provides-Extra: spatial
17
- Requires-Dist: esda>=2.6; extra == "spatial"
18
- Requires-Dist: libpysal>=4.10; extra == "spatial"
19
-
20
1
  # ndscape
21
2
 
22
3
  Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
@@ -32,6 +13,7 @@ tree structures.
32
13
  ```
33
14
  pip install ndscape
34
15
  pip install ndscape[spatial] # adds Moran's I support (esda, libpysal)
16
+ pip install ndscape[plot] # adds plotting (matplotlib, bokeh)
35
17
  ```
36
18
 
37
19
  ## Quickstart
@@ -96,6 +78,26 @@ rows = nds.analyze(X_train, y_train, classes, X_test=X_test, y_test=y_test, base
96
78
  # [{"tree": ..., "accuracy": ..., "logloss": ..., "coord": array([...])}, ...]
97
79
  ```
98
80
 
81
+ **You want a picture of that tree-space.**
82
+
83
+ ```python
84
+ nds.plot(rows, metric="accuracy", path="tree_space.png") # static PNG/PDF
85
+ nds.plot_interactive(rows, metric="accuracy", path="tree_space.html") # pan/zoom/hover
86
+ ```
87
+
88
+ Both color points by `metric` and mark the best tree with a black x. Needs
89
+ `ndscape[plot]`.
90
+
91
+ **The embedding is slow to recompute and you want to reuse it.**
92
+
93
+ ```python
94
+ rows = nds.analyze(X_train, y_train, classes, cache="embedding.joblib")
95
+ ```
96
+
97
+ The MDS step in `embed_trees`/`analyze` is the slow part. Pass `cache=` a
98
+ `.joblib` path: the first call computes the embedding and saves it there,
99
+ later calls with the same path just load it.
100
+
99
101
  A `tree` is a list of `(left, right)` tuples of class labels, e.g.
100
102
  `[((0, 1), (2, 3)), ((0,), (1,)), ((2,), (3,))]`. Use `nds.all_trees(classes)`
101
103
  (exhaustive, for small C) or `nds.sample_trees(classes, N)` (for larger C)
@@ -9,6 +9,7 @@ from .api import (
9
9
  sample_trees,
10
10
  spatial_autocorrelation,
11
11
  )
12
+ from .plot import plot, plot_interactive
12
13
 
13
14
  __all__ = [
14
15
  "ND",
@@ -18,4 +19,6 @@ __all__ = [
18
19
  "fit",
19
20
  "sample_trees",
20
21
  "spatial_autocorrelation",
22
+ "plot",
23
+ "plot_interactive",
21
24
  ]
@@ -11,7 +11,9 @@ read top-down: first split {0,1} vs {2,3}, then split each side further.
11
11
 
12
12
  from itertools import combinations
13
13
  from math import comb, sqrt
14
+ from pathlib import Path
14
15
 
16
+ import joblib
15
17
  import numpy as np
16
18
  from sklearn.base import clone
17
19
  from sklearn.feature_extraction.text import TfidfVectorizer
@@ -33,7 +35,7 @@ def _get_model(base):
33
35
  if isinstance(base, str):
34
36
  if base == "lr":
35
37
  return LogisticRegression(
36
- penalty="l2", solver="newton-cholesky", C=0.1, max_iter=2000
38
+ solver="newton-cholesky", C=0.1, max_iter=2000
37
39
  )
38
40
  if base in ("decisiontree", "dt"):
39
41
  return DecisionTreeClassifier(
@@ -298,13 +300,20 @@ def _split_analyzer(n_classes):
298
300
  return _analyze
299
301
 
300
302
 
301
- def embed_trees(classes, trees=None, N=2000, seed=0, dim=2):
303
+ def embed_trees(classes, trees=None, N=2000, seed=0, dim=2, cache=None):
302
304
  """Place a set of ND trees in `dim`-D tree-space (TF-IDF + cosine distance + MDS).
303
305
 
304
306
  If `trees` is omitted, all trees are used for <=7 classes and a balanced
305
307
  sample of N otherwise. Returns (trees, coords); coords[i] is the
306
308
  embedding of trees[i].
309
+
310
+ `cache` is an optional .joblib path. If it exists, (trees, coords) are
311
+ loaded from it instead of recomputed (the MDS step is the slow part).
312
+ Otherwise they're computed as usual and saved there for next time.
307
313
  """
314
+ if cache is not None and Path(cache).exists():
315
+ return joblib.load(cache)
316
+
308
317
  classes = list(classes)
309
318
  n = len(classes)
310
319
  if trees is None:
@@ -321,6 +330,9 @@ def embed_trees(classes, trees=None, N=2000, seed=0, dim=2):
321
330
  random_state=seed, n_init=1, max_iter=1000, eps=1e-6, n_jobs=-1,
322
331
  )
323
332
  coords = mds.fit_transform(dist)
333
+
334
+ if cache is not None:
335
+ joblib.dump((trees, coords), cache)
324
336
  return trees, coords
325
337
 
326
338
 
@@ -348,17 +360,20 @@ def spatial_autocorrelation(values, coords, k=50, permutations=999):
348
360
  return {"I": mi.I, "p_sim": mi.p_sim}
349
361
 
350
362
 
351
- def analyze(X, y, classes, X_test=None, y_test=None, base="lr", N=2000, seed=0):
363
+ def analyze(X, y, classes, X_test=None, y_test=None, base="lr", N=2000, seed=0, cache=None):
352
364
  """Fit every candidate ND tree on (X, y), score it, and place it in tree-space.
353
365
 
354
366
  If X_test/y_test are omitted, scores are computed on (X, y). Returns a
355
367
  list of dicts: tree, accuracy, logloss, coord.
368
+
369
+ `cache` is an optional .joblib path for the embedding (see `embed_trees`)
370
+ — reuse it across calls so the MDS step doesn't rerun every time.
356
371
  """
357
372
  classes = list(classes)
358
373
  Xte = X if X_test is None else X_test
359
374
  yte = y if y_test is None else y_test
360
375
 
361
- trees, coords = embed_trees(classes, N=N, seed=seed)
376
+ trees, coords = embed_trees(classes, N=N, seed=seed, cache=cache)
362
377
  rows = []
363
378
  for tree, coord in zip(trees, coords):
364
379
  nd = ND(tree, classes).fit(X, y, base=base)
@@ -0,0 +1,78 @@
1
+ """Plot a tree-space embedding (the kind of figure used in the paper).
2
+
3
+ Both functions take `rows`, the list of dicts returned by `ndscape.analyze()`
4
+ (each needs "coord" and the metric you're plotting). Needs matplotlib for
5
+ `plot`, and bokeh as well for `plot_interactive`:
6
+
7
+ pip install ndscape[plot]
8
+ """
9
+
10
+ import numpy as np
11
+ from matplotlib.colors import LinearSegmentedColormap
12
+
13
+ # red -> yellow -> green, same palette used for the paper's accuracy/variance plots
14
+ BRIGHT = LinearSegmentedColormap.from_list("bright_RYG", ["#ff2d2d", "#fff176", "#00e676"], N=100)
15
+
16
+
17
+ def plot(rows, metric="accuracy", path=None, ax=None):
18
+ """Static scatter of the embedding, colored by `metric`. Marks the best tree with a black x.
19
+
20
+ Pass `path` to save a PNG/PDF, or `ax` to draw onto an existing matplotlib axes.
21
+ Returns (fig, ax).
22
+ """
23
+ import matplotlib.pyplot as plt
24
+
25
+ coords = np.array([r["coord"] for r in rows])
26
+ values = np.array([r[metric] for r in rows], dtype=float)
27
+ best = int(np.argmin(values)) if metric == "logloss" else int(np.argmax(values))
28
+
29
+ if ax is None:
30
+ fig, ax = plt.subplots(figsize=(8.0, 5.2))
31
+ else:
32
+ fig = ax.figure
33
+
34
+ sc = ax.scatter(coords[:, 0], coords[:, 1], c=values, cmap=BRIGHT, s=30, linewidths=0)
35
+ fig.colorbar(sc, ax=ax, fraction=0.046, pad=0.02).set_label(metric)
36
+ ax.scatter(coords[best, 0], coords[best, 1], marker="x", s=220, c="black", linewidths=2.2, zorder=12)
37
+ ax.set_xlabel("MDS-1")
38
+ ax.set_ylabel("MDS-2")
39
+ ax.grid(True, lw=0.3, alpha=0.4)
40
+ fig.tight_layout()
41
+
42
+ if path is not None:
43
+ fig.savefig(path, dpi=300, bbox_inches="tight")
44
+ return fig, ax
45
+
46
+
47
+ def plot_interactive(rows, metric="accuracy", path="tree_space.html"):
48
+ """Same plot as `plot`, but as an HTML page with pan/zoom/hover (accuracy + logloss).
49
+
50
+ Needs bokeh: pip install ndscape[plot]. Returns the bokeh figure.
51
+ """
52
+ from bokeh.models import ColorBar, ColumnDataSource, HoverTool, LinearColorMapper
53
+ from bokeh.plotting import figure, output_file, save
54
+ from matplotlib.colors import to_hex
55
+
56
+ coords = np.array([r["coord"] for r in rows])
57
+ values = np.array([r[metric] for r in rows], dtype=float)
58
+ palette = [to_hex(BRIGHT(i / 99)) for i in range(100)]
59
+ mapper = LinearColorMapper(palette=palette, low=float(values.min()), high=float(values.max()))
60
+
61
+ source = ColumnDataSource(dict(
62
+ x=coords[:, 0], y=coords[:, 1], value=values,
63
+ accuracy=[r["accuracy"] for r in rows],
64
+ logloss=[r["logloss"] for r in rows],
65
+ ))
66
+
67
+ p = figure(width=800, height=520, x_axis_label="MDS-1", y_axis_label="MDS-2",
68
+ tools="pan,wheel_zoom,box_zoom,reset,save")
69
+ points = p.scatter("x", "y", source=source, size=6, line_color="black", line_width=0.4,
70
+ fill_color={"field": "value", "transform": mapper}, fill_alpha=0.9)
71
+ p.add_tools(HoverTool(renderers=[points], tooltips=[
72
+ ("accuracy", "@accuracy{0.000}"), ("logloss", "@logloss{0.000}"),
73
+ ]))
74
+ p.add_layout(ColorBar(color_mapper=mapper, label_standoff=8, title=metric), "right")
75
+
76
+ output_file(path)
77
+ save(p)
78
+ return p
@@ -1,3 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: ndscape
3
+ Version: 0.1.2
4
+ Summary: Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
5
+ Author: Maxwell Dix-Matthews
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/res-lucid/ndscape
8
+ Project-URL: Repository, https://github.com/res-lucid/ndscape
9
+ Project-URL: Issues, https://github.com/res-lucid/ndscape/issues
10
+ Project-URL: Interactive maps, https://res-lucid.github.io/ndscape-plots/
11
+ Project-URL: Data archive, https://doi.org/10.5281/zenodo.20456943
12
+ Keywords: nested-dichotomy,multi-class,classification,scikit-learn
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.24
20
+ Requires-Dist: scikit-learn>=1.3
21
+ Requires-Dist: joblib>=1.2
22
+ Provides-Extra: spatial
23
+ Requires-Dist: esda>=2.6; extra == "spatial"
24
+ Requires-Dist: libpysal>=4.10; extra == "spatial"
25
+ Provides-Extra: plot
26
+ Requires-Dist: matplotlib>=3.5; extra == "plot"
27
+ Requires-Dist: bokeh>=3.0; extra == "plot"
28
+ Dynamic: license-file
29
+
1
30
  # ndscape
2
31
 
3
32
  Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
@@ -13,6 +42,7 @@ tree structures.
13
42
  ```
14
43
  pip install ndscape
15
44
  pip install ndscape[spatial] # adds Moran's I support (esda, libpysal)
45
+ pip install ndscape[plot] # adds plotting (matplotlib, bokeh)
16
46
  ```
17
47
 
18
48
  ## Quickstart
@@ -77,6 +107,26 @@ rows = nds.analyze(X_train, y_train, classes, X_test=X_test, y_test=y_test, base
77
107
  # [{"tree": ..., "accuracy": ..., "logloss": ..., "coord": array([...])}, ...]
78
108
  ```
79
109
 
110
+ **You want a picture of that tree-space.**
111
+
112
+ ```python
113
+ nds.plot(rows, metric="accuracy", path="tree_space.png") # static PNG/PDF
114
+ nds.plot_interactive(rows, metric="accuracy", path="tree_space.html") # pan/zoom/hover
115
+ ```
116
+
117
+ Both color points by `metric` and mark the best tree with a black x. Needs
118
+ `ndscape[plot]`.
119
+
120
+ **The embedding is slow to recompute and you want to reuse it.**
121
+
122
+ ```python
123
+ rows = nds.analyze(X_train, y_train, classes, cache="embedding.joblib")
124
+ ```
125
+
126
+ The MDS step in `embed_trees`/`analyze` is the slow part. Pass `cache=` a
127
+ `.joblib` path: the first call computes the embedding and saves it there,
128
+ later calls with the same path just load it.
129
+
80
130
  A `tree` is a list of `(left, right)` tuples of class labels, e.g.
81
131
  `[((0, 1), (2, 3)), ((0,), (1,)), ((2,), (3,))]`. Use `nds.all_trees(classes)`
82
132
  (exhaustive, for small C) or `nds.sample_trees(classes, N)` (for larger C)
@@ -1,7 +1,11 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
1
4
  pyproject.toml
2
5
  ndscape/README.md
3
6
  ndscape/__init__.py
4
7
  ndscape/api.py
8
+ ndscape/plot.py
5
9
  ndscape.egg-info/PKG-INFO
6
10
  ndscape.egg-info/SOURCES.txt
7
11
  ndscape.egg-info/dependency_links.txt
@@ -1,5 +1,10 @@
1
1
  numpy>=1.24
2
2
  scikit-learn>=1.3
3
+ joblib>=1.2
4
+
5
+ [plot]
6
+ matplotlib>=3.5
7
+ bokeh>=3.0
3
8
 
4
9
  [spatial]
5
10
  esda>=2.6
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68"]
2
+ requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ndscape"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification."
9
9
  readme = "ndscape/README.md"
10
10
  license = "MIT"
@@ -21,13 +21,19 @@ classifiers = [
21
21
  dependencies = [
22
22
  "numpy>=1.24",
23
23
  "scikit-learn>=1.3",
24
+ "joblib>=1.2",
24
25
  ]
25
26
 
26
27
  [project.optional-dependencies]
27
28
  spatial = ["esda>=2.6", "libpysal>=4.10"]
29
+ plot = ["matplotlib>=3.5", "bokeh>=3.0"]
28
30
 
29
31
  [project.urls]
32
+ Homepage = "https://github.com/res-lucid/ndscape"
30
33
  Repository = "https://github.com/res-lucid/ndscape"
34
+ Issues = "https://github.com/res-lucid/ndscape/issues"
35
+ "Interactive maps" = "https://res-lucid.github.io/ndscape-plots/"
36
+ "Data archive" = "https://doi.org/10.5281/zenodo.20456943"
31
37
 
32
38
  [tool.setuptools.packages.find]
33
39
  include = ["ndscape*"]
File without changes