lp2graph 0.3.0__py3-none-any.whl
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.
- lp2graph/__init__.py +54 -0
- lp2graph/cli.py +238 -0
- lp2graph/codec/__init__.py +41 -0
- lp2graph/codec/latex.py +884 -0
- lp2graph/codec/normalize.py +82 -0
- lp2graph/core/__init__.py +35 -0
- lp2graph/core/graph.py +183 -0
- lp2graph/core/loader.py +63 -0
- lp2graph/core/model.py +437 -0
- lp2graph/core/validate.py +237 -0
- lp2graph/export/__init__.py +13 -0
- lp2graph/export/dgl.py +51 -0
- lp2graph/export/latex.py +126 -0
- lp2graph/export/networkx_adapter.py +50 -0
- lp2graph/export/pyg.py +79 -0
- lp2graph/export/pyomo_stub.py +81 -0
- lp2graph/metrics/__init__.py +58 -0
- lp2graph/metrics/classification.py +113 -0
- lp2graph/metrics/flags.py +122 -0
- lp2graph/metrics/result.py +26 -0
- lp2graph/metrics/structural.py +236 -0
- lp2graph/mining/__init__.py +47 -0
- lp2graph/mining/cluster/__init__.py +65 -0
- lp2graph/mining/cluster/agglomerative.py +82 -0
- lp2graph/mining/cluster/distance.py +65 -0
- lp2graph/mining/cluster/operator.py +218 -0
- lp2graph/mining/cluster/silhouette.py +88 -0
- lp2graph/mining/cluster/stability.py +178 -0
- lp2graph/mining/cluster/taxonomy.py +268 -0
- lp2graph/mining/corpusmgr/__init__.py +70 -0
- lp2graph/mining/corpusmgr/dedup.py +183 -0
- lp2graph/mining/corpusmgr/manager.py +79 -0
- lp2graph/mining/corpusmgr/manifest.py +82 -0
- lp2graph/mining/corpusmgr/record.py +101 -0
- lp2graph/mining/corpusmgr/select.py +128 -0
- lp2graph/mining/homologize/__init__.py +82 -0
- lp2graph/mining/homologize/concept.py +134 -0
- lp2graph/mining/homologize/entity.py +217 -0
- lp2graph/mining/homologize/lemmatize.py +80 -0
- lp2graph/mining/homologize/signature.py +166 -0
- lp2graph/mining/homologize/thesaurus.py +70 -0
- lp2graph/mining/homologize/tokenize.py +255 -0
- lp2graph/mining/homologize/vectorize.py +141 -0
- lp2graph/mining/ingest/__init__.py +59 -0
- lp2graph/mining/ingest/code_importers.py +104 -0
- lp2graph/mining/ingest/dispatch.py +148 -0
- lp2graph/mining/ingest/latex_normalizer.py +243 -0
- lp2graph/mining/ingest/pyomo_importer.py +297 -0
- lp2graph/mining/ingest/result.py +124 -0
- lp2graph/mining/isomorphism/__init__.py +26 -0
- lp2graph/mining/isomorphism/report.py +178 -0
- lp2graph/mining/label/__init__.py +70 -0
- lp2graph/mining/label/classifier.py +161 -0
- lp2graph/mining/label/features.py +35 -0
- lp2graph/mining/label/guardrails.py +176 -0
- lp2graph/mining/label/loop.py +314 -0
- lp2graph/mining/label/rules.py +92 -0
- lp2graph/mining/label/store.py +164 -0
- lp2graph/mining/label/vocab.py +64 -0
- lp2graph/mining/provenance.py +90 -0
- lp2graph/mining/versions.py +51 -0
- lp2graph/nl/__init__.py +15 -0
- lp2graph/nl/describe.py +301 -0
- lp2graph/render/__init__.py +11 -0
- lp2graph/render/palette.py +80 -0
- lp2graph/render/svg.py +220 -0
- lp2graph/solve/__init__.py +50 -0
- lp2graph/solve/grounder.py +405 -0
- lp2graph/solve/instance.py +76 -0
- lp2graph/transform/__init__.py +30 -0
- lp2graph/transform/bigm.py +173 -0
- lp2graph/views/__init__.py +17 -0
- lp2graph/views/ground.py +477 -0
- lp2graph/views/hybrid.py +202 -0
- lp2graph/views/schema.py +208 -0
- lp2graph-0.3.0.dist-info/METADATA +206 -0
- lp2graph-0.3.0.dist-info/RECORD +80 -0
- lp2graph-0.3.0.dist-info/WHEEL +4 -0
- lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
- lp2graph-0.3.0.dist-info/licenses/LICENSE +205 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""The cluster-and-name operator ``CN`` (M3 core).
|
|
2
|
+
|
|
3
|
+
``CN(E)`` partitions a set of entities ``E`` (each carrying a TF-IDF concept
|
|
4
|
+
vector) into named parts. It is the single primitive the multi-level taxonomy
|
|
5
|
+
passes are built from:
|
|
6
|
+
|
|
7
|
+
1. Route entities with no concept signal (zero vector) to an explicit
|
|
8
|
+
``unassigned`` part (label ``-1``) — every entity ends up in exactly one
|
|
9
|
+
part.
|
|
10
|
+
2. Cluster the rest in cosine-distance space. Default backend is the
|
|
11
|
+
deterministic average-linkage core; ``fixed_k`` selects K by silhouette;
|
|
12
|
+
``hdbscan`` is used when the optional dependency is installed.
|
|
13
|
+
3. Name each part by aggregated TF-IDF weight: the concept carrying the most
|
|
14
|
+
summed mass across the part's members becomes its name (collisions get a
|
|
15
|
+
deterministic numeric suffix).
|
|
16
|
+
|
|
17
|
+
The whole operator is deterministic given the versioned
|
|
18
|
+
:class:`ClusterConfig`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Literal
|
|
26
|
+
|
|
27
|
+
from lp2graph.mining.cluster.agglomerative import agglomerative
|
|
28
|
+
from lp2graph.mining.cluster.distance import distance_matrix, is_zero
|
|
29
|
+
from lp2graph.mining.cluster.silhouette import select_k, silhouette_score
|
|
30
|
+
from lp2graph.mining.homologize.vectorize import Vocabulary
|
|
31
|
+
from lp2graph.mining.versions import CLUSTERING_VERSION
|
|
32
|
+
|
|
33
|
+
#: Cluster id reserved for entities with no concept signal.
|
|
34
|
+
UNASSIGNED = -1
|
|
35
|
+
|
|
36
|
+
Algorithm = Literal["agglomerative", "fixed_k", "hdbscan"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ClusterConfig:
|
|
41
|
+
"""Versioned, deterministic configuration for :func:`CN`."""
|
|
42
|
+
|
|
43
|
+
algorithm: Algorithm = "agglomerative"
|
|
44
|
+
distance_threshold: float = 0.7
|
|
45
|
+
k: int | None = None
|
|
46
|
+
k_range: tuple[int, int] = (2, 8)
|
|
47
|
+
seed: int = 0
|
|
48
|
+
version: str = CLUSTERING_VERSION
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class NamedClustering:
|
|
53
|
+
"""The result of :func:`CN`: a named partition over the input entities.
|
|
54
|
+
|
|
55
|
+
``labels`` is aligned to the input order (``-1`` == unassigned).
|
|
56
|
+
``names`` maps each cluster id to its concept name. ``members`` maps each
|
|
57
|
+
id to the (sorted) input indices it contains. ``silhouette`` is the mean
|
|
58
|
+
silhouette over the assigned points.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
labels: tuple[int, ...]
|
|
62
|
+
names: dict[int, str]
|
|
63
|
+
members: dict[int, tuple[int, ...]]
|
|
64
|
+
config: ClusterConfig
|
|
65
|
+
silhouette: float
|
|
66
|
+
top_concepts: dict[int, tuple[tuple[str, float], ...]] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def name_of(self, index: int) -> str:
|
|
69
|
+
"""Name of the cluster the entity at ``index`` belongs to."""
|
|
70
|
+
return self.names[self.labels[index]]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def n_clusters(self) -> int:
|
|
74
|
+
"""Number of real (non-unassigned) clusters."""
|
|
75
|
+
return len([cid for cid in self.members if cid != UNASSIGNED])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _aggregate_weights(
|
|
79
|
+
members: Sequence[int], vectors: Sequence[Sequence[float]], vocab: Vocabulary
|
|
80
|
+
) -> list[tuple[str, float]]:
|
|
81
|
+
totals = [0.0] * len(vocab)
|
|
82
|
+
for idx in members:
|
|
83
|
+
v = vectors[idx]
|
|
84
|
+
for j, val in enumerate(v):
|
|
85
|
+
if val != 0.0:
|
|
86
|
+
totals[j] += val
|
|
87
|
+
weighted = [(vocab.concepts[j], totals[j]) for j in range(len(vocab)) if totals[j] > 0.0]
|
|
88
|
+
# Sort by descending weight, then concept name for deterministic ties.
|
|
89
|
+
weighted.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
90
|
+
return weighted
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _name_clusters(
|
|
94
|
+
members: dict[int, tuple[int, ...]],
|
|
95
|
+
vectors: Sequence[Sequence[float]],
|
|
96
|
+
vocab: Vocabulary,
|
|
97
|
+
) -> tuple[dict[int, str], dict[int, tuple[tuple[str, float], ...]]]:
|
|
98
|
+
names: dict[int, str] = {}
|
|
99
|
+
tops: dict[int, tuple[tuple[str, float], ...]] = {}
|
|
100
|
+
used: dict[str, int] = {}
|
|
101
|
+
# Deterministic order: by cluster id.
|
|
102
|
+
for cid in sorted(members):
|
|
103
|
+
if cid == UNASSIGNED:
|
|
104
|
+
names[cid] = "unassigned"
|
|
105
|
+
tops[cid] = ()
|
|
106
|
+
continue
|
|
107
|
+
weighted = _aggregate_weights(members[cid], vectors, vocab)
|
|
108
|
+
tops[cid] = tuple(weighted[:5])
|
|
109
|
+
base = weighted[0][0] if weighted else f"cluster_{cid}"
|
|
110
|
+
if base in used:
|
|
111
|
+
used[base] += 1
|
|
112
|
+
name = f"{base}_{used[base]}"
|
|
113
|
+
else:
|
|
114
|
+
used[base] = 1
|
|
115
|
+
name = base
|
|
116
|
+
names[cid] = name
|
|
117
|
+
return names, tops
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _hdbscan_labels(dist: Sequence[Sequence[float]], min_cluster_size: int) -> list[int]:
|
|
121
|
+
import hdbscan # local import; optional dependency
|
|
122
|
+
|
|
123
|
+
clusterer = hdbscan.HDBSCAN(metric="precomputed", min_cluster_size=max(2, min_cluster_size))
|
|
124
|
+
rows = [[float(x) for x in row] for row in dist]
|
|
125
|
+
labels = clusterer.fit_predict(rows)
|
|
126
|
+
return [int(x) for x in labels]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def CN(
|
|
130
|
+
entities: Sequence[object],
|
|
131
|
+
vectors: Sequence[Sequence[float]],
|
|
132
|
+
vocab: Vocabulary,
|
|
133
|
+
config: ClusterConfig | None = None,
|
|
134
|
+
) -> NamedClustering:
|
|
135
|
+
"""Cluster ``entities`` (by ``vectors``) and name the parts.
|
|
136
|
+
|
|
137
|
+
``entities`` is only used for its length and ordering; the partition is
|
|
138
|
+
computed from ``vectors`` and named from ``vocab``. Returns a
|
|
139
|
+
deterministic :class:`NamedClustering`.
|
|
140
|
+
"""
|
|
141
|
+
cfg = config or ClusterConfig()
|
|
142
|
+
n = len(vectors)
|
|
143
|
+
if len(entities) != n:
|
|
144
|
+
raise ValueError("entities and vectors must have the same length")
|
|
145
|
+
|
|
146
|
+
labels = [UNASSIGNED] * n
|
|
147
|
+
assigned = [i for i in range(n) if not is_zero(vectors[i])]
|
|
148
|
+
|
|
149
|
+
if assigned:
|
|
150
|
+
sub_vectors = [vectors[i] for i in assigned]
|
|
151
|
+
sub_dist = distance_matrix(sub_vectors)
|
|
152
|
+
sub_labels = _cluster(sub_dist, cfg)
|
|
153
|
+
# Re-pack sub-cluster ids into a dense 0..K-1 space, ordered by first
|
|
154
|
+
# appearance, then map back to original indices.
|
|
155
|
+
remap: dict[int, int] = {}
|
|
156
|
+
for sub_idx, lab in enumerate(sub_labels):
|
|
157
|
+
if lab < 0: # hdbscan noise → unassigned
|
|
158
|
+
continue
|
|
159
|
+
if lab not in remap:
|
|
160
|
+
remap[lab] = len(remap)
|
|
161
|
+
labels[assigned[sub_idx]] = remap[lab]
|
|
162
|
+
|
|
163
|
+
grouped: dict[int, list[int]] = {}
|
|
164
|
+
for i, lab in enumerate(labels):
|
|
165
|
+
grouped.setdefault(lab, []).append(i)
|
|
166
|
+
members: dict[int, tuple[int, ...]] = {
|
|
167
|
+
cid: tuple(sorted(idxs)) for cid, idxs in grouped.items()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
names, tops = _name_clusters(members, vectors, vocab)
|
|
171
|
+
|
|
172
|
+
real_labels = [labels[i] for i in assigned]
|
|
173
|
+
if assigned and len({lab for lab in real_labels if lab >= 0}) >= 2:
|
|
174
|
+
sub_dist = distance_matrix([vectors[i] for i in assigned])
|
|
175
|
+
sil = silhouette_score(sub_dist, real_labels)
|
|
176
|
+
else:
|
|
177
|
+
sil = 0.0
|
|
178
|
+
|
|
179
|
+
return NamedClustering(
|
|
180
|
+
labels=tuple(labels),
|
|
181
|
+
names=names,
|
|
182
|
+
members=members,
|
|
183
|
+
config=cfg,
|
|
184
|
+
silhouette=sil,
|
|
185
|
+
top_concepts=tops,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cluster(dist: Sequence[Sequence[float]], cfg: ClusterConfig) -> list[int]:
|
|
190
|
+
n = len(dist)
|
|
191
|
+
if n == 0:
|
|
192
|
+
return []
|
|
193
|
+
if n == 1:
|
|
194
|
+
return [0]
|
|
195
|
+
if cfg.algorithm == "hdbscan":
|
|
196
|
+
return _hdbscan_labels(dist, min_cluster_size=2)
|
|
197
|
+
if cfg.algorithm == "fixed_k":
|
|
198
|
+
k_target = cfg.k
|
|
199
|
+
if k_target is None:
|
|
200
|
+
k_target, _ = select_k(
|
|
201
|
+
dist,
|
|
202
|
+
lambda k: agglomerative(dist, distance_threshold=None, k=k),
|
|
203
|
+
k_min=cfg.k_range[0],
|
|
204
|
+
k_max=cfg.k_range[1],
|
|
205
|
+
)
|
|
206
|
+
k_target = max(1, min(k_target, n))
|
|
207
|
+
return list(agglomerative(dist, distance_threshold=None, k=k_target))
|
|
208
|
+
# default: threshold-based agglomerative
|
|
209
|
+
return list(agglomerative(dist, distance_threshold=cfg.distance_threshold, k=None))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
__all__ = [
|
|
213
|
+
"CN",
|
|
214
|
+
"UNASSIGNED",
|
|
215
|
+
"Algorithm",
|
|
216
|
+
"ClusterConfig",
|
|
217
|
+
"NamedClustering",
|
|
218
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Silhouette score and K selection (M3).
|
|
2
|
+
|
|
3
|
+
The silhouette coefficient measures how well each point sits in its assigned
|
|
4
|
+
cluster versus the nearest other cluster, in the same cosine-distance space
|
|
5
|
+
the clustering runs in. It serves two roles in the method: it is the
|
|
6
|
+
selection criterion for the fixed-K fallback, and it is reported (alongside
|
|
7
|
+
bootstrap ARI) in the stability report.
|
|
8
|
+
|
|
9
|
+
Pure Python and deterministic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def silhouette_samples(dist: Sequence[Sequence[float]], labels: Sequence[int]) -> list[float]:
|
|
19
|
+
"""Per-point silhouette values given a distance matrix and labels.
|
|
20
|
+
|
|
21
|
+
Points whose cluster has size 1, and all points when there is only one
|
|
22
|
+
cluster, get a silhouette of 0 (the conventional definition).
|
|
23
|
+
"""
|
|
24
|
+
n = len(labels)
|
|
25
|
+
by_cluster: dict[int, list[int]] = defaultdict(list)
|
|
26
|
+
for i, lab in enumerate(labels):
|
|
27
|
+
by_cluster[lab].append(i)
|
|
28
|
+
clusters = sorted(by_cluster)
|
|
29
|
+
out = [0.0] * n
|
|
30
|
+
if len(clusters) < 2:
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
for i in range(n):
|
|
34
|
+
own = labels[i]
|
|
35
|
+
own_members = by_cluster[own]
|
|
36
|
+
if len(own_members) <= 1:
|
|
37
|
+
out[i] = 0.0
|
|
38
|
+
continue
|
|
39
|
+
a = sum(dist[i][j] for j in own_members if j != i) / (len(own_members) - 1)
|
|
40
|
+
b = float("inf")
|
|
41
|
+
for lab in clusters:
|
|
42
|
+
if lab == own:
|
|
43
|
+
continue
|
|
44
|
+
members = by_cluster[lab]
|
|
45
|
+
mean_d = sum(dist[i][j] for j in members) / len(members)
|
|
46
|
+
if mean_d < b:
|
|
47
|
+
b = mean_d
|
|
48
|
+
denom = max(a, b)
|
|
49
|
+
out[i] = 0.0 if denom == 0.0 else (b - a) / denom
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def silhouette_score(dist: Sequence[Sequence[float]], labels: Sequence[int]) -> float:
|
|
54
|
+
"""Mean silhouette over all points (0.0 for a degenerate partition)."""
|
|
55
|
+
samples = silhouette_samples(dist, labels)
|
|
56
|
+
if not samples:
|
|
57
|
+
return 0.0
|
|
58
|
+
return sum(samples) / len(samples)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def select_k(
|
|
62
|
+
dist: Sequence[Sequence[float]],
|
|
63
|
+
cluster_fn: Callable[[int], Sequence[int]],
|
|
64
|
+
*,
|
|
65
|
+
k_min: int,
|
|
66
|
+
k_max: int,
|
|
67
|
+
) -> tuple[int, float]:
|
|
68
|
+
"""Pick the K in ``[k_min, k_max]`` with the highest mean silhouette.
|
|
69
|
+
|
|
70
|
+
``cluster_fn(k)`` must return labels for ``k`` clusters. Ties are broken
|
|
71
|
+
toward the smaller K (simpler partition). Returns ``(best_k, best_score)``.
|
|
72
|
+
"""
|
|
73
|
+
n = len(dist)
|
|
74
|
+
hi = min(k_max, n - 1)
|
|
75
|
+
lo = max(2, k_min)
|
|
76
|
+
if hi < lo:
|
|
77
|
+
return (1, 0.0)
|
|
78
|
+
best_k = lo
|
|
79
|
+
best_score = float("-inf")
|
|
80
|
+
for k in range(lo, hi + 1):
|
|
81
|
+
score = silhouette_score(dist, cluster_fn(k))
|
|
82
|
+
if score > best_score:
|
|
83
|
+
best_score = score
|
|
84
|
+
best_k = k
|
|
85
|
+
return (best_k, best_score)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["select_k", "silhouette_samples", "silhouette_score"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Clustering stability report: silhouette, bootstrap ARI, sensitivity (M3).
|
|
2
|
+
|
|
3
|
+
A partition is only trustworthy if it survives resampling and small changes
|
|
4
|
+
to the knobs. This module quantifies that:
|
|
5
|
+
|
|
6
|
+
- **silhouette** — cohesion/separation of the reported partition (re-exported
|
|
7
|
+
from :mod:`lp2graph.mining.cluster.silhouette`).
|
|
8
|
+
- **bootstrap ARI** — resample the entities with replacement, re-cluster, and
|
|
9
|
+
measure Adjusted Rand Index against the reference partition restricted to
|
|
10
|
+
the shared points; report mean and spread.
|
|
11
|
+
- **sensitivity** — re-cluster under alternative configs (a different
|
|
12
|
+
algorithm, a coarsened vocabulary ``|C|``) and report the ARI to the
|
|
13
|
+
reference partition.
|
|
14
|
+
|
|
15
|
+
Determinism: bootstrap resampling uses a seeded :class:`random.Random`, so the
|
|
16
|
+
report is reproducible from ``(seed, versions)``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import random
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from lp2graph.mining.cluster.operator import CN, ClusterConfig, NamedClustering
|
|
27
|
+
from lp2graph.mining.homologize.vectorize import Vocabulary, build_vocabulary
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def adjusted_rand_index(a: Sequence[int], b: Sequence[int]) -> float:
|
|
31
|
+
"""Adjusted Rand Index between two labelings of the same points.
|
|
32
|
+
|
|
33
|
+
Returns 1.0 for identical partitions (up to relabeling), ~0.0 for random
|
|
34
|
+
agreement. Defined as 1.0 for the degenerate all-equal case.
|
|
35
|
+
"""
|
|
36
|
+
if len(a) != len(b):
|
|
37
|
+
raise ValueError("labelings must have equal length")
|
|
38
|
+
n = len(a)
|
|
39
|
+
if n == 0:
|
|
40
|
+
return 1.0
|
|
41
|
+
contingency: dict[tuple[int, int], int] = defaultdict(int)
|
|
42
|
+
rows: dict[int, int] = defaultdict(int)
|
|
43
|
+
cols: dict[int, int] = defaultdict(int)
|
|
44
|
+
for x, y in zip(a, b, strict=True):
|
|
45
|
+
contingency[(x, y)] += 1
|
|
46
|
+
rows[x] += 1
|
|
47
|
+
cols[y] += 1
|
|
48
|
+
|
|
49
|
+
def comb2(x: int) -> int:
|
|
50
|
+
return x * (x - 1) // 2
|
|
51
|
+
|
|
52
|
+
sum_comb = sum(comb2(v) for v in contingency.values())
|
|
53
|
+
sum_rows = sum(comb2(v) for v in rows.values())
|
|
54
|
+
sum_cols = sum(comb2(v) for v in cols.values())
|
|
55
|
+
total = comb2(n)
|
|
56
|
+
expected = (sum_rows * sum_cols) / total if total else 0.0
|
|
57
|
+
max_index = (sum_rows + sum_cols) / 2.0
|
|
58
|
+
if max_index == expected:
|
|
59
|
+
return 1.0
|
|
60
|
+
return (sum_comb - expected) / (max_index - expected)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class StabilityReport:
|
|
65
|
+
"""Silhouette + bootstrap ARI + sensitivity for one clustering."""
|
|
66
|
+
|
|
67
|
+
silhouette: float
|
|
68
|
+
bootstrap_ari_mean: float
|
|
69
|
+
bootstrap_ari_min: float
|
|
70
|
+
bootstrap_ari_max: float
|
|
71
|
+
n_bootstrap: int
|
|
72
|
+
sensitivity: dict[str, float]
|
|
73
|
+
config_version: str
|
|
74
|
+
seed: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def bootstrap_ari(
|
|
78
|
+
vectors: Sequence[Sequence[float]],
|
|
79
|
+
vocab: Vocabulary,
|
|
80
|
+
reference: NamedClustering,
|
|
81
|
+
config: ClusterConfig,
|
|
82
|
+
*,
|
|
83
|
+
n_bootstrap: int = 25,
|
|
84
|
+
) -> tuple[float, float, float]:
|
|
85
|
+
"""Mean/min/max ARI of bootstrap re-clusterings vs the reference partition.
|
|
86
|
+
|
|
87
|
+
Each bootstrap draws ``n`` indices with replacement, re-clusters the
|
|
88
|
+
unique drawn points, and compares to the reference labels on those same
|
|
89
|
+
points. Deterministic via ``config.seed``.
|
|
90
|
+
"""
|
|
91
|
+
n = len(vectors)
|
|
92
|
+
if n < 3:
|
|
93
|
+
return (1.0, 1.0, 1.0)
|
|
94
|
+
rng = random.Random(config.seed)
|
|
95
|
+
scores: list[float] = []
|
|
96
|
+
for _ in range(n_bootstrap):
|
|
97
|
+
drawn = sorted({rng.randrange(n) for _ in range(n)})
|
|
98
|
+
if len(drawn) < 2:
|
|
99
|
+
continue
|
|
100
|
+
sub_vectors = [vectors[i] for i in drawn]
|
|
101
|
+
sub = CN(drawn, sub_vectors, vocab, config)
|
|
102
|
+
ref_labels = [reference.labels[i] for i in drawn]
|
|
103
|
+
scores.append(adjusted_rand_index(ref_labels, list(sub.labels)))
|
|
104
|
+
if not scores:
|
|
105
|
+
return (1.0, 1.0, 1.0)
|
|
106
|
+
return (sum(scores) / len(scores), min(scores), max(scores))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _coarsen_vocabulary(vocab: Vocabulary, keep_every: int) -> Vocabulary:
|
|
110
|
+
"""Drop concepts to shrink ``|C|`` (deterministic stride over sorted axis)."""
|
|
111
|
+
kept = tuple(c for i, c in enumerate(vocab.concepts) if i % keep_every == 0)
|
|
112
|
+
return build_vocabulary([{c: 1 for c in kept}], version=vocab.version)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sensitivity(
|
|
116
|
+
vectors: Sequence[Sequence[float]],
|
|
117
|
+
vocab: Vocabulary,
|
|
118
|
+
reference: NamedClustering,
|
|
119
|
+
config: ClusterConfig,
|
|
120
|
+
) -> dict[str, float]:
|
|
121
|
+
"""ARI of the reference partition against alternative configurations.
|
|
122
|
+
|
|
123
|
+
Probes robustness to the clustering algorithm (``fixed_k``) and to a
|
|
124
|
+
coarsened vocabulary ``|C|`` (every-other concept). Each entry is the ARI
|
|
125
|
+
between the reference labels and the labels under that variation.
|
|
126
|
+
"""
|
|
127
|
+
out: dict[str, float] = {}
|
|
128
|
+
|
|
129
|
+
alt_algo = ClusterConfig(
|
|
130
|
+
algorithm="fixed_k",
|
|
131
|
+
k_range=config.k_range,
|
|
132
|
+
seed=config.seed,
|
|
133
|
+
version=config.version,
|
|
134
|
+
)
|
|
135
|
+
alt = CN(list(range(len(vectors))), vectors, vocab, alt_algo)
|
|
136
|
+
out["algorithm=fixed_k"] = adjusted_rand_index(list(reference.labels), list(alt.labels))
|
|
137
|
+
|
|
138
|
+
if len(vocab) >= 4:
|
|
139
|
+
coarse_vocab = _coarsen_vocabulary(vocab, keep_every=2)
|
|
140
|
+
pos = {c: i for i, c in enumerate(vocab.concepts)}
|
|
141
|
+
# Re-project vectors onto the coarsened axis (keep matching coordinates).
|
|
142
|
+
coarse_vectors = [tuple(v[pos[c]] for c in coarse_vocab.concepts) for v in vectors]
|
|
143
|
+
coarse = CN(list(range(len(vectors))), coarse_vectors, coarse_vocab, config)
|
|
144
|
+
out["vocabulary=halved"] = adjusted_rand_index(list(reference.labels), list(coarse.labels))
|
|
145
|
+
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def stability_report(
|
|
150
|
+
vectors: Sequence[Sequence[float]],
|
|
151
|
+
vocab: Vocabulary,
|
|
152
|
+
clustering: NamedClustering,
|
|
153
|
+
config: ClusterConfig,
|
|
154
|
+
*,
|
|
155
|
+
n_bootstrap: int = 25,
|
|
156
|
+
) -> StabilityReport:
|
|
157
|
+
"""Assemble the full stability report for one clustering."""
|
|
158
|
+
mean, lo, hi = bootstrap_ari(vectors, vocab, clustering, config, n_bootstrap=n_bootstrap)
|
|
159
|
+
sens = sensitivity(vectors, vocab, clustering, config)
|
|
160
|
+
return StabilityReport(
|
|
161
|
+
silhouette=clustering.silhouette,
|
|
162
|
+
bootstrap_ari_mean=mean,
|
|
163
|
+
bootstrap_ari_min=lo,
|
|
164
|
+
bootstrap_ari_max=hi,
|
|
165
|
+
n_bootstrap=n_bootstrap,
|
|
166
|
+
sensitivity=sens,
|
|
167
|
+
config_version=config.version,
|
|
168
|
+
seed=config.seed,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"StabilityReport",
|
|
174
|
+
"adjusted_rand_index",
|
|
175
|
+
"bootstrap_ari",
|
|
176
|
+
"sensitivity",
|
|
177
|
+
"stability_report",
|
|
178
|
+
]
|