dimc-toolkit 1.0.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.
dimc/fit.py ADDED
@@ -0,0 +1,682 @@
1
+ """
2
+ Alternating optimization loop, alpha-sensitivity sweep, and full pipeline.
3
+
4
+ Corresponds to notebook Sections 8, 9b, and 10:
5
+ 8. Algorithm 1, lines 4-9: the alternating optimization loop
6
+ 9b. Parameter sensitivity sweep for alpha(1)/alpha(2) (Section 4.4, Fig. 4)
7
+ 10. Full pipeline = Algorithm 1, driven by one plain settings dict
8
+
9
+ The public entry point is ``fit`` (aliased to ``run_dimc`` for backward
10
+ compatibility with the notebook's naming), matching the requested usage::
11
+
12
+ from dimc import fit
13
+ result = fit(X_views, labels, learning_rate=1e-5, alpha_1=60, alpha_2=60)
14
+ """
15
+
16
+ import time
17
+ from typing import Any, Dict, List, Optional, Sequence
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+ from dimc.algorithms.update_graph import update_PC, graph_regularization_loss
23
+ from dimc.algorithms.update_weights import update_PI
24
+ from dimc.algorithms.update_W import update_U
25
+ from dimc.algorithms.update_H import finetune_view_network
26
+ from dimc.config import DEFAULT_ALPHA_CANDIDATES, DEFAULT_SETTINGS, SWEEP_MAX_EPOCHS
27
+ from dimc.graph import build_view_graph
28
+ from dimc.initialize import (
29
+ build_view_decoder,
30
+ build_view_network,
31
+ initialize_nmf_factor,
32
+ pretrain_view_network,
33
+ )
34
+ from dimc.metrics import cluster_representation, compute_ACC, compute_NMI
35
+ from dimc.preprocessing import (
36
+ load_views,
37
+ make_missing_masks,
38
+ split_by_availability,
39
+ standardize_views,
40
+ )
41
+ from dimc.utils import set_global_seed
42
+
43
+
44
+ # ============================================================
45
+ # 8. Algorithm 1, lines 4-9: the alternating optimization loop
46
+ # ============================================================
47
+
48
+ def _forward_all_views(
49
+ encoders: Sequence[Any],
50
+ X_views_t: Sequence[torch.Tensor],
51
+ ) -> List[np.ndarray]:
52
+ """
53
+ Run every view's current encoder to obtain H(v) for all views
54
+ (Algorithm 1, line 5: forward propagation).
55
+
56
+ Parameters
57
+ ----------
58
+ encoders : Sequence[torch.nn.Sequential]
59
+ Per-view encoder networks.
60
+ X_views_t : Sequence[torch.Tensor]
61
+ Per-view input tensors, each shape ``(n_samples, input_dim_v)``.
62
+
63
+ Returns
64
+ -------
65
+ List[numpy.ndarray]
66
+ Per-view latent representations ``H(v)``, each shape
67
+ ``(n_samples, latent_dim)``.
68
+ """
69
+ return [enc(X).detach().numpy() for enc, X in zip(encoders, X_views_t)]
70
+
71
+
72
+ def _update_codes(
73
+ U_list: List[np.ndarray],
74
+ PC: np.ndarray,
75
+ PI_list: List[np.ndarray],
76
+ H_complete: List[np.ndarray],
77
+ H_private: List[np.ndarray],
78
+ W_list: List[np.ndarray],
79
+ D_list: List[np.ndarray],
80
+ alpha_list: List[float],
81
+ ) -> tuple:
82
+ """
83
+ Algorithm 1, line 6: update PC (Eq. 13) and each P(v)_I (Eq. 14).
84
+
85
+ Parameters
86
+ ----------
87
+ U_list : List[numpy.ndarray]
88
+ Per-view basis matrices.
89
+ PC : numpy.ndarray
90
+ Current shared code matrix.
91
+ PI_list : List[numpy.ndarray]
92
+ Per-view private code matrices.
93
+ H_complete : List[numpy.ndarray]
94
+ Per-view DNN latent representations restricted to complete samples.
95
+ H_private : List[numpy.ndarray]
96
+ Per-view DNN latent representations restricted to private samples.
97
+ W_list : List[numpy.ndarray]
98
+ Per-view affinity matrices.
99
+ D_list : List[numpy.ndarray]
100
+ Per-view degree matrices.
101
+ alpha_list : List[float]
102
+ Per-view graph-regularization strengths.
103
+
104
+ Returns
105
+ -------
106
+ PC : numpy.ndarray
107
+ Updated shared code matrix.
108
+ PI_list : List[numpy.ndarray]
109
+ Updated per-view private code matrices.
110
+ """
111
+ PC = update_PC(PC, U_list, H_complete, W_list, D_list, alpha_list)
112
+ PI_list = [update_PI(PI, U, H) for PI, U, H in zip(PI_list, U_list, H_private)]
113
+ return PC, PI_list
114
+
115
+
116
+ def _update_bases(
117
+ U_list: List[np.ndarray],
118
+ H_full: List[np.ndarray],
119
+ obs_idx: List[np.ndarray],
120
+ PC: np.ndarray,
121
+ PI_list: List[np.ndarray],
122
+ ) -> List[np.ndarray]:
123
+ """
124
+ Algorithm 1, line 7: update each U(v) (Eq. 16), using the FULL H(v)
125
+ and P(v) = [PC, P(v)_I].
126
+
127
+ Parameters
128
+ ----------
129
+ U_list : List[numpy.ndarray]
130
+ Per-view basis matrices.
131
+ H_full : List[numpy.ndarray]
132
+ Per-view full DNN latent representations, each shape
133
+ ``(n_samples, latent_dim)``.
134
+ obs_idx : List[numpy.ndarray]
135
+ Per-view indices of samples that view observes.
136
+ PC : numpy.ndarray
137
+ Current shared code matrix.
138
+ PI_list : List[numpy.ndarray]
139
+ Per-view private code matrices.
140
+
141
+ Returns
142
+ -------
143
+ List[numpy.ndarray]
144
+ Updated per-view basis matrices.
145
+ """
146
+ return [
147
+ update_U(U, H[idx].T, np.hstack([PC, PI]))
148
+ for U, H, idx, PI in zip(U_list, H_full, obs_idx, PI_list)
149
+ ]
150
+
151
+
152
+ def _finetune_all_views(
153
+ encoders: Sequence[Any],
154
+ X_views_t: Sequence[torch.Tensor],
155
+ obs_idx: List[np.ndarray],
156
+ U_list: List[np.ndarray],
157
+ PC: np.ndarray,
158
+ PI_list: List[np.ndarray],
159
+ learning_rate: float,
160
+ ) -> List[float]:
161
+ """
162
+ Algorithm 1, line 8: fine-tune each view-specific DNN (Eqs. 17-23).
163
+
164
+ Parameters
165
+ ----------
166
+ encoders : Sequence[torch.nn.Sequential]
167
+ Per-view encoder networks, modified in place.
168
+ X_views_t : Sequence[torch.Tensor]
169
+ Per-view input tensors.
170
+ obs_idx : List[numpy.ndarray]
171
+ Per-view indices of samples that view observes.
172
+ U_list : List[numpy.ndarray]
173
+ Per-view basis matrices.
174
+ PC : numpy.ndarray
175
+ Current shared code matrix.
176
+ PI_list : List[numpy.ndarray]
177
+ Per-view private code matrices.
178
+ learning_rate : float
179
+ Plain gradient-descent step size for the fine-tune step.
180
+
181
+ Returns
182
+ -------
183
+ List[float]
184
+ Per-view MSE reconstruction loss values.
185
+ """
186
+ losses = []
187
+ for v in range(2):
188
+ P_full = np.hstack([PC, PI_list[v]])
189
+ target = torch.tensor((U_list[v] @ P_full).T, dtype=torch.float32)
190
+ losses.append(
191
+ finetune_view_network(encoders[v], X_views_t[v][obs_idx[v]], target, learning_rate)
192
+ )
193
+ return losses
194
+
195
+
196
+ def train_dimc(
197
+ X_views_t: Sequence[torch.Tensor],
198
+ encoders: Sequence[Any],
199
+ U_list: List[np.ndarray],
200
+ PC: np.ndarray,
201
+ PI_list: List[np.ndarray],
202
+ split: Dict[str, np.ndarray],
203
+ graphs: Sequence[Dict[str, np.ndarray]],
204
+ alpha_list: List[float],
205
+ learning_rate: float = 1e-2,
206
+ n_epochs: int = 300,
207
+ log_every: int = 50,
208
+ ) -> tuple:
209
+ """
210
+ Run the Algorithm 1, lines 4-9 alternating optimization loop.
211
+
212
+ Parameters
213
+ ----------
214
+ X_views_t : Sequence[torch.Tensor]
215
+ Per-view input tensors, each shape ``(n_samples, input_dim_v)``.
216
+ encoders : Sequence[torch.nn.Sequential]
217
+ Per-view encoder networks, modified in place during training.
218
+ U_list : List[numpy.ndarray]
219
+ Initial per-view basis matrices.
220
+ PC : numpy.ndarray
221
+ Initial shared code matrix.
222
+ PI_list : List[numpy.ndarray]
223
+ Initial per-view private code matrices.
224
+ split : Dict[str, numpy.ndarray]
225
+ Output of ``dimc.preprocessing.split_by_availability``.
226
+ graphs : Sequence[Dict[str, numpy.ndarray]]
227
+ Per-view graph dictionaries from ``dimc.graph.build_view_graph``,
228
+ each containing ``"W"``, ``"D"``, and ``"L"``.
229
+ alpha_list : List[float]
230
+ Per-view graph-regularization strengths.
231
+ learning_rate : float, default=1e-2
232
+ Learning rate used for the DNN fine-tuning step.
233
+ n_epochs : int, default=300
234
+ Number of alternating-optimization iterations.
235
+ log_every : int, default=50
236
+ Print reconstruction/graph loss every this many epochs.
237
+
238
+ Returns
239
+ -------
240
+ U_list : List[numpy.ndarray]
241
+ Final per-view basis matrices.
242
+ PC : numpy.ndarray
243
+ Final shared code matrix.
244
+ PI_list : List[numpy.ndarray]
245
+ Final per-view private code matrices.
246
+ """
247
+ complete_idx = split["complete_idx"]
248
+ private_idx = [split["view0_only_idx"], split["view1_only_idx"]]
249
+ obs_idx = [split["view0_obs_idx"], split["view1_obs_idx"]]
250
+
251
+ W_list = [g["W"] for g in graphs]
252
+ D_list = [g["D"] for g in graphs]
253
+ L_list = [g["L"] for g in graphs]
254
+
255
+ for epoch in range(n_epochs):
256
+ # line 5: forward propagation -> H(v) for all views
257
+ H_full = _forward_all_views(encoders, X_views_t)
258
+ H_complete = [H[complete_idx].T for H in H_full]
259
+ H_private = [H[idx].T for H, idx in zip(H_full, private_idx)]
260
+
261
+ # line 6: update PC (Eq. 13) and each P(v)_I (Eq. 14)
262
+ PC, PI_list = _update_codes(
263
+ U_list, PC, PI_list, H_complete, H_private, W_list, D_list, alpha_list
264
+ )
265
+
266
+ # line 7: update each U(v) (Eq. 16), using the FULL H(v) and P(v) = [PC, P(v)_I]
267
+ U_list = _update_bases(U_list, H_full, obs_idx, PC, PI_list)
268
+
269
+ # line 8: fine-tune each view-specific DNN (Eqs. 17-23)
270
+ losses = _finetune_all_views(encoders, X_views_t, obs_idx, U_list, PC, PI_list, learning_rate)
271
+
272
+ if epoch % log_every == 0:
273
+ recon_loss = sum(losses)
274
+ graph_loss = sum(
275
+ graph_regularization_loss(PC, L, alpha)
276
+ for L, alpha in zip(L_list, alpha_list)
277
+ )
278
+ print(f"Epoch {epoch:3d} | Recon: {recon_loss:.4f} | Graph: {graph_loss:.4f}")
279
+
280
+ return U_list, PC, PI_list
281
+
282
+
283
+ # ============================================================
284
+ # 10. Full pipeline = Algorithm 1, driven by one plain settings dict
285
+ # ============================================================
286
+
287
+ def _prepare_data(
288
+ settings: Dict[str, Any],
289
+ X_views: Optional[Sequence[np.ndarray]],
290
+ labels: Optional[np.ndarray],
291
+ ) -> tuple:
292
+ """
293
+ Load (if needed) and standardize the input views, and derive labels
294
+ if not provided (Section 4.1).
295
+
296
+ Parameters
297
+ ----------
298
+ settings : Dict[str, Any]
299
+ Full settings dictionary.
300
+ X_views : Optional[Sequence[numpy.ndarray]]
301
+ Pre-loaded views, or ``None`` to load from ``settings["fou_path"]``
302
+ / ``settings["pix_path"]``.
303
+ labels : Optional[numpy.ndarray]
304
+ Ground-truth labels, or ``None`` to derive from
305
+ ``settings["n_classes"]`` / ``settings["samples_per_class"]``.
306
+
307
+ Returns
308
+ -------
309
+ X_views : List[numpy.ndarray]
310
+ Standardized views.
311
+ labels : numpy.ndarray
312
+ Ground-truth labels.
313
+ n_samples : int
314
+ Number of samples (rows) in view 0.
315
+ """
316
+ if X_views is None:
317
+ X_views = load_views([settings["fou_path"], settings["pix_path"]])
318
+ X_views = standardize_views(X_views)
319
+ n_samples = X_views[0].shape[0]
320
+
321
+ if labels is None:
322
+ labels = np.repeat(np.arange(settings["n_classes"]), settings["samples_per_class"])
323
+
324
+ return X_views, labels, n_samples
325
+
326
+
327
+ def _build_and_pretrain_networks(
328
+ settings: Dict[str, Any],
329
+ X_views: Sequence[np.ndarray],
330
+ X_views_t: Sequence[torch.Tensor],
331
+ ) -> tuple:
332
+ """
333
+ Algorithm 1, line 1: build and pretrain each view-specific DNN
334
+ (encoder + decoder) by auto-encoding.
335
+
336
+ Parameters
337
+ ----------
338
+ settings : Dict[str, Any]
339
+ Full settings dictionary.
340
+ X_views : Sequence[numpy.ndarray]
341
+ Standardized views (used only for their feature dimensionality).
342
+ X_views_t : Sequence[torch.Tensor]
343
+ Torch tensors of the standardized views.
344
+
345
+ Returns
346
+ -------
347
+ encoders : List[torch.nn.Sequential]
348
+ Pretrained per-view encoders.
349
+ decoders : List[torch.nn.Sequential]
350
+ Pretrained per-view decoders.
351
+ """
352
+ encoders = [
353
+ build_view_network(X.shape[1], latent_dim=settings["latent_dim"]) for X in X_views
354
+ ]
355
+ decoders = [
356
+ build_view_decoder(X.shape[1], latent_dim=settings["latent_dim"]) for X in X_views
357
+ ]
358
+
359
+ for enc, dec, X in zip(encoders, decoders, X_views_t):
360
+ pretrain_view_network(
361
+ enc, dec, X,
362
+ epochs=settings["pretrain_epochs"],
363
+ learning_rate=settings["pretrain_lr"],
364
+ )
365
+
366
+ return encoders, decoders
367
+
368
+
369
+ def _initialize_factors(
370
+ settings: Dict[str, Any],
371
+ split: Dict[str, np.ndarray],
372
+ rng: np.random.RandomState,
373
+ ) -> tuple:
374
+ """
375
+ Algorithm 1, lines 2-3: initialize U(v), PC (Eq. 7), and P(v)_I randomly.
376
+
377
+ Parameters
378
+ ----------
379
+ settings : Dict[str, Any]
380
+ Full settings dictionary.
381
+ split : Dict[str, numpy.ndarray]
382
+ Output of ``dimc.preprocessing.split_by_availability``.
383
+ rng : numpy.random.RandomState
384
+ Random state used for all factor initializations.
385
+
386
+ Returns
387
+ -------
388
+ U_list : List[numpy.ndarray]
389
+ Initial per-view basis matrices.
390
+ PC : numpy.ndarray
391
+ Initial shared code matrix.
392
+ PI_list : List[numpy.ndarray]
393
+ Initial per-view private code matrices.
394
+ """
395
+ complete_idx = split["complete_idx"]
396
+
397
+ U_list = [
398
+ initialize_nmf_factor((settings["latent_dim"], settings["latent_k"]), rng)
399
+ for _ in range(2)
400
+ ]
401
+ PC = initialize_nmf_factor((settings["latent_k"], len(complete_idx)), rng)
402
+ PI_list = [
403
+ initialize_nmf_factor((settings["latent_k"], len(idx)), rng)
404
+ for idx in [split["view0_only_idx"], split["view1_only_idx"]]
405
+ ]
406
+
407
+ return U_list, PC, PI_list
408
+
409
+
410
+ def _nmf_warmup(
411
+ settings: Dict[str, Any],
412
+ encoders: Sequence[Any],
413
+ X_views_t: Sequence[torch.Tensor],
414
+ split: Dict[str, np.ndarray],
415
+ graphs: Sequence[Dict[str, np.ndarray]],
416
+ U_list: List[np.ndarray],
417
+ PC: np.ndarray,
418
+ alpha_list: List[float],
419
+ ) -> tuple:
420
+ """
421
+ NMF-only warm-up on the complete samples to minimize Eq. (7) before
422
+ joint training.
423
+
424
+ Parameters
425
+ ----------
426
+ settings : Dict[str, Any]
427
+ Full settings dictionary.
428
+ encoders : Sequence[torch.nn.Sequential]
429
+ Pretrained per-view encoders.
430
+ X_views_t : Sequence[torch.Tensor]
431
+ Torch tensors of the standardized views.
432
+ split : Dict[str, numpy.ndarray]
433
+ Output of ``dimc.preprocessing.split_by_availability``.
434
+ graphs : Sequence[Dict[str, numpy.ndarray]]
435
+ Per-view graph dictionaries.
436
+ U_list : List[numpy.ndarray]
437
+ Initial per-view basis matrices.
438
+ PC : numpy.ndarray
439
+ Initial shared code matrix.
440
+ alpha_list : List[float]
441
+ Per-view graph-regularization strengths.
442
+
443
+ Returns
444
+ -------
445
+ U_list : List[numpy.ndarray]
446
+ Warmed-up per-view basis matrices.
447
+ PC : numpy.ndarray
448
+ Warmed-up shared code matrix.
449
+ """
450
+ complete_idx = split["complete_idx"]
451
+
452
+ H0 = encoders[0](X_views_t[0]).detach().numpy()
453
+ H1 = encoders[1](X_views_t[1]).detach().numpy()
454
+ H_complete = [H0[complete_idx].T, H1[complete_idx].T]
455
+ W_list = [g["W"] for g in graphs]
456
+ D_list = [g["D"] for g in graphs]
457
+
458
+ for _ in range(settings["nmf_pretrain_iters"]):
459
+ PC = update_PC(PC, U_list, H_complete, W_list, D_list, alpha_list)
460
+ U_list = [update_U(U, H, PC) for U, H in zip(U_list, H_complete)]
461
+
462
+ return U_list, PC
463
+
464
+
465
+ def _assemble_shared_representation(
466
+ settings: Dict[str, Any],
467
+ split: Dict[str, np.ndarray],
468
+ n_samples: int,
469
+ PC: np.ndarray,
470
+ PI_list: List[np.ndarray],
471
+ ) -> np.ndarray:
472
+ """
473
+ Assemble the full shared representation P by scattering PC and each
474
+ P(v)_I back into their original sample positions (Section 3, end).
475
+
476
+ Parameters
477
+ ----------
478
+ settings : Dict[str, Any]
479
+ Full settings dictionary.
480
+ split : Dict[str, numpy.ndarray]
481
+ Output of ``dimc.preprocessing.split_by_availability``.
482
+ n_samples : int
483
+ Total number of samples.
484
+ PC : numpy.ndarray
485
+ Final shared code matrix, restricted to complete samples.
486
+ PI_list : List[numpy.ndarray]
487
+ Final per-view private code matrices.
488
+
489
+ Returns
490
+ -------
491
+ numpy.ndarray
492
+ Full shared representation, shape ``(latent_k, n_samples)``.
493
+ """
494
+ P_shared = np.zeros((settings["latent_k"], n_samples), dtype=np.float32)
495
+ P_shared[:, split["complete_idx"]] = PC
496
+ P_shared[:, split["view0_only_idx"]] = PI_list[0]
497
+ P_shared[:, split["view1_only_idx"]] = PI_list[1]
498
+ return P_shared
499
+
500
+
501
+ def run_dimc(
502
+ settings: Dict[str, Any],
503
+ X_views: Optional[Sequence[np.ndarray]] = None,
504
+ labels: Optional[np.ndarray] = None,
505
+ ) -> Dict[str, Any]:
506
+ """
507
+ Run the full DIMC pipeline (Algorithm 1) end to end, driven by one
508
+ plain settings dictionary.
509
+
510
+ Parameters
511
+ ----------
512
+ settings : Dict[str, Any]
513
+ Full settings dictionary; see ``dimc.config.DEFAULT_SETTINGS``
514
+ for the expected keys and their meanings.
515
+ X_views : Optional[Sequence[numpy.ndarray]], default=None
516
+ Pre-loaded views. If ``None``, views are loaded from
517
+ ``settings["fou_path"]`` / ``settings["pix_path"]``.
518
+ labels : Optional[numpy.ndarray], default=None
519
+ Ground-truth labels. If ``None``, labels are derived from
520
+ ``settings["n_classes"]`` / ``settings["samples_per_class"]``.
521
+
522
+ Returns
523
+ -------
524
+ Dict[str, Any]
525
+ Dictionary with keys ``"ACC"``, ``"NMI"``, ``"encoders"``,
526
+ ``"decoders"``, ``"U_list"``, ``"PC"``, ``"PI_list"``,
527
+ ``"P_shared"``, and ``"cluster_labels"``.
528
+ """
529
+ t0 = time.time()
530
+
531
+ seed = settings["seed"]
532
+ rng = set_global_seed(seed)
533
+
534
+ # ---- data (Section 4.1) ----
535
+ X_views, labels, n_samples = _prepare_data(settings, X_views, labels)
536
+
537
+ # ---- incomplete-view split: D = {X_C, X_I} ----
538
+ mask_0, mask_1 = make_missing_masks(n_samples, settings["missing_ratio"], settings["mask_seed"])
539
+ split = split_by_availability(mask_0, mask_1)
540
+ complete_idx = split["complete_idx"]
541
+
542
+ X_views_t = [torch.tensor(X, dtype=torch.float32) for X in X_views]
543
+
544
+ # ---- one affinity graph W(v)/L(v) PER VIEW, built from that view's own
545
+ # complete samples X(v)_C (Eq. 2, Section 3.2) ----
546
+ graphs = [build_view_graph(X[complete_idx], settings["p_neighbors"]) for X in X_views]
547
+
548
+ # ---- Algorithm 1, line 1: pretrain view-specific DNNs by auto-encoding ----
549
+ encoders, decoders = _build_and_pretrain_networks(settings, X_views, X_views_t)
550
+
551
+ # ---- Algorithm 1, lines 2-3: initialize U(v), PC (Eq. 7) and P(v)_I randomly ----
552
+ U_list, PC, PI_list = _initialize_factors(settings, split, rng)
553
+
554
+ alpha_list = [settings["alpha_1"], settings["alpha_2"]]
555
+
556
+ # NMF-only warm-up on the complete samples to minimize Eq. (7) before joint training
557
+ U_list, PC = _nmf_warmup(settings, encoders, X_views_t, split, graphs, U_list, PC, alpha_list)
558
+
559
+ # ---- Algorithm 1, lines 4-9: alternating optimization until convergence ----
560
+ U_list, PC, PI_list = train_dimc(
561
+ X_views_t, encoders, U_list, PC, PI_list, split, graphs, alpha_list,
562
+ learning_rate=settings["learning_rate"],
563
+ n_epochs=settings["n_epochs"],
564
+ log_every=settings["log_every"],
565
+ )
566
+
567
+ # ---- assemble shared representation P and cluster (Section 3, end) ----
568
+ P_shared = _assemble_shared_representation(settings, split, n_samples, PC, PI_list)
569
+
570
+ cluster_labels = cluster_representation(P_shared, settings["n_clusters"], seed)
571
+
572
+ acc = compute_ACC(labels, cluster_labels)
573
+ nmi = compute_NMI(labels, cluster_labels)
574
+ runtime = time.time() - t0
575
+
576
+ print(f"ACC={acc:.4f} NMI={nmi:.4f} Runtime={runtime:.2f}s")
577
+
578
+ return {
579
+ "ACC": acc,
580
+ "NMI": nmi,
581
+ "encoders": encoders,
582
+ "decoders": decoders,
583
+ "U_list": U_list,
584
+ "PC": PC,
585
+ "PI_list": PI_list,
586
+ "P_shared": P_shared,
587
+ "cluster_labels": cluster_labels,
588
+ }
589
+
590
+
591
+ # ============================================================
592
+ # 9b. Parameter sensitivity sweep for alpha(1)/alpha(2) (Section 4.4, Fig. 4)
593
+ # ============================================================
594
+ # The paper does NOT use one fixed alpha across datasets: Section 4.4 explicitly
595
+ # sweeps alpha(1)/alpha(2) per dataset and picks the values that maximize NMI
596
+ # (Fig. 4 shows NMI rises then falls as alpha grows -- too small under-uses the
597
+ # graph regularizer, too large lets it dominate reconstruction and over-smooth
598
+ # PC, which hurts clustering). alpha=60 is the value the paper found for its
599
+ # own Multiple Features preprocessing; a different fou/pix file (different
600
+ # scaling, different feature statistics) can need a very different alpha.
601
+ # Run this sweep on your own data before trusting a fixed default.
602
+
603
+ def sweep_alpha(
604
+ settings: Dict[str, Any],
605
+ alpha_candidates: Sequence[float],
606
+ X_views: Optional[Sequence[np.ndarray]] = None,
607
+ labels: Optional[np.ndarray] = None,
608
+ ) -> List[Dict[str, float]]:
609
+ """
610
+ Try each alpha value (same alpha for both views, as in Fig. 4) with a
611
+ reduced epoch budget, and report ACC/NMI for each so you can pick the
612
+ best one for your data before doing a full run.
613
+
614
+ Parameters
615
+ ----------
616
+ settings : Dict[str, Any]
617
+ Base settings dictionary; a copy is modified per candidate alpha.
618
+ alpha_candidates : Sequence[float]
619
+ Candidate values to try for both ``alpha_1`` and ``alpha_2``.
620
+ X_views : Optional[Sequence[numpy.ndarray]], default=None
621
+ Pre-loaded views, forwarded to ``run_dimc``.
622
+ labels : Optional[numpy.ndarray], default=None
623
+ Ground-truth labels, forwarded to ``run_dimc``.
624
+
625
+ Returns
626
+ -------
627
+ List[Dict[str, float]]
628
+ One dict per candidate alpha with keys ``"alpha"``, ``"ACC"``,
629
+ and ``"NMI"``.
630
+ """
631
+ sweep_settings = dict(settings)
632
+ sweep_settings["n_epochs"] = min(settings["n_epochs"], SWEEP_MAX_EPOCHS)
633
+ sweep_settings["log_every"] = sweep_settings["n_epochs"] # only log the final epoch
634
+
635
+ scores = []
636
+ for alpha in alpha_candidates:
637
+ sweep_settings["alpha_1"] = alpha
638
+ sweep_settings["alpha_2"] = alpha
639
+ result = run_dimc(sweep_settings, X_views=X_views, labels=labels)
640
+ scores.append({"alpha": alpha, "ACC": result["ACC"], "NMI": result["NMI"]})
641
+ print(f"alpha={alpha:<8} -> ACC={result['ACC']:.4f} NMI={result['NMI']:.4f}")
642
+
643
+ best = max(scores, key=lambda s: s["NMI"])
644
+ print(f"\nBest alpha by NMI: {best['alpha']} (ACC={best['ACC']:.4f}, NMI={best['NMI']:.4f})")
645
+ return scores
646
+
647
+
648
+ def fit(
649
+ X_views: Optional[Sequence[np.ndarray]] = None,
650
+ labels: Optional[np.ndarray] = None,
651
+ **overrides: Any,
652
+ ) -> Dict[str, Any]:
653
+ """
654
+ Convenience entry point: run the full DIMC pipeline using
655
+ ``dimc.config.DEFAULT_SETTINGS`` merged with any keyword overrides.
656
+
657
+ This matches the requested top-level usage::
658
+
659
+ from dimc import fit
660
+ result = fit(X_views, labels, learning_rate=1e-5, alpha_1=60, alpha_2=60)
661
+
662
+ Parameters
663
+ ----------
664
+ X_views : Optional[Sequence[numpy.ndarray]], default=None
665
+ Pre-loaded views. If ``None``, views are loaded from the default
666
+ (or overridden) ``fou_path`` / ``pix_path`` settings.
667
+ labels : Optional[numpy.ndarray], default=None
668
+ Ground-truth labels. If ``None``, labels are derived from the
669
+ default (or overridden) ``n_classes`` / ``samples_per_class``
670
+ settings.
671
+ **overrides : Any
672
+ Any key from ``dimc.config.DEFAULT_SETTINGS`` to override, e.g.
673
+ ``learning_rate=1e-5``, ``alpha_1=60``, ``alpha_2=60``.
674
+
675
+ Returns
676
+ -------
677
+ Dict[str, Any]
678
+ Same return value as ``run_dimc``.
679
+ """
680
+ settings = dict(DEFAULT_SETTINGS)
681
+ settings.update(overrides)
682
+ return run_dimc(settings, X_views=X_views, labels=labels)