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.
- ndscape-0.1.4/.gitattributes +10 -0
- ndscape-0.1.4/.github/workflows/release.yml +54 -0
- ndscape-0.1.4/.gitignore +55 -0
- ndscape-0.1.4/CITATION.cff +47 -0
- {ndscape-0.1.2/ndscape.egg-info → ndscape-0.1.4}/PKG-INFO +9 -6
- {ndscape-0.1.2 → ndscape-0.1.4}/README.md +7 -3
- ndscape-0.1.4/config.py +14 -0
- ndscape-0.1.4/configs/artifacts.yml +81 -0
- ndscape-0.1.4/core.py +337 -0
- ndscape-0.1.4/nd/ACND.py +80 -0
- ndscape-0.1.4/nd/BBoK.py +45 -0
- ndscape-0.1.4/nd/BestOfK.py +14 -0
- ndscape-0.1.4/nd/CBND.py +33 -0
- ndscape-0.1.4/nd/NDC.py +50 -0
- ndscape-0.1.4/nd/NestedDichotomy.py +165 -0
- ndscape-0.1.4/nd/RPND.py +51 -0
- ndscape-0.1.4/nd/RandomGeneration.py +143 -0
- ndscape-0.1.4/nd/__init__.py +8 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/README.md +5 -3
- {ndscape-0.1.2 → ndscape-0.1.4/ndscape.egg-info}/PKG-INFO +9 -6
- ndscape-0.1.4/ndscape.egg-info/SOURCES.txt +62 -0
- ndscape-0.1.4/ndscape.egg-info/scm_file_list.json +169 -0
- ndscape-0.1.4/ndscape.egg-info/scm_version.json +8 -0
- ndscape-0.1.4/paper_artifact.md +138 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/pyproject.toml +8 -5
- ndscape-0.1.4/pytest.ini +4 -0
- ndscape-0.1.4/reproduce.py +175 -0
- ndscape-0.1.4/requirements.txt +15 -0
- ndscape-0.1.4/run_datasets.py +171 -0
- ndscape-0.1.4/run_simulations.py +474 -0
- ndscape-0.1.4/scripts/csv_to_configs.py +147 -0
- ndscape-0.1.4/scripts/download_zenodo.py +217 -0
- ndscape-0.1.4/scripts/make_ablation_summaries.py +49 -0
- ndscape-0.1.4/scripts/make_ablation_tables.py +133 -0
- ndscape-0.1.4/scripts/make_artifacts.py +81 -0
- ndscape-0.1.4/scripts/make_dataset_plots.py +340 -0
- ndscape-0.1.4/scripts/make_dataset_tables.py +247 -0
- ndscape-0.1.4/scripts/make_paper_tables.py +179 -0
- ndscape-0.1.4/scripts/make_simulation_plots.py +192 -0
- ndscape-0.1.4/scripts/make_simulation_tables.py +279 -0
- ndscape-0.1.4/scripts/moran_simulation_analysis.py +291 -0
- ndscape-0.1.4/scripts/verify_artifacts.py +127 -0
- ndscape-0.1.4/zenodo/art_1_C10_N15000_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/art_1_C19_N15000_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/art_1_C6_N945_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/art_1_C7_N10395_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/art_1_C8_N15000_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/art_1_C9_N15000_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/bow_1_C6_N945_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/bow_1_C7_N10395_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/manifest.csv +39 -0
- ndscape-0.1.4/zenodo/rf_1_C6_N945_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/rf_1_C7_N10395_s0_d2.joblib +3 -0
- ndscape-0.1.4/zenodo/simulation_configs.csv +416 -0
- ndscape-0.1.4/zenodo/simulation_configs_dt.csv +151 -0
- ndscape-0.1.2/ndscape.egg-info/SOURCES.txt +0 -13
- {ndscape-0.1.2 → ndscape-0.1.4}/LICENSE +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/MANIFEST.in +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/__init__.py +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/api.py +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape/plot.py +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/dependency_links.txt +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/requires.txt +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/ndscape.egg-info/top_level.txt +0 -0
- {ndscape-0.1.2 → ndscape-0.1.4}/setup.cfg +0 -0
|
@@ -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
|
ndscape-0.1.4/.gitignore
ADDED
|
@@ -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.
|
|
4
|
-
Summary:
|
|
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,
|
|
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
|
-
|
|
33
|
+
Nested dichotomies in Python: fit, score and map nested dichotomy (ND) trees for multiclass classification.
|
|
33
34
|
|
|
34
|
-
A nested dichotomy reduces
|
|
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
|
|
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:
|
|
1
|
+
# ndscape: nested dichotomies in Python
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/ndscape/)
|
|
4
4
|
[](https://doi.org/10.5281/zenodo.20456943)
|
|
5
5
|
[](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).
|
ndscape-0.1.4/config.py
ADDED
|
@@ -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
|