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.
Files changed (98) hide show
  1. polyclustx/__init__.py +15 -0
  2. polyclustx/__main__.py +6 -0
  3. polyclustx/brand.py +31 -0
  4. polyclustx/cli.py +622 -0
  5. polyclustx/command.py +251 -0
  6. polyclustx/command_experiments.py +187 -0
  7. polyclustx/config.py +303 -0
  8. polyclustx/configs/default.yaml +29 -0
  9. polyclustx/configs/experiment.yaml +27 -0
  10. polyclustx/configs/minimal.yaml +7 -0
  11. polyclustx/configs/random_search.yaml +32 -0
  12. polyclustx/core/__init__.py +3 -0
  13. polyclustx/core/base_model.py +52 -0
  14. polyclustx/data/__init__.py +66 -0
  15. polyclustx/data/base.py +21 -0
  16. polyclustx/data/dataset.py +180 -0
  17. polyclustx/data/downloader.py +89 -0
  18. polyclustx/data/graphs.py +31 -0
  19. polyclustx/data/importers.py +281 -0
  20. polyclustx/data/missing.py +132 -0
  21. polyclustx/data/readers.py +95 -0
  22. polyclustx/data/registry.py +412 -0
  23. polyclustx/display/__init__.py +41 -0
  24. polyclustx/display/__main__.py +4 -0
  25. polyclustx/display/app.py +1640 -0
  26. polyclustx/display/artifacts.py +33 -0
  27. polyclustx/display/assets/borelli.png +0 -0
  28. polyclustx/display/assets/logo.png +0 -0
  29. polyclustx/display/assets/paris_cite.png +0 -0
  30. polyclustx/display/data_controls.py +67 -0
  31. polyclustx/display/exporting.py +75 -0
  32. polyclustx/display/graph_uploads.py +93 -0
  33. polyclustx/display/model_controls.py +205 -0
  34. polyclustx/display/preprocessing.py +106 -0
  35. polyclustx/display/profiles.py +162 -0
  36. polyclustx/display/projection.py +41 -0
  37. polyclustx/display/results.py +132 -0
  38. polyclustx/display/synthetic.py +173 -0
  39. polyclustx/display/theme.py +51 -0
  40. polyclustx/display/workspace.py +609 -0
  41. polyclustx/experiment/__init__.py +17 -0
  42. polyclustx/experiment/experiment.py +727 -0
  43. polyclustx/experiment/exports.py +205 -0
  44. polyclustx/experiment/grid_search.py +15 -0
  45. polyclustx/experiment/logger.py +145 -0
  46. polyclustx/experiment/markdown_export.py +519 -0
  47. polyclustx/experiment/optuna_search.py +94 -0
  48. polyclustx/experiment/random_search.py +576 -0
  49. polyclustx/experiment/result.py +570 -0
  50. polyclustx/metrics/__init__.py +21 -0
  51. polyclustx/metrics/clustering.py +535 -0
  52. polyclustx/metrics/evaluation.py +44 -0
  53. polyclustx/models/__init__.py +41 -0
  54. polyclustx/models/_common.py +148 -0
  55. polyclustx/models/_deep.py +67 -0
  56. polyclustx/models/_graphs.py +47 -0
  57. polyclustx/models/_torch_utils.py +37 -0
  58. polyclustx/models/completer/__init__.py +12 -0
  59. polyclustx/models/completer/completer.py +204 -0
  60. polyclustx/models/conan/__init__.py +12 -0
  61. polyclustx/models/conan/conan.py +137 -0
  62. polyclustx/models/dcp/__init__.py +12 -0
  63. polyclustx/models/dcp/dcp.py +31 -0
  64. polyclustx/models/dgf/__init__.py +12 -0
  65. polyclustx/models/dgf/dgf.py +205 -0
  66. polyclustx/models/g_comvkm/__init__.py +12 -0
  67. polyclustx/models/g_comvkm/g_comvkm.py +141 -0
  68. polyclustx/models/idec/__init__.py +12 -0
  69. polyclustx/models/idec/idec.py +147 -0
  70. polyclustx/models/lmgec/__init__.py +10 -0
  71. polyclustx/models/lmgec/lmgec.py +463 -0
  72. polyclustx/models/mflvc/__init__.py +12 -0
  73. polyclustx/models/mflvc/mflvc.py +191 -0
  74. polyclustx/models/multiview_kmeans/__init__.py +12 -0
  75. polyclustx/models/multiview_kmeans/multiview_kmeans.py +140 -0
  76. polyclustx/models/multiview_spectral/__init__.py +12 -0
  77. polyclustx/models/multiview_spectral/multiview_spectral.py +124 -0
  78. polyclustx/models/registry.py +167 -0
  79. polyclustx/models/scm/__init__.py +12 -0
  80. polyclustx/models/scm/scm.py +310 -0
  81. polyclustx/models/sgla/__init__.py +12 -0
  82. polyclustx/models/sgla/sgla.py +107 -0
  83. polyclustx/models/umc/__init__.py +12 -0
  84. polyclustx/models/umc/umc.py +191 -0
  85. polyclustx/preprocessing/__init__.py +10 -0
  86. polyclustx/preprocessing/features.py +382 -0
  87. polyclustx/preprocessing/fusion.py +42 -0
  88. polyclustx/preprocessing/graph_construction.py +0 -0
  89. polyclustx/progress.py +36 -0
  90. polyclustx/tracking.py +552 -0
  91. polyclustx/visualization/__init__.py +3 -0
  92. polyclustx/visualization/visualizer.py +309 -0
  93. polyclustx-1.4.1.dist-info/METADATA +427 -0
  94. polyclustx-1.4.1.dist-info/RECORD +98 -0
  95. polyclustx-1.4.1.dist-info/WHEEL +5 -0
  96. polyclustx-1.4.1.dist-info/entry_points.txt +16 -0
  97. polyclustx-1.4.1.dist-info/licenses/LICENSE +133 -0
  98. 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m polyclustx`` wherever the console command is unavailable."""
2
+
3
+ from .command import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()