ndscape 0.1.2__tar.gz → 0.1.4__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.
Files changed (65) hide show
  1. ndscape-0.1.4/.gitattributes +10 -0
  2. ndscape-0.1.4/.github/workflows/release.yml +54 -0
  3. ndscape-0.1.4/.gitignore +55 -0
  4. ndscape-0.1.4/CITATION.cff +47 -0
  5. {ndscape-0.1.2/ndscape.egg-info → ndscape-0.1.4}/PKG-INFO +9 -6
  6. {ndscape-0.1.2 → ndscape-0.1.4}/README.md +7 -3
  7. ndscape-0.1.4/config.py +14 -0
  8. ndscape-0.1.4/configs/artifacts.yml +81 -0
  9. ndscape-0.1.4/core.py +337 -0
  10. ndscape-0.1.4/nd/ACND.py +80 -0
  11. ndscape-0.1.4/nd/BBoK.py +45 -0
  12. ndscape-0.1.4/nd/BestOfK.py +14 -0
  13. ndscape-0.1.4/nd/CBND.py +33 -0
  14. ndscape-0.1.4/nd/NDC.py +50 -0
  15. ndscape-0.1.4/nd/NestedDichotomy.py +165 -0
  16. ndscape-0.1.4/nd/RPND.py +51 -0
  17. ndscape-0.1.4/nd/RandomGeneration.py +143 -0
  18. ndscape-0.1.4/nd/__init__.py +8 -0
  19. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/README.md +5 -3
  20. {ndscape-0.1.2 → ndscape-0.1.4/ndscape.egg-info}/PKG-INFO +9 -6
  21. ndscape-0.1.4/ndscape.egg-info/SOURCES.txt +62 -0
  22. ndscape-0.1.4/ndscape.egg-info/scm_file_list.json +169 -0
  23. ndscape-0.1.4/ndscape.egg-info/scm_version.json +8 -0
  24. ndscape-0.1.4/paper_artifact.md +138 -0
  25. {ndscape-0.1.2 → ndscape-0.1.4}/pyproject.toml +8 -5
  26. ndscape-0.1.4/pytest.ini +4 -0
  27. ndscape-0.1.4/reproduce.py +175 -0
  28. ndscape-0.1.4/requirements.txt +15 -0
  29. ndscape-0.1.4/run_datasets.py +171 -0
  30. ndscape-0.1.4/run_simulations.py +474 -0
  31. ndscape-0.1.4/scripts/csv_to_configs.py +147 -0
  32. ndscape-0.1.4/scripts/download_zenodo.py +217 -0
  33. ndscape-0.1.4/scripts/make_ablation_summaries.py +49 -0
  34. ndscape-0.1.4/scripts/make_ablation_tables.py +133 -0
  35. ndscape-0.1.4/scripts/make_artifacts.py +81 -0
  36. ndscape-0.1.4/scripts/make_dataset_plots.py +340 -0
  37. ndscape-0.1.4/scripts/make_dataset_tables.py +247 -0
  38. ndscape-0.1.4/scripts/make_paper_tables.py +179 -0
  39. ndscape-0.1.4/scripts/make_simulation_plots.py +192 -0
  40. ndscape-0.1.4/scripts/make_simulation_tables.py +279 -0
  41. ndscape-0.1.4/scripts/moran_simulation_analysis.py +291 -0
  42. ndscape-0.1.4/scripts/verify_artifacts.py +127 -0
  43. ndscape-0.1.4/zenodo/art_1_C10_N15000_s0_d2.joblib +3 -0
  44. ndscape-0.1.4/zenodo/art_1_C19_N15000_s0_d2.joblib +3 -0
  45. ndscape-0.1.4/zenodo/art_1_C6_N945_s0_d2.joblib +3 -0
  46. ndscape-0.1.4/zenodo/art_1_C7_N10395_s0_d2.joblib +3 -0
  47. ndscape-0.1.4/zenodo/art_1_C8_N15000_s0_d2.joblib +3 -0
  48. ndscape-0.1.4/zenodo/art_1_C9_N15000_s0_d2.joblib +3 -0
  49. ndscape-0.1.4/zenodo/bow_1_C6_N945_s0_d2.joblib +3 -0
  50. ndscape-0.1.4/zenodo/bow_1_C7_N10395_s0_d2.joblib +3 -0
  51. ndscape-0.1.4/zenodo/manifest.csv +39 -0
  52. ndscape-0.1.4/zenodo/rf_1_C6_N945_s0_d2.joblib +3 -0
  53. ndscape-0.1.4/zenodo/rf_1_C7_N10395_s0_d2.joblib +3 -0
  54. ndscape-0.1.4/zenodo/simulation_configs.csv +416 -0
  55. ndscape-0.1.4/zenodo/simulation_configs_dt.csv +151 -0
  56. ndscape-0.1.2/ndscape.egg-info/SOURCES.txt +0 -13
  57. {ndscape-0.1.2 → ndscape-0.1.4}/LICENSE +0 -0
  58. {ndscape-0.1.2 → ndscape-0.1.4}/MANIFEST.in +0 -0
  59. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/__init__.py +0 -0
  60. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/api.py +0 -0
  61. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/plot.py +0 -0
  62. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/dependency_links.txt +0 -0
  63. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/requires.txt +0 -0
  64. {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/top_level.txt +0 -0
  65. {ndscape-0.1.2 → ndscape-0.1.4}/setup.cfg +0 -0
@@ -0,0 +1,10 @@
1
+ * text=auto eol=lf
2
+ *.py text eol=lf
3
+ *.md text eol=lf
4
+ *.txt text eol=lf
5
+ *.yml text eol=lf
6
+ *.yaml text eol=lf
7
+ *.json text eol=lf
8
+ *.jsonl text eol=lf
9
+ *.tex text eol=lf
10
+ *.joblib filter=lfs diff=lfs merge=lfs -text
@@ -0,0 +1,54 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ name: Build distribution
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - uses: actions/setup-python@v7
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Build sdist and wheel
25
+ run: |
26
+ python -m pip install --upgrade build
27
+ python -m build
28
+
29
+ - name: Check package metadata
30
+ run: |
31
+ python -m pip install --upgrade twine
32
+ python -m twine check dist/*
33
+
34
+ - uses: actions/upload-artifact@v7
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ publish:
40
+ name: Publish to PyPI
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ environment:
44
+ name: pypi
45
+ url: https://pypi.org/p/ndscape
46
+ permissions:
47
+ id-token: write
48
+ steps:
49
+ - uses: actions/download-artifact@v8
50
+ with:
51
+ name: dist
52
+ path: dist/
53
+
54
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,55 @@
1
+ # virtual environments
2
+ .venv/
3
+ .venv_linux/
4
+ venv/
5
+ venv_linux/
6
+
7
+ # IDE
8
+ .vscode/
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.pyc
13
+ .pytest_cache/
14
+ numba_cache/
15
+
16
+ # packaging artifacts
17
+ build/
18
+ dist/
19
+ *.egg-info/
20
+ .eggs/
21
+ *.whl
22
+
23
+ # datasets — download separately via Zenodo (see README)
24
+ data/
25
+ zenodo/data/
26
+
27
+ # zenodo/ subdirs downloaded or generated (not committed)
28
+ zenodo/raw_simulation_results/
29
+ zenodo/raw_simulation_results_dt/
30
+ zenodo/simulation_scores/
31
+ zenodo/simulation_scores_dt/
32
+ zenodo/tree_metrics/
33
+ zenodo/*.zip
34
+
35
+ # generated outputs — reproduced by running the scripts
36
+ real-data-bias-analysis/
37
+ bias-analysis/
38
+ paper_outputs/
39
+ cache_nd/
40
+
41
+ # stale / superseded files
42
+ all_sims.csv
43
+ all_sims_trimmed.csv
44
+ ndscape_repo.7z
45
+ # optional JSON conversion of simulation_configs.csv
46
+ simulation_configs.json
47
+ zenodo/simulation_configs.json
48
+
49
+ # temp file written by reproduce.py --steps sim/moran --classes ...
50
+ _selected_sims.csv
51
+
52
+ # credentials — never commit these
53
+ .env
54
+ .pypirc
55
+ *.pypirc
@@ -0,0 +1,47 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ type: software
4
+ title: "ndscape: nested dichotomy tree-space embedding"
5
+ abstract: >
6
+ Code for scoring nested-dichotomy (ND) tree structures on
7
+ multiclass datasets, embedding the tree space via metric MDS, and running
8
+ a bias-variance simulation over the space.
9
+ authors:
10
+ - family-names: "Dix-Matthews"
11
+ given-names: "Maxwell"
12
+ orcid: "https://orcid.org/0009-0003-1575-0744"
13
+ - family-names: "Hodkiewicz"
14
+ given-names: "Melinda"
15
+ orcid: "https://orcid.org/0000-0002-7336-3932"
16
+ - family-names: "Polpo"
17
+ given-names: "Adriano"
18
+ orcid: "https://orcid.org/0000-0002-5959-1808"
19
+
20
+ repository-code: "https://github.com/res-lucid/ndscape"
21
+ url: "https://res-lucid.github.io/ndscape-plots/"
22
+ license: MIT
23
+ doi: "10.5281/zenodo.20456943"
24
+ version: "0.1.3"
25
+ date-released: "2026-06-17"
26
+ keywords:
27
+ - nested dichotomies
28
+ - nested-dichotomy
29
+ - multiclass classification
30
+ - tree-space
31
+ - bias-variance
32
+ - MDS
33
+ - spatial-autocorrelation
34
+ - machine-learning
35
+ preferred-citation:
36
+ type: article
37
+ title: "ndscape: a structural map of the nested dichotomy space"
38
+ authors:
39
+ - family-names: "Dix-Matthews"
40
+ given-names: "Maxwell"
41
+ - family-names: "Hodkiewicz"
42
+ given-names: "Melinda"
43
+ - family-names: "Polpo"
44
+ given-names: "Adriano"
45
+ year: 2026
46
+ doi: "10.48550/arXiv.2610.18050"
47
+ url: "https://arxiv.org/abs/2610.18050"
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndscape
3
- Version: 0.1.2
4
- Summary: Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
3
+ Version: 0.1.4
4
+ Summary: Nested dichotomies in Python: fit, score and map nested dichotomy trees for multiclass classification.
5
5
  Author: Maxwell Dix-Matthews
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/res-lucid/ndscape
8
8
  Project-URL: Repository, https://github.com/res-lucid/ndscape
9
+ Project-URL: Paper, https://arxiv.org/abs/2610.18050
9
10
  Project-URL: Issues, https://github.com/res-lucid/ndscape/issues
10
11
  Project-URL: Interactive maps, https://res-lucid.github.io/ndscape-plots/
11
12
  Project-URL: Data archive, https://doi.org/10.5281/zenodo.20456943
12
- Keywords: nested-dichotomy,multi-class,classification,scikit-learn
13
+ Keywords: nested-dichotomy,nested dichotomies,multiclass,multiclass classification,hierarchical classification,tree embedding,classification,scikit-learn
13
14
  Classifier: Programming Language :: Python :: 3
14
15
  Classifier: Intended Audience :: Science/Research
15
16
  Classifier: Topic :: Scientific/Engineering
@@ -29,14 +30,16 @@ Dynamic: license-file
29
30
 
30
31
  # ndscape
31
32
 
32
- Fit, score, and embed nested-dichotomy (ND) trees for multi-class classification.
33
+ Nested dichotomies in Python: fit, score and map nested dichotomy (ND) trees for multiclass classification.
33
34
 
34
- A nested dichotomy reduces a C-class problem to a tree of binary splits
35
+ A nested dichotomy reduces an M-class problem to a tree of binary splits
35
36
  (e.g. {0,1,2} vs {3,4}, then {0} vs {1,2}, ...). ndscape lets you fit one,
36
37
  score it, or place a whole population of candidate trees in a 2-D
37
38
  "tree-space" to see how a property (accuracy, variance, ...) varies across
38
39
  tree structures.
39
40
 
41
+ The method is described in [Dix-Matthews, Hodkiewicz and Polpo (2026)](https://arxiv.org/abs/2610.18050).
42
+
40
43
  ## Install
41
44
 
42
45
  ```
@@ -114,7 +117,7 @@ nds.plot(rows, metric="accuracy", path="tree_space.png") # static PNG/PDF
114
117
  nds.plot_interactive(rows, metric="accuracy", path="tree_space.html") # pan/zoom/hover
115
118
  ```
116
119
 
117
- Both color points by `metric` and mark the best tree with a black x. Needs
120
+ Both colour points by `metric` and mark the best tree with a black x. Needs
118
121
  `ndscape[plot]`.
119
122
 
120
123
  **The embedding is slow to recompute and you want to reuse it.**
@@ -1,18 +1,20 @@
1
- # ndscape: Nested Dichotomy Tree-Space Embedding
1
+ # ndscape: nested dichotomies in Python
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/ndscape.svg)](https://pypi.org/project/ndscape/)
4
4
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20456943.svg)](https://doi.org/10.5281/zenodo.20456943)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
+ `ndscape` is a Python package for nested dichotomies in multiclass classification: it fits and scores nested dichotomy trees and maps the space of possible trees, so that structurally similar trees sit close together.
8
+
7
9
  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
10
 
9
11
  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
12
 
11
13
  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
14
 
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.
15
+ `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](https://arxiv.org/abs/2610.18050) outputs.
14
16
 
15
- Interactive maps from the paper: [res-lucid.github.io/ndscape-plots](https://res-lucid.github.io/ndscape-plots/)
17
+ Interactive maps from the [paper](https://arxiv.org/abs/2610.18050): [res-lucid.github.io/ndscape-plots](https://res-lucid.github.io/ndscape-plots/)
16
18
 
17
19
  ## Installation
18
20
 
@@ -237,4 +239,6 @@ Use `scripts/verify_artifacts.py` to check a built artifact against its spec.
237
239
 
238
240
  ## Citation
239
241
 
242
+ Dix-Matthews, M., Hodkiewicz, M., & Polpo, A. (2026). ndscape: a structural map of the nested dichotomy space. arXiv:2610.18050 [stat.ME]. https://doi.org/10.48550/arXiv.2610.18050
243
+
240
244
  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).
@@ -0,0 +1,14 @@
1
+ """Shared training state: dataset arrays, model cache, and hyperparameters.
2
+
3
+ All variables here are mutated directly by run_datasets.py and
4
+ get_or_train_split_model in core.py. Reset model_cache between dataset
5
+ evaluations to avoid cross-contamination (the test suite does this via
6
+ an autouse fixture in conftest.py).
7
+ """
8
+
9
+ model_cache = {}
10
+ X = []
11
+ y = []
12
+ X_test = []
13
+ y_test = []
14
+ C = 0.1 # LogisticRegression regularisation strength
@@ -0,0 +1,81 @@
1
+ # Artifact manifest for nested-dichotomy tree-space analysis.
2
+ #
3
+ # Each entry defines one artifact saved to cache_nd/.
4
+ # Run: python scripts/make_artifacts.py --all
5
+ # python scripts/make_artifacts.py --target art_1_C6_N945_s0_d2
6
+ #
7
+ # Fields
8
+ # ------
9
+ # name : unique artifact identifier (matches cache filename stem)
10
+ # C : number of classes
11
+ # N : number of trees sampled (ignored when exhaustive: true)
12
+ # seed : random seed for MDS and KMeans
13
+ # dim : MDS embedding dimension
14
+ # exhaustive : true when all trees are enumerated (C <= 7)
15
+ # description : plain-English note for readers
16
+
17
+ artifacts:
18
+
19
+ - name: art_1_C4_N15_s0_d2
20
+ C: 4
21
+ N: 15
22
+ seed: 0
23
+ dim: 2
24
+ exhaustive: true
25
+ description: "4-class full enumeration (15 trees)"
26
+
27
+ - name: art_1_C5_N105_s0_d2
28
+ C: 5
29
+ N: 105
30
+ seed: 0
31
+ dim: 2
32
+ exhaustive: true
33
+ description: "5-class full enumeration (105 trees)"
34
+
35
+ - name: art_1_C6_N945_s0_d2
36
+ C: 6
37
+ N: 945
38
+ seed: 0
39
+ dim: 2
40
+ exhaustive: true
41
+ description: "6-class full enumeration (945 trees) -- glass_identification"
42
+
43
+ - name: art_1_C7_N10395_s0_d2
44
+ C: 7
45
+ N: 10395
46
+ seed: 0
47
+ dim: 2
48
+ exhaustive: true
49
+ description: "7-class full enumeration (10395 trees) -- steel_plates_faults"
50
+
51
+ - name: art_1_C8_N15000_s0_d2
52
+ C: 8
53
+ N: 15000
54
+ seed: 0
55
+ dim: 2
56
+ exhaustive: false
57
+ description: "8-class sampled (15000 trees) -- mice_protein"
58
+
59
+ - name: art_1_C9_N15000_s0_d2
60
+ C: 9
61
+ N: 15000
62
+ seed: 0
63
+ dim: 2
64
+ exhaustive: false
65
+ description: "9-class sampled (15000 trees) -- urban_land_cover"
66
+
67
+ - name: art_1_C10_N15000_s0_d2
68
+ C: 10
69
+ N: 15000
70
+ seed: 0
71
+ dim: 2
72
+ exhaustive: false
73
+ description: "10-class sampled (15000 trees) -- pen_based_digits"
74
+
75
+ - name: art_1_C19_N15000_s0_d2
76
+ C: 19
77
+ N: 15000
78
+ seed: 0
79
+ dim: 2
80
+ exhaustive: false
81
+ description: "19-class sampled (15000 trees) -- soybean_large_122"
ndscape-0.1.4/core.py ADDED
@@ -0,0 +1,337 @@
1
+ """Core nested-dichotomy methods: tree enumeration, embedding, models, artifact building."""
2
+
3
+ import ast
4
+ import importlib.metadata
5
+ import os
6
+ from itertools import combinations
7
+ from math import comb, sqrt
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from joblib import dump, load
13
+ from sklearn.cluster import KMeans
14
+ from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
15
+ from sklearn.feature_extraction.text import TfidfVectorizer
16
+ from sklearn.linear_model import LogisticRegression
17
+ from sklearn.manifold import MDS, trustworthiness
18
+ from sklearn.metrics import silhouette_score
19
+ from sklearn.metrics.pairwise import cosine_similarity
20
+ from sklearn.svm import SVC
21
+ from sklearn.tree import DecisionTreeClassifier
22
+
23
+ import config
24
+ from esda import Moran
25
+ from libpysal.weights import KNN
26
+ import warnings
27
+
28
+ warnings.filterwarnings("ignore", module=r"sklearn\..*")
29
+
30
+ # Tree sampling (proportional balanced design)
31
+
32
+ def sample_trees(n, N):
33
+ """Sample up to N nested-dichotomy trees over n classes, proportionally balanced."""
34
+ tree_counts = [0, 1, 1] + [0] * max(0, n - 2)
35
+ for m in range(3, n + 1):
36
+ tree_counts[m] = (2 * m - 3) * tree_counts[m - 1]
37
+
38
+ def _draw(classes, budget):
39
+ m = len(classes)
40
+ if m <= 1:
41
+ return [()]
42
+ if m == 2:
43
+ return [((tuple(classes[:1]), tuple(classes[1:])),)]
44
+ if budget == 1:
45
+ k = m // 2
46
+ right, left = tuple(classes[k:]), tuple(classes[:k])
47
+ return [((right, left),) + _draw(list(right), 1)[0] + _draw(list(left), 1)[0]]
48
+ left_sizes = range(1, m // 2 + 1)
49
+ weights = [comb(m, k) // (2 if 2 * k == m else 1) * tree_counts[k] * tree_counts[m - k] for k in left_sizes]
50
+ total = sum(weights)
51
+ alloc = [budget * w // total for w in weights]
52
+ for i in sorted(range(len(weights)), key=lambda i: -(budget * weights[i] % total))[:budget - sum(alloc)]:
53
+ alloc[i] += 1
54
+ out = []
55
+ for k, k_budget in zip(left_sizes, alloc):
56
+ if k_budget == 0:
57
+ continue
58
+ n_combos = comb(m, k) // (2 if 2 * k == m else 1)
59
+ per_combo, remainder = divmod(k_budget, n_combos)
60
+ seen = 0
61
+ for left in combinations(classes, k):
62
+ if 2 * k == m and classes[0] not in left:
63
+ continue
64
+ combo_budget = per_combo + (seen < remainder)
65
+ seen += 1
66
+ if combo_budget == 0:
67
+ if per_combo == 0 and seen >= remainder:
68
+ break
69
+ continue
70
+ left = tuple(left)
71
+ right = tuple(x for x in classes if x not in left)
72
+ left_budget = int(sqrt(combo_budget * tree_counts[k] / tree_counts[m - k]) + 0.5)
73
+ lb_round_fixed = max(left_budget, (combo_budget + tree_counts[m - k] - 1) // tree_counts[m - k])
74
+ lb_round_fixed = min(lb_round_fixed, tree_counts[k])
75
+ right_budget = (combo_budget + lb_round_fixed - 1) // lb_round_fixed
76
+ drawn = 0
77
+ for right_sub in _draw(list(right), right_budget):
78
+ for left_sub in _draw(list(left), lb_round_fixed):
79
+ out.append(((right, left),) + right_sub + left_sub)
80
+ drawn += 1
81
+ if drawn == combo_budget:
82
+ break
83
+ if drawn == combo_budget:
84
+ break
85
+ return out
86
+
87
+ return _draw(list(range(n)), min(N, tree_counts[n]))
88
+
89
+ # Tree enumeration
90
+
91
+ def all_trees(n: int):
92
+ """All ND trees over labels 0..n-1."""
93
+ return list(_gen(tuple(range(n))))
94
+
95
+ def _gen(labels):
96
+ labels = tuple(sorted(labels))
97
+ if len(labels) <= 1:
98
+ yield ()
99
+ return
100
+ s = labels[0]
101
+ for r in range(1, len(labels)):
102
+ for rest in combinations(labels[1:], r - 1):
103
+ left = tuple(sorted((s,) + rest))
104
+ right = tuple(x for x in labels if x not in left)
105
+ pair = tuple(sorted((left, right), key=lambda t: (-len(t), t)))
106
+ L = ((),) if len(left) == 1 else tuple(_gen(left))
107
+ R = ((),) if len(right) == 1 else tuple(_gen(right))
108
+ for lt in L:
109
+ for rt in R:
110
+ yield (pair,) + lt + rt
111
+
112
+ # Tree embedding
113
+
114
+ def split_analyzer(n_classes):
115
+ """Return a TF-IDF token-analyser function for ND trees."""
116
+ def _analyze(doc):
117
+ splits = ast.literal_eval(doc) if isinstance(doc, str) else doc
118
+ splits = sorted(splits, key=lambda lr: -len(set(lr[0]) | set(lr[1])))
119
+
120
+ max_depth = min(8, n_classes - 2)
121
+ small_group = 3
122
+ all_classes = frozenset(x for L, R in splits for x in tuple(L) + tuple(R))
123
+ depth = {all_classes: 0}
124
+ extra_depth_w = max(0, n_classes - 6)
125
+ toks = []
126
+
127
+ for L, R in splits:
128
+ U = frozenset(L) | frozenset(R)
129
+ d = depth[U]
130
+ if len(L) > 1: depth[frozenset(L)] = d + 1
131
+ if len(R) > 1: depth[frozenset(R)] = d + 1
132
+ if d > max_depth:
133
+ continue
134
+
135
+ L, R = tuple(sorted(L)), tuple(sorted(R))
136
+ A, B = sorted([L, R], key=lambda x: (-len(x), x))
137
+ dbin = min(d, 5)
138
+ w = max(1, max_depth + 1 - d)
139
+
140
+ toks.extend([f"sz:{len(A)}|{len(B)}"] * max(1, w // 2))
141
+ if d == 0:
142
+ toks.extend([f"root:{A}|{B}"] * 2)
143
+ toks.extend([f"root_sz:{len(A)}|{len(B)}"] * 4)
144
+
145
+ for i in A:
146
+ for j in B:
147
+ a, b = sorted((i, j))
148
+ toks.extend([f"sep:{a}-{b}:sz:{len(A)}|{len(B)}"] * max(1, w // 2))
149
+ toks.append(f"sep:{a}-{b}:d{dbin}")
150
+ if d == 0:
151
+ toks.extend([f"root_sep:{a}-{b}"] * 2)
152
+ toks.extend([f"root_sep:{a}-{b}:sz:{len(A)}|{len(B)}"] * 2)
153
+
154
+ for side in (A, B):
155
+ for i, j in combinations(side, 2):
156
+ toks.append(f"same:{i}-{j}")
157
+ if n_classes >= 7 and 1 < len(side) <= small_group:
158
+ toks.extend([f"small_group_size:{len(side)}:d{dbin}"] * extra_depth_w)
159
+
160
+ return toks
161
+ return _analyze
162
+
163
+ # Models
164
+
165
+ def get_model(base):
166
+ if base == "lr":
167
+ return LogisticRegression(
168
+ solver="newton-cholesky",
169
+ C=getattr(config, "C", 0.1), max_iter=2000,
170
+ )
171
+ if base == "lda":
172
+ return LinearDiscriminantAnalysis(solver="svd")
173
+ if base == "svm":
174
+ return SVC(kernel="rbf", C=1.0, gamma="scale", probability=True)
175
+ if base == "decisiontree":
176
+ return DecisionTreeClassifier(criterion="entropy", min_samples_leaf=5, random_state=0)
177
+ raise ValueError(f"Unknown base model: '{base}'")
178
+
179
+ def get_or_train_split_model(left, right, base="lr"):
180
+ key = (base, tuple(left), tuple(right))
181
+ if key in config.model_cache:
182
+ return config.model_cache[key]
183
+ mask = np.isin(config.y, tuple(left) + tuple(right))
184
+ y_node = np.where(np.isin(config.y[mask], left), 0, 1)
185
+ if len(np.unique(y_node)) < 2:
186
+ config.model_cache[key] = None
187
+ return None
188
+ model = get_model(base)
189
+ with warnings.catch_warnings():
190
+ warnings.simplefilter("ignore", RuntimeWarning)
191
+ model.fit(config.X[mask], y_node)
192
+ config.model_cache[key] = model
193
+ return model
194
+
195
+ # Prediction
196
+
197
+ def nd_predict_proba(X, T, classes, base="lr"):
198
+ X = np.asarray(X)
199
+ idx = {c: i for i, c in enumerate(classes)}
200
+ proba = np.ones((X.shape[0], len(classes)), dtype=float)
201
+
202
+ splits = [
203
+ (get_or_train_split_model(L, R, base=base),
204
+ np.array([idx[c] for c in L]),
205
+ np.array([idx[c] for c in R]))
206
+ for L, R in T
207
+ ]
208
+ for model, li, ri in splits:
209
+ if model is None:
210
+ proba[:, ri] = 0.0
211
+ else:
212
+ p = model.predict_proba(X) # (n, 2)
213
+ proba[:, li] *= p[:, 0:1]
214
+ proba[:, ri] *= p[:, 1:2]
215
+
216
+ row_sums = proba.sum(axis=1, keepdims=True)
217
+ return proba / np.where(row_sums == 0, 1.0, row_sums), classes
218
+
219
+
220
+ # Artifact building
221
+
222
+ _PINNED = {"numpy": "2.2.6", "scikit-learn": "1.7.2", "scipy": "1.15.3"}
223
+
224
+ def _check_versions():
225
+ for pkg, pinned in _PINNED.items():
226
+ try:
227
+ installed = importlib.metadata.version(pkg)
228
+ except importlib.metadata.PackageNotFoundError:
229
+ continue
230
+ if installed != pinned:
231
+ warnings.warn(
232
+ f"{pkg}=={installed} installed, but artifacts were built with "
233
+ f"{pkg}=={pinned}. MDS coords may differ — rebuild artifacts or "
234
+ f"pin versions with: pip install -r requirements.txt",
235
+ stacklevel=3,
236
+ )
237
+
238
+ _EXHAUSTIVE_N = {6: 945, 7: 10_395}
239
+
240
+ def _cache_path(C, N, seed, dim, cache_dir):
241
+ if C in _EXHAUSTIVE_N:
242
+ N, seed, dim = _EXHAUSTIVE_N[C], 0, 2
243
+ return Path(cache_dir) / f"art_1_C{C}_N{N}_s{seed}_d{dim}.joblib"
244
+
245
+ def build_artifact(cats, N=5000, seed=0, dim=2, cache_dir="cache_nd"):
246
+ """Build a tree-space artifact from scratch (always regenerates)."""
247
+ _check_versions()
248
+ cats = list(cats)
249
+ C = len(cats)
250
+
251
+ if C <= 7:
252
+ # Call _gen directly so cats (already string labels) are used as-is;
253
+ # all_trees() works on integer labels 0..n-1 and would require remapping.
254
+ trees = list(_gen(tuple(cats)))
255
+ else:
256
+ m = dict(enumerate(cats))
257
+ trees = [
258
+ tuple((tuple(m[i] for i in L), tuple(m[i] for i in R)) for L, R in t)
259
+ for t in sample_trees(C, N)
260
+ ]
261
+
262
+ df = pd.DataFrame({"tree": trees})
263
+ df["tree_encoded"] = df["tree"]
264
+ vec = TfidfVectorizer(analyzer=split_analyzer(C), use_idf=False, norm="l2")
265
+ tfidf_vecs = vec.fit_transform(df["tree_encoded"])
266
+ sim = cosine_similarity(tfidf_vecs)
267
+ dist = np.sqrt(np.maximum(0, 2.0 * (1.0 - sim)))
268
+ np.fill_diagonal(dist, 0.0)
269
+
270
+ mds = MDS(n_components=dim, dissimilarity="precomputed",
271
+ random_state=seed, n_init=1, max_iter=1000, eps=1e-6, n_jobs=-1)
272
+ coords = mds.fit_transform(dist)
273
+ print("MDS iters:", mds.n_iter_)
274
+ print("raw stress:", mds.stress_)
275
+
276
+ upper_tri = np.triu_indices_from(dist, 1)
277
+ stress1 = float(np.sqrt(mds.stress_ / (dist[upper_tri]**2).sum()))
278
+ n_neighbors = max(1, min(20, len(trees) // 2 - 1))
279
+ trust = float(trustworthiness(dist, coords, n_neighbors=n_neighbors, metric="precomputed"))
280
+
281
+ k_min = 14 if C >= 11 else 4
282
+ k_max = min(100 if C >= 11 else 20, len(trees) // 2)
283
+ best_score, best_labels, n_clusters = -1, None, k_min
284
+ for k in range(k_min, k_max):
285
+ lab = KMeans(n_clusters=k, n_init=10, random_state=seed).fit_predict(coords)
286
+ score = silhouette_score(coords, lab)
287
+ if score > best_score:
288
+ best_score, best_labels, n_clusters = score, lab, k
289
+
290
+ metadata = dict(
291
+ C=C, N_sampled=len(trees), exhaustive=C <= 7, seed=seed, dim=dim,
292
+ tokenizer="split_analyzer", traversal="bfs",
293
+ mds_raw_stress=float(mds.stress_), mds_stress1=stress1,
294
+ trustworthiness=trust, n_clusters=n_clusters,
295
+ silhouette_score=float(best_score),
296
+ packages={n: importlib.metadata.version(n) for n in ("numpy", "scikit-learn", "scipy", "joblib")},
297
+ )
298
+
299
+ return dict(
300
+ trees=trees, trees_plot=list(map(str, trees)),
301
+ coords_plot=coords, labels_plot=best_labels.astype(int),
302
+ k=n_clusters, idx_score_in_plot=np.arange(len(trees), dtype=int),
303
+ trustworthiness=trust, metadata=metadata,
304
+ )
305
+
306
+ def get_trees_and_artifact(cats, N=5000, seed=0, dim=2, cache_dir="cache_nd"):
307
+ """Load from cache if present, otherwise build and cache."""
308
+ cats = list(cats)
309
+ C = len(cats)
310
+ fp = _cache_path(C, N, seed, dim, cache_dir)
311
+ if fp.exists():
312
+ return load(fp)
313
+ art = build_artifact(cats, N=N, seed=seed, dim=dim, cache_dir=cache_dir)
314
+ os.makedirs(cache_dir, exist_ok=True)
315
+ dump(art, fp)
316
+ return art
317
+
318
+
319
+ # Spatial statistics
320
+
321
+ def build_knn_weights(coords, k=50):
322
+ """KNN spatial weights matrix for Moran's I, symmetrised and row-standardised."""
323
+ k = min(k, len(coords) - 1)
324
+ with warnings.catch_warnings():
325
+ warnings.simplefilter("ignore")
326
+ w = KNN.from_array(coords, k=k)
327
+ w.transform = "r"
328
+ return w
329
+
330
+ def spatial_stats(values, coords, k=50, permutations=999):
331
+ """Global Moran's I for an array of tree-level values on a 2-D embedding.
332
+
333
+ Returns (I, p_sim) using a KNN spatial weights graph.
334
+ """
335
+ w = build_knn_weights(coords, k=k)
336
+ mi = Moran(values, w, permutations=permutations)
337
+ return mi.I, mi.p_sim