polyclustx 1.4.1__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.
- polyclustx/__init__.py +15 -0
- polyclustx/__main__.py +6 -0
- polyclustx/brand.py +31 -0
- polyclustx/cli.py +622 -0
- polyclustx/command.py +251 -0
- polyclustx/command_experiments.py +187 -0
- polyclustx/config.py +303 -0
- polyclustx/configs/default.yaml +29 -0
- polyclustx/configs/experiment.yaml +27 -0
- polyclustx/configs/minimal.yaml +7 -0
- polyclustx/configs/random_search.yaml +32 -0
- polyclustx/core/__init__.py +3 -0
- polyclustx/core/base_model.py +52 -0
- polyclustx/data/__init__.py +66 -0
- polyclustx/data/base.py +21 -0
- polyclustx/data/dataset.py +180 -0
- polyclustx/data/downloader.py +89 -0
- polyclustx/data/graphs.py +31 -0
- polyclustx/data/importers.py +281 -0
- polyclustx/data/missing.py +132 -0
- polyclustx/data/readers.py +95 -0
- polyclustx/data/registry.py +412 -0
- polyclustx/display/__init__.py +41 -0
- polyclustx/display/__main__.py +4 -0
- polyclustx/display/app.py +1640 -0
- polyclustx/display/artifacts.py +33 -0
- polyclustx/display/assets/borelli.png +0 -0
- polyclustx/display/assets/logo.png +0 -0
- polyclustx/display/assets/paris_cite.png +0 -0
- polyclustx/display/data_controls.py +67 -0
- polyclustx/display/exporting.py +75 -0
- polyclustx/display/graph_uploads.py +93 -0
- polyclustx/display/model_controls.py +205 -0
- polyclustx/display/preprocessing.py +106 -0
- polyclustx/display/profiles.py +162 -0
- polyclustx/display/projection.py +41 -0
- polyclustx/display/results.py +132 -0
- polyclustx/display/synthetic.py +173 -0
- polyclustx/display/theme.py +51 -0
- polyclustx/display/workspace.py +609 -0
- polyclustx/experiment/__init__.py +17 -0
- polyclustx/experiment/experiment.py +727 -0
- polyclustx/experiment/exports.py +205 -0
- polyclustx/experiment/grid_search.py +15 -0
- polyclustx/experiment/logger.py +145 -0
- polyclustx/experiment/markdown_export.py +519 -0
- polyclustx/experiment/optuna_search.py +94 -0
- polyclustx/experiment/random_search.py +576 -0
- polyclustx/experiment/result.py +570 -0
- polyclustx/metrics/__init__.py +21 -0
- polyclustx/metrics/clustering.py +535 -0
- polyclustx/metrics/evaluation.py +44 -0
- polyclustx/models/__init__.py +41 -0
- polyclustx/models/_common.py +148 -0
- polyclustx/models/_deep.py +67 -0
- polyclustx/models/_graphs.py +47 -0
- polyclustx/models/_torch_utils.py +37 -0
- polyclustx/models/completer/__init__.py +12 -0
- polyclustx/models/completer/completer.py +204 -0
- polyclustx/models/conan/__init__.py +12 -0
- polyclustx/models/conan/conan.py +137 -0
- polyclustx/models/dcp/__init__.py +12 -0
- polyclustx/models/dcp/dcp.py +31 -0
- polyclustx/models/dgf/__init__.py +12 -0
- polyclustx/models/dgf/dgf.py +205 -0
- polyclustx/models/g_comvkm/__init__.py +12 -0
- polyclustx/models/g_comvkm/g_comvkm.py +141 -0
- polyclustx/models/idec/__init__.py +12 -0
- polyclustx/models/idec/idec.py +147 -0
- polyclustx/models/lmgec/__init__.py +10 -0
- polyclustx/models/lmgec/lmgec.py +463 -0
- polyclustx/models/mflvc/__init__.py +12 -0
- polyclustx/models/mflvc/mflvc.py +191 -0
- polyclustx/models/multiview_kmeans/__init__.py +12 -0
- polyclustx/models/multiview_kmeans/multiview_kmeans.py +140 -0
- polyclustx/models/multiview_spectral/__init__.py +12 -0
- polyclustx/models/multiview_spectral/multiview_spectral.py +124 -0
- polyclustx/models/registry.py +167 -0
- polyclustx/models/scm/__init__.py +12 -0
- polyclustx/models/scm/scm.py +310 -0
- polyclustx/models/sgla/__init__.py +12 -0
- polyclustx/models/sgla/sgla.py +107 -0
- polyclustx/models/umc/__init__.py +12 -0
- polyclustx/models/umc/umc.py +191 -0
- polyclustx/preprocessing/__init__.py +10 -0
- polyclustx/preprocessing/features.py +382 -0
- polyclustx/preprocessing/fusion.py +42 -0
- polyclustx/preprocessing/graph_construction.py +0 -0
- polyclustx/progress.py +36 -0
- polyclustx/tracking.py +552 -0
- polyclustx/visualization/__init__.py +3 -0
- polyclustx/visualization/visualizer.py +309 -0
- polyclustx-1.4.1.dist-info/METADATA +427 -0
- polyclustx-1.4.1.dist-info/RECORD +98 -0
- polyclustx-1.4.1.dist-info/WHEEL +5 -0
- polyclustx-1.4.1.dist-info/entry_points.txt +16 -0
- polyclustx-1.4.1.dist-info/licenses/LICENSE +133 -0
- polyclustx-1.4.1.dist-info/top_level.txt +1 -0
polyclustx/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""PolyClustX: representation learning and clustering from features to graphs."""
|
|
2
|
+
|
|
3
|
+
from . import (
|
|
4
|
+
data, # noqa: F401
|
|
5
|
+
experiment, # noqa: F401
|
|
6
|
+
metrics, # noqa: F401
|
|
7
|
+
models, # noqa: F401
|
|
8
|
+
preprocessing, # noqa: F401
|
|
9
|
+
visualization, # noqa: F401
|
|
10
|
+
)
|
|
11
|
+
from .experiment import Experiment, GridSearchExperiment, OptunaSearchExperiment, RandomSearchExperiment # noqa: F401
|
|
12
|
+
from .models import get_model, list_models, register_model # noqa: F401
|
|
13
|
+
from .tracking import ExperimentTracker # noqa: F401
|
|
14
|
+
|
|
15
|
+
__version__ = "1.4.1"
|
polyclustx/__main__.py
ADDED
polyclustx/brand.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Lightweight shared identity; no UI or numerical dependencies."""
|
|
2
|
+
|
|
3
|
+
NAME = "PolyClustX"
|
|
4
|
+
DEFINITION = (
|
|
5
|
+
"A Unified Framework for Multi-View and Multimodal Representation Learning "
|
|
6
|
+
"and Clustering from Feature Views to Attributed Graphs"
|
|
7
|
+
)
|
|
8
|
+
REPOSITORY_URL = "https://github.com/AbdelheqMokhtari/PolyClustX"
|
|
9
|
+
DOCUMENTATION_URL = "https://polyclustx.readthedocs.io/en/latest/"
|
|
10
|
+
|
|
11
|
+
BRAND = {
|
|
12
|
+
"primary": "#4E4E92",
|
|
13
|
+
"primary_dark": "#3B3D73",
|
|
14
|
+
"primary_light": "#7478AE",
|
|
15
|
+
"secondary": "#634E87",
|
|
16
|
+
"accent": "#557AA3",
|
|
17
|
+
"background": "#F7F8FC",
|
|
18
|
+
"surface": "#FFFFFF",
|
|
19
|
+
"surface_alt": "#EEF0F7",
|
|
20
|
+
"border": "#D7DAE8",
|
|
21
|
+
"text": "#292B3A",
|
|
22
|
+
"text_muted": "#6B6E7D",
|
|
23
|
+
"brand_tint": "#E8E9F3",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
PLOT_COLORS = [BRAND[key] for key in ("primary", "accent", "secondary", "primary_light", "text_muted")]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def css_variables():
|
|
30
|
+
"""CSS custom properties shared by Sphinx and Streamlit."""
|
|
31
|
+
return ":root {" + "".join(f"--pcx-{key.replace('_', '-')}: {value};" for key, value in BRAND.items()) + "}"
|
polyclustx/cli.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""
|
|
2
|
+
python -m polyclustx.cli — Command-line interface for the polyclustx library.
|
|
3
|
+
|
|
4
|
+
Run multi-view clustering experiments directly from the terminal
|
|
5
|
+
without writing any Python code.
|
|
6
|
+
|
|
7
|
+
Examples
|
|
8
|
+
--------
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
# Basic experiment
|
|
12
|
+
python -m polyclustx.cli --dataset acm
|
|
13
|
+
|
|
14
|
+
# With model parameters
|
|
15
|
+
python -m polyclustx.cli --dataset dblp --beta 2 --temperature 10 --runs 3
|
|
16
|
+
|
|
17
|
+
# Random search
|
|
18
|
+
python -m polyclustx.cli --dataset acm --random-search --n-trials 20
|
|
19
|
+
|
|
20
|
+
# Save results to Markdown
|
|
21
|
+
python -m polyclustx.cli --dataset acm --save --name my_experiment
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import sys
|
|
30
|
+
import textwrap
|
|
31
|
+
|
|
32
|
+
# ── Model registry ────────────────────────────────────────────────
|
|
33
|
+
# Query model metadata when constructing commands.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_model_registry() -> dict:
|
|
37
|
+
"""Return the global model registry (lazy import)."""
|
|
38
|
+
from polyclustx.models.registry import list_models_info
|
|
39
|
+
return list_models_info()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Metric registry ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
_METRIC_ALIASES = {
|
|
45
|
+
"acc": "clustering_accuracy",
|
|
46
|
+
"f1": "clustering_f1_score",
|
|
47
|
+
"nmi": "nmi_score",
|
|
48
|
+
"ari": "ari_score",
|
|
49
|
+
"silhouette": "silhouette_score",
|
|
50
|
+
"db": "davies_bouldin_score",
|
|
51
|
+
"ch": "calinski_harabasz_score",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_DEFAULT_METRICS = ["acc", "nmi", "ari"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_metrics(names: list[str]) -> list:
|
|
58
|
+
"""Turn short metric names into callables."""
|
|
59
|
+
import polyclustx.metrics as M
|
|
60
|
+
|
|
61
|
+
out = []
|
|
62
|
+
for name in names:
|
|
63
|
+
full = _METRIC_ALIASES.get(name.lower(), name.lower())
|
|
64
|
+
fn = getattr(M, full, None)
|
|
65
|
+
if fn is None:
|
|
66
|
+
_die(
|
|
67
|
+
f"Unknown metric '{name}'.\n"
|
|
68
|
+
f"Available: {', '.join(sorted(_METRIC_ALIASES))} "
|
|
69
|
+
f"(or full names like clustering_accuracy)"
|
|
70
|
+
)
|
|
71
|
+
out.append(fn)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Helpers ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _die(msg: str, code: int = 1):
|
|
78
|
+
# Keep errors visible alongside the CLI's result output.
|
|
79
|
+
print(f"Error: {msg}")
|
|
80
|
+
sys.exit(code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _configure_verbosity(level: int) -> None:
|
|
84
|
+
"""Configure logging: silent, tables, progress, or debug."""
|
|
85
|
+
if level == 0:
|
|
86
|
+
logging.basicConfig(level=logging.CRITICAL + 1)
|
|
87
|
+
elif level <= 2:
|
|
88
|
+
logging.basicConfig(level=logging.WARNING)
|
|
89
|
+
else:
|
|
90
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_json_or_csv(value: str):
|
|
94
|
+
"""Parse a CLI value that may be JSON list or comma-separated."""
|
|
95
|
+
value = value.strip()
|
|
96
|
+
if value.startswith("["):
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(value)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
_die(f"Invalid JSON list: {value}")
|
|
101
|
+
if "," in value:
|
|
102
|
+
parts = [p.strip() for p in value.split(",")]
|
|
103
|
+
try:
|
|
104
|
+
return [float(p) for p in parts]
|
|
105
|
+
except ValueError:
|
|
106
|
+
return parts
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _coerce(value: str, default):
|
|
111
|
+
"""Coerce a CLI string to the same type as *default*, allowing lists."""
|
|
112
|
+
value = value.strip()
|
|
113
|
+
|
|
114
|
+
# Always parse lists first, regardless of the default type
|
|
115
|
+
if value.startswith("[") or "," in value:
|
|
116
|
+
parsed = _parse_json_or_csv(value)
|
|
117
|
+
if isinstance(default, tuple):
|
|
118
|
+
numbers = [float(item) for item in parsed]
|
|
119
|
+
if any(not item.is_integer() or item < 1 for item in numbers):
|
|
120
|
+
raise ValueError("Layer widths must be positive integers")
|
|
121
|
+
return tuple(int(item) for item in numbers)
|
|
122
|
+
return parsed
|
|
123
|
+
|
|
124
|
+
if default is None:
|
|
125
|
+
if value.lower() in {"none", "null"}:
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
return json.loads(value)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
return value
|
|
131
|
+
if isinstance(default, tuple):
|
|
132
|
+
return (int(value),)
|
|
133
|
+
|
|
134
|
+
if isinstance(default, bool):
|
|
135
|
+
return value.lower() in ("true", "1", "yes")
|
|
136
|
+
if isinstance(default, int):
|
|
137
|
+
return int(value)
|
|
138
|
+
if isinstance(default, float):
|
|
139
|
+
return float(value)
|
|
140
|
+
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_list(value):
|
|
145
|
+
"""Wrap a scalar in a list; leave lists as-is."""
|
|
146
|
+
if isinstance(value, list):
|
|
147
|
+
return value
|
|
148
|
+
return [value]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Argument parser ───────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
154
|
+
from polyclustx import __version__
|
|
155
|
+
|
|
156
|
+
p = argparse.ArgumentParser(
|
|
157
|
+
prog="python -m polyclustx.cli",
|
|
158
|
+
description="Run multi-view clustering experiments from the terminal.",
|
|
159
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
160
|
+
epilog=textwrap.dedent("""\
|
|
161
|
+
examples:
|
|
162
|
+
python -m polyclustx.cli --dataset acm
|
|
163
|
+
python -m polyclustx.cli --dataset dblp --beta 2 --temperature 10 --runs 3
|
|
164
|
+
python -m polyclustx.cli --dataset acm --random-search --n-trials 20
|
|
165
|
+
python -m polyclustx.cli --config configs/experiment.yaml
|
|
166
|
+
python -m polyclustx.cli --config configs/experiment.yaml --dataset acm # override
|
|
167
|
+
python -m polyclustx.cli --list-datasets
|
|
168
|
+
python -m polyclustx.cli --list-models
|
|
169
|
+
"""),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
p.add_argument(
|
|
173
|
+
"--version", "-V", action="version",
|
|
174
|
+
version=f"%(prog)s {__version__} (polyclustx {__version__})",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ── Config file ──
|
|
178
|
+
p.add_argument(
|
|
179
|
+
"--config", "-c", default=None, metavar="PATH",
|
|
180
|
+
help="Path to a YAML/JSON config file. "
|
|
181
|
+
"Shorthand names (e.g. 'default') resolve to configs/<name>.yaml. "
|
|
182
|
+
"CLI flags override config values.",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ── Discovery ──
|
|
186
|
+
info = p.add_argument_group("discovery")
|
|
187
|
+
info.add_argument(
|
|
188
|
+
"--list-datasets", action="store_true",
|
|
189
|
+
help="List available datasets and exit.",
|
|
190
|
+
)
|
|
191
|
+
info.add_argument(
|
|
192
|
+
"--list-models", action="store_true",
|
|
193
|
+
help="List available models and exit.",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ── Required ──
|
|
197
|
+
core = p.add_argument_group("core")
|
|
198
|
+
core.add_argument("--variant", default=None, help="Dataset variant; see --list-datasets.")
|
|
199
|
+
core.add_argument("--missing-rate", type=float, default=0.0, help="Simulated missing-view fraction.")
|
|
200
|
+
core.add_argument("--data-seed", type=int, default=None, help="Separate missingness seed; otherwise use --seed.")
|
|
201
|
+
core.add_argument("--impute", choices=["mean", "zero"], default=None, help="Explicit missing-view baseline.")
|
|
202
|
+
core.add_argument("--no-benchmark", action="store_true", help="Disable all use of ground-truth labels.")
|
|
203
|
+
core.add_argument(
|
|
204
|
+
"--dataset", "-d",
|
|
205
|
+
help="Dataset name (e.g. acm, dblp, prokaryotic).",
|
|
206
|
+
)
|
|
207
|
+
core.add_argument(
|
|
208
|
+
"--model", "-m", default="lmgec",
|
|
209
|
+
help="Model name (default: lmgec).",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# ── Model parameters ──
|
|
213
|
+
params = p.add_argument_group(
|
|
214
|
+
"model parameters",
|
|
215
|
+
"Override any model parameter. "
|
|
216
|
+
"Lists can be passed as JSON or comma-separated.",
|
|
217
|
+
)
|
|
218
|
+
params.add_argument("--n-clusters", "-k", type=int, default=None,
|
|
219
|
+
help="Number of clusters (auto-detected from labels if omitted).")
|
|
220
|
+
params.add_argument("--embedding-dim", type=int, default=None,
|
|
221
|
+
help="Embedding dimension (default: 16).")
|
|
222
|
+
params.add_argument("--temperature", type=float, default=None,
|
|
223
|
+
help="Softmax temperature (default: 1.0).")
|
|
224
|
+
params.add_argument("--beta", default=None,
|
|
225
|
+
help="Self-loop weight. Scalar or list (e.g. 2 or '1.0,0.5').")
|
|
226
|
+
params.add_argument("--tf-idf", default=None,
|
|
227
|
+
help="TF-IDF mode. true/false or list (e.g. 'true,false').")
|
|
228
|
+
params.add_argument("--center", default=None,
|
|
229
|
+
help="Center features (true/false, default: false).")
|
|
230
|
+
params.add_argument("--scale", default=None,
|
|
231
|
+
help="Scale features (true/false, default: false).")
|
|
232
|
+
params.add_argument("--no-preprocess", action="store_true",
|
|
233
|
+
help="Disable internal preprocessing (preprocess=False).")
|
|
234
|
+
params.add_argument("--max-iter", type=int, default=None,
|
|
235
|
+
help="Max optimisation iterations (default: 30).")
|
|
236
|
+
# Make registered model parameters available without maintaining a second
|
|
237
|
+
# hand-written model list. Each selected model coerces its own defaults.
|
|
238
|
+
reserved = {action.dest for action in p._actions} | {"random_state", "preprocess"}
|
|
239
|
+
for info in _get_model_registry().values():
|
|
240
|
+
for name in info["defaults"]:
|
|
241
|
+
if name not in reserved:
|
|
242
|
+
params.add_argument("--" + name.replace("_", "-"), default=None,
|
|
243
|
+
help=f"Model-specific {name.replace('_', ' ')}.")
|
|
244
|
+
reserved.add(name)
|
|
245
|
+
|
|
246
|
+
# ── Experiment ──
|
|
247
|
+
exp = p.add_argument_group("experiment")
|
|
248
|
+
exp.add_argument("--runs", "-r", type=int, default=1,
|
|
249
|
+
help="Number of independent runs (default: 1).")
|
|
250
|
+
exp.add_argument("--metrics", nargs="+", default=None,
|
|
251
|
+
help="Metrics to report (default: acc nmi ari). "
|
|
252
|
+
"Short names: acc, nmi, ari, f1.")
|
|
253
|
+
exp.add_argument("--verbose", "-v", type=int, default=1,
|
|
254
|
+
choices=[0, 1, 2, 3],
|
|
255
|
+
help="Verbosity level: "
|
|
256
|
+
"0=silent, "
|
|
257
|
+
"1=results tables only (default), "
|
|
258
|
+
"2=progress + tables, "
|
|
259
|
+
"3=debug (includes library logs).")
|
|
260
|
+
|
|
261
|
+
# ── Random search ──
|
|
262
|
+
rs = p.add_argument_group("random search")
|
|
263
|
+
rs.add_argument("--random-search", action="store_true",
|
|
264
|
+
help="Run random hyper-parameter search instead of a fixed experiment.")
|
|
265
|
+
rs.add_argument("--n-trials", type=int, default=20,
|
|
266
|
+
help="Number of random-search trials (default: 20).")
|
|
267
|
+
rs.add_argument("--best-metric", default=None,
|
|
268
|
+
help="Metric to optimise (default: acc in benchmark mode, silhouette otherwise).")
|
|
269
|
+
rs.add_argument("--seed", type=int, default=None,
|
|
270
|
+
help="Random seed for reproducibility.")
|
|
271
|
+
|
|
272
|
+
# ── Output ──
|
|
273
|
+
out = p.add_argument_group("output")
|
|
274
|
+
out.add_argument("--save", action="store_true",
|
|
275
|
+
help="Save a Markdown report to experiments/ folder.")
|
|
276
|
+
out.add_argument("--name", default=None,
|
|
277
|
+
help="Experiment folder name (used with --save).")
|
|
278
|
+
out.add_argument("--save-path", default=None,
|
|
279
|
+
help="Base directory for experiment output (default: ./experiments).")
|
|
280
|
+
out.add_argument("--log", action="store_true",
|
|
281
|
+
help="Write a log file.")
|
|
282
|
+
|
|
283
|
+
return p
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── Commands ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def cmd_list_datasets():
|
|
289
|
+
from polyclustx.data import get_dataset_info, list_datasets
|
|
290
|
+
print("Available datasets:")
|
|
291
|
+
for category in ("attributed_graph_multiview", "attributed_graph_multimodal", "multiview", "multimodal"):
|
|
292
|
+
names = list_datasets(category=category)
|
|
293
|
+
if not names:
|
|
294
|
+
continue
|
|
295
|
+
print(f"\n {category}:")
|
|
296
|
+
for name in names:
|
|
297
|
+
info = get_dataset_info(name)
|
|
298
|
+
cached = " [cached]" if info["cached"] else ""
|
|
299
|
+
print(f" {name:<26} {info['description']}{cached} | variants: {', '.join(info['variants'])}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _load_cli_data(args):
|
|
303
|
+
from polyclustx.data import impute_missing, load_dataset
|
|
304
|
+
data_seed = getattr(args, "data_seed", None)
|
|
305
|
+
data = load_dataset(args.dataset, getattr(args, "missing_rate", 0.0),
|
|
306
|
+
variant=getattr(args, "variant", None),
|
|
307
|
+
random_state=args.seed if data_seed is None else data_seed)
|
|
308
|
+
if getattr(args, "impute", None):
|
|
309
|
+
data = impute_missing(data, args.impute)
|
|
310
|
+
if getattr(args, "no_benchmark", False) and args.n_clusters is None:
|
|
311
|
+
_die("--no-benchmark requires a manually chosen --n-clusters")
|
|
312
|
+
return data
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_list_models():
|
|
316
|
+
registry = _get_model_registry()
|
|
317
|
+
print("Available models:")
|
|
318
|
+
for name, info in registry.items():
|
|
319
|
+
defaults = info["defaults"]
|
|
320
|
+
params = ", ".join(f"{k}={v}" for k, v in defaults.items())
|
|
321
|
+
print(f" • {name} ({params})")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def cmd_run_experiment(args):
|
|
325
|
+
"""Standard experiment: fixed model config → results table."""
|
|
326
|
+
from polyclustx.experiment import Experiment
|
|
327
|
+
|
|
328
|
+
registry = _get_model_registry()
|
|
329
|
+
model_name = args.model.lower()
|
|
330
|
+
if model_name not in registry:
|
|
331
|
+
_die(
|
|
332
|
+
f"Unknown model '{args.model}'.\n"
|
|
333
|
+
f"Available: {', '.join(registry)}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Load dataset to auto-detect n_clusters
|
|
337
|
+
data = _load_cli_data(args)
|
|
338
|
+
n_clusters = args.n_clusters or data.n_clusters
|
|
339
|
+
|
|
340
|
+
# Build model kwargs
|
|
341
|
+
model_info = registry[model_name]
|
|
342
|
+
kwargs = {"n_clusters": n_clusters}
|
|
343
|
+
defaults = model_info["defaults"]
|
|
344
|
+
|
|
345
|
+
param_map = {
|
|
346
|
+
"embedding_dim": args.embedding_dim,
|
|
347
|
+
"temperature": args.temperature,
|
|
348
|
+
"beta": args.beta,
|
|
349
|
+
"tf_idf": args.tf_idf,
|
|
350
|
+
"center": args.center,
|
|
351
|
+
"scale": args.scale,
|
|
352
|
+
"max_iter": args.max_iter,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for key, cli_val in param_map.items():
|
|
356
|
+
# Model registries are heterogeneous: only pass parameters declared
|
|
357
|
+
# by the selected implementation.
|
|
358
|
+
if key not in defaults:
|
|
359
|
+
continue
|
|
360
|
+
if cli_val is not None:
|
|
361
|
+
kwargs[key] = _coerce(str(cli_val), defaults[key])
|
|
362
|
+
else:
|
|
363
|
+
kwargs[key] = defaults[key]
|
|
364
|
+
|
|
365
|
+
if "preprocess" in defaults:
|
|
366
|
+
if args.no_preprocess:
|
|
367
|
+
kwargs["preprocess"] = False
|
|
368
|
+
else:
|
|
369
|
+
kwargs["preprocess"] = defaults["preprocess"]
|
|
370
|
+
|
|
371
|
+
# Pass through every other registered model parameter. Config
|
|
372
|
+
# files can set params that have no dedicated CLI flag;
|
|
373
|
+
# merge_config_with_cli attaches them to `args`, so pick them up
|
|
374
|
+
# here and fall back to the registry default otherwise.
|
|
375
|
+
for key, default in defaults.items():
|
|
376
|
+
if key in kwargs or key == "preprocess":
|
|
377
|
+
continue
|
|
378
|
+
val = getattr(args, key, None)
|
|
379
|
+
kwargs[key] = default if val is None else val
|
|
380
|
+
|
|
381
|
+
if "random_state" in defaults and args.seed is not None:
|
|
382
|
+
kwargs["random_state"] = args.seed
|
|
383
|
+
|
|
384
|
+
model = model_info["class"](**kwargs)
|
|
385
|
+
benchmark = not getattr(args, "no_benchmark", False)
|
|
386
|
+
metrics = _resolve_metrics(args.metrics or (_DEFAULT_METRICS if benchmark else ["silhouette", "db", "ch"]))
|
|
387
|
+
|
|
388
|
+
result = Experiment(
|
|
389
|
+
datasets={args.dataset: data},
|
|
390
|
+
models=[model],
|
|
391
|
+
benchmark=benchmark,
|
|
392
|
+
metrics=metrics,
|
|
393
|
+
runs=args.runs,
|
|
394
|
+
verbose=args.verbose,
|
|
395
|
+
save_markdown=args.save,
|
|
396
|
+
experiment_name=args.name,
|
|
397
|
+
log=args.log,
|
|
398
|
+
save_path=args.save_path,
|
|
399
|
+
).run()
|
|
400
|
+
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def cmd_random_search(args):
|
|
405
|
+
"""Random hyper-parameter search."""
|
|
406
|
+
from polyclustx.experiment import RandomSearchExperiment
|
|
407
|
+
|
|
408
|
+
registry = _get_model_registry()
|
|
409
|
+
model_name = args.model.lower()
|
|
410
|
+
if model_name not in registry:
|
|
411
|
+
_die(
|
|
412
|
+
f"Unknown model '{args.model}'.\n"
|
|
413
|
+
f"Available: {', '.join(registry)}"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
data = _load_cli_data(args)
|
|
417
|
+
n_clusters = args.n_clusters or data.n_clusters
|
|
418
|
+
|
|
419
|
+
model_info = registry[model_name]
|
|
420
|
+
defaults = model_info["defaults"]
|
|
421
|
+
|
|
422
|
+
# Build search distributions — any CLI-provided value is fixed,
|
|
423
|
+
# others get sensible search ranges.
|
|
424
|
+
# If a config file provided a search_space, use it as the base.
|
|
425
|
+
config_space = getattr(args, "_search_space", None) or {}
|
|
426
|
+
|
|
427
|
+
distributions: dict = {"n_clusters": [n_clusters]}
|
|
428
|
+
|
|
429
|
+
# Embedding dim
|
|
430
|
+
if args.embedding_dim is not None:
|
|
431
|
+
distributions["embedding_dim"] = [args.embedding_dim]
|
|
432
|
+
elif "embedding_dim" in config_space:
|
|
433
|
+
distributions["embedding_dim"] = _ensure_list(config_space["embedding_dim"])
|
|
434
|
+
else:
|
|
435
|
+
distributions["embedding_dim"] = [4, 8, 16, 32, 64]
|
|
436
|
+
|
|
437
|
+
# Temperature
|
|
438
|
+
if args.temperature is not None:
|
|
439
|
+
distributions["temperature"] = [args.temperature]
|
|
440
|
+
elif "temperature" in config_space:
|
|
441
|
+
distributions["temperature"] = _ensure_list(config_space["temperature"])
|
|
442
|
+
else:
|
|
443
|
+
distributions["temperature"] = [0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0]
|
|
444
|
+
|
|
445
|
+
# Beta
|
|
446
|
+
if "beta" in defaults:
|
|
447
|
+
if args.beta is not None:
|
|
448
|
+
distributions["beta"] = [_coerce(str(args.beta), defaults["beta"])]
|
|
449
|
+
elif "beta" in config_space:
|
|
450
|
+
distributions["beta"] = _ensure_list(config_space["beta"])
|
|
451
|
+
else:
|
|
452
|
+
distributions["beta"] = [0.1, 0.5, 1.0, 2.0, 5.0]
|
|
453
|
+
|
|
454
|
+
# TF-IDF
|
|
455
|
+
if "tf_idf" in defaults:
|
|
456
|
+
if args.tf_idf is not None:
|
|
457
|
+
distributions["tf_idf"] = [_coerce(str(args.tf_idf), defaults["tf_idf"])]
|
|
458
|
+
elif "tf_idf" in config_space:
|
|
459
|
+
distributions["tf_idf"] = _ensure_list(config_space["tf_idf"])
|
|
460
|
+
else:
|
|
461
|
+
distributions["tf_idf"] = [True, False]
|
|
462
|
+
|
|
463
|
+
# Max iter
|
|
464
|
+
if args.max_iter is not None:
|
|
465
|
+
distributions["max_iter"] = [args.max_iter]
|
|
466
|
+
elif "max_iter" in config_space:
|
|
467
|
+
distributions["max_iter"] = _ensure_list(config_space["max_iter"])
|
|
468
|
+
else:
|
|
469
|
+
distributions["max_iter"] = [defaults["max_iter"]]
|
|
470
|
+
|
|
471
|
+
# Preprocess
|
|
472
|
+
if "preprocess" in defaults:
|
|
473
|
+
if args.no_preprocess:
|
|
474
|
+
distributions["preprocess"] = [False]
|
|
475
|
+
else:
|
|
476
|
+
distributions["preprocess"] = [defaults["preprocess"]]
|
|
477
|
+
|
|
478
|
+
# Model-specific parameters can be supplied through config search_space;
|
|
479
|
+
# otherwise keep their registered defaults fixed during the search.
|
|
480
|
+
for key, default in defaults.items():
|
|
481
|
+
if key in distributions:
|
|
482
|
+
continue
|
|
483
|
+
distributions[key] = (
|
|
484
|
+
_ensure_list(config_space[key]) if key in config_space else [default]
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
benchmark = not getattr(args, "no_benchmark", False)
|
|
488
|
+
metrics = _resolve_metrics(args.metrics or (_DEFAULT_METRICS if benchmark else ["silhouette", "db", "ch"]))
|
|
489
|
+
|
|
490
|
+
# Resolve best_metric full name
|
|
491
|
+
chosen_metric = args.best_metric or ("acc" if benchmark else "silhouette")
|
|
492
|
+
best_full = _METRIC_ALIASES.get(chosen_metric.lower(), chosen_metric.lower())
|
|
493
|
+
|
|
494
|
+
result = RandomSearchExperiment(
|
|
495
|
+
model_class=model_info["class"],
|
|
496
|
+
benchmark=benchmark,
|
|
497
|
+
param_distributions=distributions,
|
|
498
|
+
dataset=(args.dataset, data),
|
|
499
|
+
metrics=metrics,
|
|
500
|
+
best_metric=best_full,
|
|
501
|
+
n_iter=args.n_trials,
|
|
502
|
+
seed=args.seed,
|
|
503
|
+
verbose=args.verbose,
|
|
504
|
+
save_markdown=args.save,
|
|
505
|
+
experiment_name=args.name,
|
|
506
|
+
log=args.log,
|
|
507
|
+
save_path=args.save_path,
|
|
508
|
+
).run()
|
|
509
|
+
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ── Quick-start guide ─────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
QUICK_START = """\
|
|
516
|
+
\033[1mpython -m polyclustx.cli\033[0m — Run multi-view clustering experiments from the terminal.
|
|
517
|
+
|
|
518
|
+
\033[1;33m⚡ Quick start:\033[0m
|
|
519
|
+
|
|
520
|
+
python -m polyclustx.cli --dataset acm # run with defaults
|
|
521
|
+
python -m polyclustx.cli --dataset dblp --beta 2 --runs 3 # custom params
|
|
522
|
+
python -m polyclustx.cli --dataset acm --random-search # hyper-parameter search
|
|
523
|
+
python -m polyclustx.cli --config configs/experiment.yaml # run from config file
|
|
524
|
+
|
|
525
|
+
\033[1;33m🔍 Discover:\033[0m
|
|
526
|
+
|
|
527
|
+
python -m polyclustx.cli --version # show library version
|
|
528
|
+
python -m polyclustx.cli --list-datasets # show available datasets
|
|
529
|
+
python -m polyclustx.cli --list-models # show available models
|
|
530
|
+
python -m polyclustx.cli --help # full usage reference
|
|
531
|
+
|
|
532
|
+
\033[1;33m📖 Common examples:\033[0m
|
|
533
|
+
|
|
534
|
+
python -m polyclustx.cli -d acm --beta 2 --temperature 100 --tf-idf true
|
|
535
|
+
python -m polyclustx.cli -d acm --random-search --n-trials 30 --seed 42
|
|
536
|
+
python -m polyclustx.cli -d dblp --runs 5 --metrics acc nmi f1 --save
|
|
537
|
+
python -m polyclustx.cli --config experiment.yaml --dataset acm # config + override
|
|
538
|
+
|
|
539
|
+
\033[1;33m💡 Tips:\033[0m
|
|
540
|
+
|
|
541
|
+
• n_clusters is auto-detected from the dataset if not provided
|
|
542
|
+
• Use --no-preprocess if you want to skip internal preprocessing
|
|
543
|
+
• Use --save to export a Markdown report to experiments/
|
|
544
|
+
• Use -v 0 for silent, -v 1 for tables (default), -v 2 for progress, -v 3 for full debug
|
|
545
|
+
• Use --config with a YAML/JSON file for reproducible experiments
|
|
546
|
+
• CLI flags always override config file values
|
|
547
|
+
• Run python -m polyclustx.cli --help for the full list of parameters
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ── Entry point ───────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
def main(argv: list[str] | None = None):
|
|
554
|
+
parser = build_parser()
|
|
555
|
+
|
|
556
|
+
# No arguments at all → show quick-start guide
|
|
557
|
+
if argv is None and len(sys.argv) == 1:
|
|
558
|
+
print(QUICK_START)
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
args = parser.parse_args(argv)
|
|
562
|
+
|
|
563
|
+
# ── Config file loading ──
|
|
564
|
+
if args.config:
|
|
565
|
+
from polyclustx.config import config_to_args, load_config, merge_config_with_cli, validate_config
|
|
566
|
+
try:
|
|
567
|
+
raw_cfg = load_config(args.config)
|
|
568
|
+
except (FileNotFoundError, ValueError, ImportError) as exc:
|
|
569
|
+
_die(str(exc))
|
|
570
|
+
|
|
571
|
+
errors = validate_config(raw_cfg)
|
|
572
|
+
if errors:
|
|
573
|
+
_die("Config validation failed:\n • " + "\n • ".join(errors))
|
|
574
|
+
|
|
575
|
+
cfg_args = config_to_args(raw_cfg)
|
|
576
|
+
args = merge_config_with_cli(cfg_args, args, parser)
|
|
577
|
+
|
|
578
|
+
# Configure Python-side verbosity (env vars already set at module load)
|
|
579
|
+
_configure_verbosity(args.verbose)
|
|
580
|
+
|
|
581
|
+
# Discovery commands (no dataset needed)
|
|
582
|
+
if args.list_datasets:
|
|
583
|
+
cmd_list_datasets()
|
|
584
|
+
sys.exit(0)
|
|
585
|
+
if args.list_models:
|
|
586
|
+
cmd_list_models()
|
|
587
|
+
sys.exit(0)
|
|
588
|
+
|
|
589
|
+
# Validate required --dataset
|
|
590
|
+
if not args.dataset:
|
|
591
|
+
print(QUICK_START)
|
|
592
|
+
print("\033[1;31mError:\033[0m --dataset is required.")
|
|
593
|
+
print(" Use --list-datasets to see available options.\n")
|
|
594
|
+
sys.exit(1)
|
|
595
|
+
|
|
596
|
+
# Validate dataset name early (before heavy imports)
|
|
597
|
+
from polyclustx.data import list_datasets
|
|
598
|
+
available = list_datasets(include_aliases=True)
|
|
599
|
+
if args.dataset.lower() not in available:
|
|
600
|
+
_die(
|
|
601
|
+
f"Unknown dataset '{args.dataset}'.\n"
|
|
602
|
+
f"Available: {', '.join(available)}"
|
|
603
|
+
)
|
|
604
|
+
args.dataset = args.dataset.lower()
|
|
605
|
+
|
|
606
|
+
# Validate model name early
|
|
607
|
+
registry = _get_model_registry()
|
|
608
|
+
if args.model.lower() not in registry:
|
|
609
|
+
_die(
|
|
610
|
+
f"Unknown model '{args.model}'.\n"
|
|
611
|
+
f"Available: {', '.join(registry)}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
# Dispatch
|
|
615
|
+
if args.random_search:
|
|
616
|
+
cmd_random_search(args)
|
|
617
|
+
else:
|
|
618
|
+
cmd_run_experiment(args)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
if __name__ == "__main__":
|
|
622
|
+
main()
|