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,26 @@
|
|
|
1
|
+
"""Result type returned by every metric."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class MetricResult:
|
|
11
|
+
"""The output of a single metric.
|
|
12
|
+
|
|
13
|
+
``name`` is a stable machine identifier; ``value`` is whatever the
|
|
14
|
+
metric produces (int, float, bool, dict). ``explanation`` is a short
|
|
15
|
+
one-line human-readable description. ``data`` carries optional
|
|
16
|
+
auxiliary fields (e.g. the diameter path along with the diameter
|
|
17
|
+
length).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
value: Any
|
|
22
|
+
explanation: str = ""
|
|
23
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["MetricResult"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Structural metrics over typed graphs.
|
|
2
|
+
|
|
3
|
+
Adapted from joernmht/raiLPminerExperimentation
|
|
4
|
+
(railpminer/analysis/metrics.py and railpminer/visualization/diameter.py),
|
|
5
|
+
MIT License. The algorithms are the same; the surface targets the new
|
|
6
|
+
internal :class:`~lp2graph.core.graph.Graph` rather than NetworkX
|
|
7
|
+
directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import defaultdict, deque
|
|
13
|
+
|
|
14
|
+
from lp2graph.core.graph import Graph
|
|
15
|
+
from lp2graph.core.model import Formulation
|
|
16
|
+
from lp2graph.metrics.result import MetricResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def node_counts_by_class(g: Graph) -> MetricResult:
|
|
20
|
+
"""Count nodes by their high-level class.
|
|
21
|
+
|
|
22
|
+
Returns a dict like ``{"variable": 4, "constraint": 6, ...}``.
|
|
23
|
+
"""
|
|
24
|
+
counts: dict[str, int] = defaultdict(int)
|
|
25
|
+
for n in g.nodes:
|
|
26
|
+
counts[n.cls] += 1
|
|
27
|
+
return MetricResult(
|
|
28
|
+
name="node_counts_by_class",
|
|
29
|
+
value=dict(counts),
|
|
30
|
+
explanation="Count of nodes grouped by class.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def edge_density(g: Graph) -> MetricResult:
|
|
35
|
+
"""Edge density: |E| / max(1, |V| * (|V| - 1))."""
|
|
36
|
+
n = len(g)
|
|
37
|
+
e = len(g.edges)
|
|
38
|
+
density = 0.0 if n <= 1 else e / (n * (n - 1))
|
|
39
|
+
return MetricResult(
|
|
40
|
+
name="edge_density",
|
|
41
|
+
value=density,
|
|
42
|
+
explanation="Directed-graph edge density.",
|
|
43
|
+
data={"nodes": n, "edges": e},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def constraint_variable_ratio(g: Graph) -> MetricResult:
|
|
48
|
+
"""Number of constraint nodes divided by number of variable nodes.
|
|
49
|
+
|
|
50
|
+
Operates on the schema or hybrid view. For ground views, instance
|
|
51
|
+
nodes are counted instead.
|
|
52
|
+
"""
|
|
53
|
+
constraints = len([n for n in g.nodes if n.cls in ("constraint", "instance_constraint")])
|
|
54
|
+
variables = len([n for n in g.nodes if n.cls in ("variable", "instance_variable")])
|
|
55
|
+
if variables == 0:
|
|
56
|
+
ratio = float("inf") if constraints > 0 else 0.0
|
|
57
|
+
else:
|
|
58
|
+
ratio = constraints / variables
|
|
59
|
+
return MetricResult(
|
|
60
|
+
name="constraint_variable_ratio",
|
|
61
|
+
value=ratio,
|
|
62
|
+
explanation="Constraint-to-variable count ratio.",
|
|
63
|
+
data={"constraints": constraints, "variables": variables},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def minimal_size(g: Graph) -> MetricResult:
|
|
68
|
+
"""The product |constraints| · |variables| (the dense incidence-matrix size).
|
|
69
|
+
|
|
70
|
+
A weak proxy for problem size; useful for relative comparison across
|
|
71
|
+
formulations.
|
|
72
|
+
"""
|
|
73
|
+
constraints = len([n for n in g.nodes if n.cls in ("constraint", "instance_constraint")])
|
|
74
|
+
variables = len([n for n in g.nodes if n.cls in ("variable", "instance_variable")])
|
|
75
|
+
cv = max(constraints, 1) * max(variables, 1)
|
|
76
|
+
return MetricResult(
|
|
77
|
+
name="minimal_size",
|
|
78
|
+
value=cv,
|
|
79
|
+
explanation="Product of constraint count and variable count.",
|
|
80
|
+
data={"constraints": constraints, "variables": variables},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def model_coherence(g: Graph) -> MetricResult:
|
|
85
|
+
"""1 if the underlying undirected graph is connected, else 0.
|
|
86
|
+
|
|
87
|
+
A coherent model has every variable reached by some path through
|
|
88
|
+
constraints. Disconnected components usually signal a missing
|
|
89
|
+
coupling.
|
|
90
|
+
"""
|
|
91
|
+
if len(g) <= 1:
|
|
92
|
+
return MetricResult(name="model_coherence", value=1, explanation="Trivially connected.")
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
start = g.nodes[0].id
|
|
95
|
+
queue = deque([start])
|
|
96
|
+
seen.add(start)
|
|
97
|
+
adj: dict[str, set[str]] = defaultdict(set)
|
|
98
|
+
for e in g.edges:
|
|
99
|
+
adj[e.src].add(e.dst)
|
|
100
|
+
adj[e.dst].add(e.src)
|
|
101
|
+
while queue:
|
|
102
|
+
n = queue.popleft()
|
|
103
|
+
for m in adj[n]:
|
|
104
|
+
if m not in seen:
|
|
105
|
+
seen.add(m)
|
|
106
|
+
queue.append(m)
|
|
107
|
+
coherent = 1 if len(seen) == len(g) else 0
|
|
108
|
+
return MetricResult(
|
|
109
|
+
name="model_coherence",
|
|
110
|
+
value=coherent,
|
|
111
|
+
explanation="1 if undirected graph is connected, else 0.",
|
|
112
|
+
data={"reached": len(seen), "total": len(g)},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def model_completeness(f: Formulation) -> MetricResult:
|
|
117
|
+
"""1 if the formulation is a recoverable model, else 0.
|
|
118
|
+
|
|
119
|
+
A *complete* model declares an objective together with at least one
|
|
120
|
+
variable and at least one constraint -- the minimal evidence that a
|
|
121
|
+
whole model, rather than a fragment, was extracted. This is the
|
|
122
|
+
companion well-formedness indicator to :func:`model_coherence`; unlike
|
|
123
|
+
coherence (a graph property) it reads the canonical
|
|
124
|
+
:class:`~lp2graph.core.model.Formulation` directly.
|
|
125
|
+
"""
|
|
126
|
+
complete = (
|
|
127
|
+
1 if f.objective is not None and len(f.variables) >= 1 and len(f.constraints) >= 1 else 0
|
|
128
|
+
)
|
|
129
|
+
return MetricResult(
|
|
130
|
+
name="model_completeness",
|
|
131
|
+
value=complete,
|
|
132
|
+
explanation="1 if the model declares an objective, >=1 variable and >=1 constraint, else 0.",
|
|
133
|
+
data={
|
|
134
|
+
"has_objective": f.objective is not None,
|
|
135
|
+
"n_variables": len(f.variables),
|
|
136
|
+
"n_constraints": len(f.constraints),
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def graph_diameter(g: Graph) -> MetricResult:
|
|
142
|
+
"""Diameter of the largest connected component (undirected, unweighted).
|
|
143
|
+
|
|
144
|
+
Returns the diameter and one diameter-realizing path. If the graph
|
|
145
|
+
has fewer than two nodes, returns 0.
|
|
146
|
+
"""
|
|
147
|
+
if len(g) < 2:
|
|
148
|
+
return MetricResult(name="graph_diameter", value=0, explanation="Too small.")
|
|
149
|
+
adj: dict[str, set[str]] = defaultdict(set)
|
|
150
|
+
for e in g.edges:
|
|
151
|
+
adj[e.src].add(e.dst)
|
|
152
|
+
adj[e.dst].add(e.src)
|
|
153
|
+
|
|
154
|
+
# Find the largest connected component.
|
|
155
|
+
seen_global: set[str] = set()
|
|
156
|
+
components: list[set[str]] = []
|
|
157
|
+
for n in g.nodes:
|
|
158
|
+
if n.id in seen_global:
|
|
159
|
+
continue
|
|
160
|
+
comp: set[str] = set()
|
|
161
|
+
queue = deque([n.id])
|
|
162
|
+
seen_global.add(n.id)
|
|
163
|
+
comp.add(n.id)
|
|
164
|
+
while queue:
|
|
165
|
+
x = queue.popleft()
|
|
166
|
+
for y in adj[x]:
|
|
167
|
+
if y not in seen_global:
|
|
168
|
+
seen_global.add(y)
|
|
169
|
+
comp.add(y)
|
|
170
|
+
queue.append(y)
|
|
171
|
+
components.append(comp)
|
|
172
|
+
largest = max(components, key=len)
|
|
173
|
+
if len(largest) < 2:
|
|
174
|
+
return MetricResult(name="graph_diameter", value=0, explanation="LCC has < 2 nodes.")
|
|
175
|
+
|
|
176
|
+
# Two-pass BFS approximation gives the exact diameter for trees and
|
|
177
|
+
# an exact answer for arbitrary graphs only when followed by full
|
|
178
|
+
# BFS from every node. Here we do the full BFS for correctness; the
|
|
179
|
+
# graphs we care about are small (template-scale).
|
|
180
|
+
best = 0
|
|
181
|
+
best_path: list[str] = []
|
|
182
|
+
nodes = sorted(largest) # deterministic
|
|
183
|
+
for src in nodes:
|
|
184
|
+
dist = {src: 0}
|
|
185
|
+
prev: dict[str, str | None] = {src: None}
|
|
186
|
+
queue = deque([src])
|
|
187
|
+
while queue:
|
|
188
|
+
x = queue.popleft()
|
|
189
|
+
for y in adj[x]:
|
|
190
|
+
if y not in dist:
|
|
191
|
+
dist[y] = dist[x] + 1
|
|
192
|
+
prev[y] = x
|
|
193
|
+
queue.append(y)
|
|
194
|
+
target = max(dist, key=lambda k: (dist[k], k))
|
|
195
|
+
if dist[target] > best:
|
|
196
|
+
best = dist[target]
|
|
197
|
+
# Reconstruct path.
|
|
198
|
+
path: list[str] = []
|
|
199
|
+
cur: str | None = target
|
|
200
|
+
while cur is not None:
|
|
201
|
+
path.append(cur)
|
|
202
|
+
cur = prev[cur]
|
|
203
|
+
best_path = list(reversed(path))
|
|
204
|
+
return MetricResult(
|
|
205
|
+
name="graph_diameter",
|
|
206
|
+
value=best,
|
|
207
|
+
explanation="Longest shortest path in the largest connected component.",
|
|
208
|
+
data={"path": best_path},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def structural_summary(g: Graph) -> dict[str, MetricResult]:
|
|
213
|
+
"""Convenience: compute every structural metric at once."""
|
|
214
|
+
return {
|
|
215
|
+
m.name: m
|
|
216
|
+
for m in (
|
|
217
|
+
node_counts_by_class(g),
|
|
218
|
+
edge_density(g),
|
|
219
|
+
constraint_variable_ratio(g),
|
|
220
|
+
minimal_size(g),
|
|
221
|
+
model_coherence(g),
|
|
222
|
+
graph_diameter(g),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = [
|
|
228
|
+
"constraint_variable_ratio",
|
|
229
|
+
"edge_density",
|
|
230
|
+
"graph_diameter",
|
|
231
|
+
"minimal_size",
|
|
232
|
+
"model_coherence",
|
|
233
|
+
"model_completeness",
|
|
234
|
+
"node_counts_by_class",
|
|
235
|
+
"structural_summary",
|
|
236
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""LP mining extensions for lp2graph.
|
|
2
|
+
|
|
3
|
+
This subpackage implements the modules the *LP Mining with LP2Graph*
|
|
4
|
+
method needs on top of the deterministic core library:
|
|
5
|
+
|
|
6
|
+
- :mod:`lp2graph.mining.ingest` (M1) — heterogeneous ingestion front-end
|
|
7
|
+
turning solver code and non-canonical LaTeX into validated
|
|
8
|
+
``Formulation`` objects, with source-span provenance.
|
|
9
|
+
- :mod:`lp2graph.mining.homologize` (M2) — lexical homologizer and
|
|
10
|
+
TF-IDF concept vectorizer.
|
|
11
|
+
- :mod:`lp2graph.mining.cluster` (M3) — the cluster-and-name operator
|
|
12
|
+
``CN`` and the bottom-up multi-level taxonomy induction.
|
|
13
|
+
- :mod:`lp2graph.mining.label` (M4) — the two-stage labeling service with
|
|
14
|
+
a versioned, self-growing closed-loop store.
|
|
15
|
+
- :mod:`lp2graph.mining.corpusmgr` (M5) — corpus & provenance manager.
|
|
16
|
+
- :mod:`lp2graph.mining.isomorphism` (M6) — intra-cluster schema-graph
|
|
17
|
+
isomorphism reporting.
|
|
18
|
+
|
|
19
|
+
Every module is deterministic: frozen resources are versioned in
|
|
20
|
+
:mod:`lp2graph.mining.versions` and stamped into emitted records.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from lp2graph.mining.provenance import ProvenanceMap, Rewrite, SourceSpan
|
|
26
|
+
from lp2graph.mining.versions import (
|
|
27
|
+
CLUSTERING_VERSION,
|
|
28
|
+
LABEL_LEXICON_VERSION,
|
|
29
|
+
LEXICON_VERSION,
|
|
30
|
+
REWRITE_RULES_VERSION,
|
|
31
|
+
THESAURUS_VERSION,
|
|
32
|
+
VOCABULARY_VERSION,
|
|
33
|
+
WORDNET_VERSION,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CLUSTERING_VERSION",
|
|
38
|
+
"LABEL_LEXICON_VERSION",
|
|
39
|
+
"LEXICON_VERSION",
|
|
40
|
+
"REWRITE_RULES_VERSION",
|
|
41
|
+
"THESAURUS_VERSION",
|
|
42
|
+
"VOCABULARY_VERSION",
|
|
43
|
+
"WORDNET_VERSION",
|
|
44
|
+
"ProvenanceMap",
|
|
45
|
+
"Rewrite",
|
|
46
|
+
"SourceSpan",
|
|
47
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""M3 — cluster-and-name operator and multi-level taxonomy induction.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- :func:`CN` / :class:`ClusterConfig` / :class:`NamedClustering` — the core
|
|
6
|
+
cluster-and-name operator.
|
|
7
|
+
- :func:`induce` / :class:`Taxonomy` / :class:`LevelResult` — the bottom-up
|
|
8
|
+
Level V → C → M passes plus the text-only domain / solution-approach
|
|
9
|
+
clusterings.
|
|
10
|
+
- :func:`stability_report` / :class:`StabilityReport` — silhouette, bootstrap
|
|
11
|
+
ARI, and sensitivity diagnostics.
|
|
12
|
+
|
|
13
|
+
Everything is deterministic given the versioned :class:`ClusterConfig`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from lp2graph.mining.cluster.distance import (
|
|
19
|
+
cosine_distance,
|
|
20
|
+
cosine_similarity,
|
|
21
|
+
distance_matrix,
|
|
22
|
+
)
|
|
23
|
+
from lp2graph.mining.cluster.operator import (
|
|
24
|
+
CN,
|
|
25
|
+
UNASSIGNED,
|
|
26
|
+
Algorithm,
|
|
27
|
+
ClusterConfig,
|
|
28
|
+
NamedClustering,
|
|
29
|
+
)
|
|
30
|
+
from lp2graph.mining.cluster.silhouette import silhouette_samples, silhouette_score
|
|
31
|
+
from lp2graph.mining.cluster.stability import (
|
|
32
|
+
StabilityReport,
|
|
33
|
+
adjusted_rand_index,
|
|
34
|
+
bootstrap_ari,
|
|
35
|
+
sensitivity,
|
|
36
|
+
stability_report,
|
|
37
|
+
)
|
|
38
|
+
from lp2graph.mining.cluster.taxonomy import (
|
|
39
|
+
LevelResult,
|
|
40
|
+
Taxonomy,
|
|
41
|
+
induce,
|
|
42
|
+
model_feature_document,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"CN",
|
|
47
|
+
"UNASSIGNED",
|
|
48
|
+
"Algorithm",
|
|
49
|
+
"ClusterConfig",
|
|
50
|
+
"LevelResult",
|
|
51
|
+
"NamedClustering",
|
|
52
|
+
"StabilityReport",
|
|
53
|
+
"Taxonomy",
|
|
54
|
+
"adjusted_rand_index",
|
|
55
|
+
"bootstrap_ari",
|
|
56
|
+
"cosine_distance",
|
|
57
|
+
"cosine_similarity",
|
|
58
|
+
"distance_matrix",
|
|
59
|
+
"induce",
|
|
60
|
+
"model_feature_document",
|
|
61
|
+
"sensitivity",
|
|
62
|
+
"silhouette_samples",
|
|
63
|
+
"silhouette_score",
|
|
64
|
+
"stability_report",
|
|
65
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Deterministic average-linkage agglomerative clustering (M3).
|
|
2
|
+
|
|
3
|
+
A small, dependency-free clustering core used as the default backend for the
|
|
4
|
+
``CN`` operator. Two stopping modes:
|
|
5
|
+
|
|
6
|
+
- **threshold** — keep merging the two closest clusters until the closest
|
|
7
|
+
average-linkage distance exceeds ``distance_threshold``; this discovers the
|
|
8
|
+
number of clusters (and naturally leaves singletons), the HDBSCAN-style
|
|
9
|
+
behavior the method asks for without the dependency.
|
|
10
|
+
- **fixed K** — merge until exactly ``k`` clusters remain.
|
|
11
|
+
|
|
12
|
+
Determinism is the whole point: ties (equal merge distances) are broken by
|
|
13
|
+
the lexicographically smallest cluster-index pair, so the dendrogram — and
|
|
14
|
+
every partition cut from it — is reproducible across runs and machines.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _labels_from_groups(groups: list[list[int]], n: int) -> tuple[int, ...]:
|
|
23
|
+
"""Assign each point a cluster id, ids ordered by smallest member index."""
|
|
24
|
+
ordered = sorted(groups, key=min)
|
|
25
|
+
labels = [-1] * n
|
|
26
|
+
for cid, group in enumerate(ordered):
|
|
27
|
+
for idx in group:
|
|
28
|
+
labels[idx] = cid
|
|
29
|
+
return tuple(labels)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _average_linkage(dist: Sequence[Sequence[float]], a: Sequence[int], b: Sequence[int]) -> float:
|
|
33
|
+
total = 0.0
|
|
34
|
+
for i in a:
|
|
35
|
+
for j in b:
|
|
36
|
+
total += dist[i][j]
|
|
37
|
+
return total / (len(a) * len(b))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def agglomerative(
|
|
41
|
+
dist: Sequence[Sequence[float]],
|
|
42
|
+
*,
|
|
43
|
+
distance_threshold: float | None = 0.7,
|
|
44
|
+
k: int | None = None,
|
|
45
|
+
) -> tuple[int, ...]:
|
|
46
|
+
"""Cluster points described by precomputed distance matrix ``dist``.
|
|
47
|
+
|
|
48
|
+
Provide exactly one stopping rule: ``k`` (fixed cluster count) or
|
|
49
|
+
``distance_threshold`` (merge while the closest pair is within it).
|
|
50
|
+
Returns a tuple of cluster ids aligned to the input order.
|
|
51
|
+
"""
|
|
52
|
+
if (k is None) == (distance_threshold is None):
|
|
53
|
+
raise ValueError("provide exactly one of 'k' or 'distance_threshold'")
|
|
54
|
+
n = len(dist)
|
|
55
|
+
if n == 0:
|
|
56
|
+
return ()
|
|
57
|
+
if n == 1:
|
|
58
|
+
return (0,)
|
|
59
|
+
|
|
60
|
+
groups: list[list[int]] = [[i] for i in range(n)]
|
|
61
|
+
|
|
62
|
+
while len(groups) > 1:
|
|
63
|
+
if k is not None and len(groups) <= k:
|
|
64
|
+
break
|
|
65
|
+
# Find the closest pair of groups; deterministic tie-break by (i, j).
|
|
66
|
+
best: tuple[float, int, int] | None = None
|
|
67
|
+
for i in range(len(groups)):
|
|
68
|
+
for j in range(i + 1, len(groups)):
|
|
69
|
+
d = _average_linkage(dist, groups[i], groups[j])
|
|
70
|
+
if best is None or d < best[0]:
|
|
71
|
+
best = (d, i, j)
|
|
72
|
+
assert best is not None
|
|
73
|
+
merge_d, gi, gj = best
|
|
74
|
+
if k is None and distance_threshold is not None and merge_d > distance_threshold:
|
|
75
|
+
break
|
|
76
|
+
groups[gi] = groups[gi] + groups[gj]
|
|
77
|
+
del groups[gj]
|
|
78
|
+
|
|
79
|
+
return _labels_from_groups(groups, n)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["agglomerative"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Cosine distance over TF-IDF concept vectors (M3).
|
|
2
|
+
|
|
3
|
+
The clustering operator works in cosine-distance space. Because the M2
|
|
4
|
+
vectorizer already L2-normalizes its output, cosine similarity is just the
|
|
5
|
+
dot product and cosine distance is ``1 - dot``. Zero vectors (entities whose
|
|
6
|
+
text yielded no in-vocabulary concept) are at distance 1 from everything and
|
|
7
|
+
are routed to the explicit *unassigned* part upstream.
|
|
8
|
+
|
|
9
|
+
Pure Python, no numpy dependency, fully order-deterministic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
Vector = Sequence[float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dot(a: Vector, b: Vector) -> float:
|
|
20
|
+
"""Dot product of two equal-length vectors."""
|
|
21
|
+
if len(a) != len(b):
|
|
22
|
+
raise ValueError(f"vector length mismatch: {len(a)} vs {len(b)}")
|
|
23
|
+
return sum(x * y for x, y in zip(a, b, strict=True))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cosine_similarity(a: Vector, b: Vector) -> float:
|
|
27
|
+
"""Cosine similarity assuming ``a``/``b`` are L2-normalized (or zero)."""
|
|
28
|
+
return dot(a, b)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cosine_distance(a: Vector, b: Vector) -> float:
|
|
32
|
+
"""Cosine distance ``1 - cosine_similarity`` clamped to ``[0, 2]``."""
|
|
33
|
+
d = 1.0 - cosine_similarity(a, b)
|
|
34
|
+
if d < 0.0:
|
|
35
|
+
return 0.0
|
|
36
|
+
if d > 2.0:
|
|
37
|
+
return 2.0
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_zero(v: Vector) -> bool:
|
|
42
|
+
"""True if every coordinate is exactly zero (no concept signal)."""
|
|
43
|
+
return all(x == 0.0 for x in v)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def distance_matrix(vectors: Sequence[Vector]) -> list[list[float]]:
|
|
47
|
+
"""Symmetric pairwise cosine-distance matrix with a zero diagonal."""
|
|
48
|
+
n = len(vectors)
|
|
49
|
+
mat = [[0.0] * n for _ in range(n)]
|
|
50
|
+
for i in range(n):
|
|
51
|
+
for j in range(i + 1, n):
|
|
52
|
+
d = cosine_distance(vectors[i], vectors[j])
|
|
53
|
+
mat[i][j] = d
|
|
54
|
+
mat[j][i] = d
|
|
55
|
+
return mat
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"Vector",
|
|
60
|
+
"cosine_distance",
|
|
61
|
+
"cosine_similarity",
|
|
62
|
+
"distance_matrix",
|
|
63
|
+
"dot",
|
|
64
|
+
"is_zero",
|
|
65
|
+
]
|