AbstractPhil commited on
Commit
526518a
Β·
verified Β·
1 Parent(s): 396b9e6

Update 111m_proto_1024_v3_geometrically_cv_aligned.py

Browse files
111m_proto_1024_v3_geometrically_cv_aligned.py CHANGED
@@ -1,16 +1,22 @@
1
  """
2
- SVAE β€” Binding Constant Alignment
3
- ====================================
4
- Matrix: 1024 Γ— 24
5
- D=24 β†’ native CV = 0.29154 (the binding constant)
6
 
7
- The 24 singular values ARE a 24-dimensional embedding.
8
- Each image produces a point in R^24 (its spectrum).
9
- The distribution of spectra across the batch should have
10
- CV = 0.29154 β€” the geometric phase boundary.
 
11
 
12
- The SVD bottleneck doesn't just compress.
13
- It produces representations at the binding constant.
 
 
 
 
 
14
 
15
  pip install "git+https://github.com/AbstractEyes/geolip-core.git"
16
  """
@@ -28,11 +34,10 @@ try:
28
  print("Using geolip-core SVD (Gram + eigh)")
29
  except ImportError:
30
  HAS_GEOLIP = False
31
- print("geolip-core not found, using torch.svd_lowrank fallback")
32
- print('Install: pip install "git+https://github.com/AbstractEyes/geolip-core.git"')
33
 
34
 
35
- # ── CM primitives for spectrum geometry ──
36
 
37
  def cayley_menger_vol2(points):
38
  B, N, D = points.shape
@@ -50,7 +55,6 @@ def cayley_menger_vol2(points):
50
 
51
 
52
  def cv_of(emb, n_samples=200):
53
- """CV of a set of points. emb: (N, D)."""
54
  if emb.dim() != 2 or emb.shape[0] < 5:
55
  return 0.0
56
  N, D = emb.shape
@@ -64,20 +68,7 @@ def cv_of(emb, n_samples=200):
64
  return (vols.std() / (vols.mean() + 1e-8)).item()
65
 
66
 
67
- def cv_loss(emb, target=0.29154, n_samples=64):
68
- """CV loss targeting the binding constant."""
69
- N, D = emb.shape
70
- if N < 5:
71
- return torch.tensor(0.0, device=emb.device, requires_grad=True)
72
- pool = min(N, 512)
73
- indices = torch.stack([torch.randperm(pool, device=emb.device)[:5] for _ in range(n_samples)])
74
- vol2 = cayley_menger_vol2(emb[:pool][indices])
75
- valid = vol2 > 1e-20
76
- if valid.sum() < 5:
77
- return torch.tensor(0.0, device=emb.device, requires_grad=True)
78
- vols = vol2[valid].sqrt()
79
- cv = vols.std() / (vols.mean() + 1e-8)
80
- return (cv - target).pow(2)
81
 
82
 
83
  # ── Data ──
@@ -96,48 +87,46 @@ def get_cifar10(batch_size=256):
96
 
97
  # ── SVAE ──
98
 
99
- BINDING_CONSTANT = 0.29154
100
-
101
  class SVAE(nn.Module):
102
- def __init__(self, matrix_h=1024, keep_k=24):
 
 
 
 
103
  super().__init__()
104
- self.matrix_h = matrix_h
105
- self.matrix_k = keep_k
106
- self.keep_k = keep_k
107
  self.img_dim = 3 * 32 * 32
108
- self.mat_dim = matrix_h * keep_k
109
 
110
- # Deeper encoder for 1024Γ—24 = 24,576 elements
111
  self.encoder = nn.Sequential(
112
- nn.Linear(self.img_dim, 1024),
113
  nn.GELU(),
114
- nn.Linear(1024, 2048),
115
  nn.GELU(),
116
- nn.Linear(2048, self.mat_dim),
117
  )
118
-
119
- # Deeper decoder β€” symmetric
120
  self.decoder = nn.Sequential(
121
- nn.Linear(self.mat_dim, 2048),
122
  nn.GELU(),
123
- nn.Linear(2048, 1024),
124
  nn.GELU(),
125
- nn.Linear(1024, self.img_dim),
126
  )
127
 
128
  def encode(self, images):
129
  B = images.shape[0]
130
- M = self.encoder(images.reshape(B, -1)).reshape(B, self.matrix_h, self.matrix_k)
131
 
132
  if HAS_GEOLIP:
133
  U, S, Vh = geolip_svd(M)
134
  else:
135
- U, S, V = torch.svd_lowrank(M, q=self.keep_k)
136
  Vh = V.transpose(1, 2)
137
 
138
  return {
139
  'U': U, 'S': S, 'Vt': Vh,
140
- 'M': M,
141
  }
142
 
143
  def decode_from_svd(self, U, S, Vt):
@@ -159,51 +148,44 @@ class SVAE(nn.Module):
159
 
160
  # ── Training ──
161
 
162
- def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
163
  device = torch.device(device if torch.cuda.is_available() else 'cpu')
164
  train_loader, test_loader = get_cifar10(batch_size=256)
165
 
166
- keep_k = 24 # D=24 β†’ binding constant
167
- model = SVAE(matrix_h=1024, keep_k=keep_k).to(device)
 
168
  opt = torch.optim.Adam(model.parameters(), lr=lr)
169
  sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
170
 
171
  total_params = sum(p.numel() for p in model.parameters())
172
- print(f"SVAE β€” Binding Constant Alignment")
173
- print(f" Matrix: ({model.matrix_h}, {model.matrix_k})")
174
- print(f" D=24 β†’ target CV = {BINDING_CONSTANT}")
175
- print(f" SVD: {'geolip-core Gram+eigh' if HAS_GEOLIP else 'torch.svd_lowrank'}")
176
- print(f" Compression: {model.img_dim} β†’ {keep_k} ({model.img_dim // keep_k}:1)")
177
  print(f" Params: {total_params:,}")
178
- print(f" Device: {device}")
179
  print("=" * 85)
180
- print(f"{'ep':>3} | {'loss':>7} {'recon':>7} {'cv_l':>7} | "
181
  f"{'t_recon':>7} | "
182
- f"{'S0':>6} {'S23':>6} {'ratio':>6} {'erank':>6} {'spec_cv':>7}")
 
183
  print("-" * 85)
184
 
185
  for epoch in range(1, epochs + 1):
186
  model.train()
187
- total_loss, total_recon, n = 0, 0, 0
188
 
189
  for images, labels in train_loader:
190
  images = images.to(device)
191
  opt.zero_grad()
192
  out = model(images)
193
 
194
- recon_loss = F.mse_loss(out['recon'], images)
195
-
196
- # CV loss on the SPECTRUM as a D=24 embedding
197
- # Each sample's 24 singular values = a point in R^24
198
- # The batch of spectra should have CV β†’ 0.29154
199
- spectrum_cv_loss = cv_loss(out['svd']['S'], target=BINDING_CONSTANT)
200
-
201
- loss = recon_loss + cv_weight * spectrum_cv_loss
202
  loss.backward()
203
  opt.step()
204
 
205
  total_loss += loss.item() * len(images)
206
- total_recon += recon_loss.item() * len(images)
207
  n += len(images)
208
 
209
  sched.step()
@@ -213,7 +195,7 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
213
  test_recon, test_n = 0, 0
214
  test_S = None
215
  test_erank = 0
216
- test_spec_cv = 0
217
  nb = 0
218
 
219
  with torch.no_grad():
@@ -224,8 +206,11 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
224
  test_n += len(images)
225
  test_erank += model.effective_rank(out['svd']['S']).mean().item()
226
 
227
- # Measure actual CV of the batch spectra
228
- test_spec_cv += cv_of(out['svd']['S'])
 
 
 
229
 
230
  if test_S is None:
231
  test_S = out['svd']['S'].mean(0).cpu()
@@ -234,15 +219,16 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
234
  nb += 1
235
 
236
  test_erank /= nb
237
- test_spec_cv /= nb
238
  test_S /= nb
239
  ratio = (test_S[0] / (test_S[-1] + 1e-8)).item()
 
 
240
 
241
- print(f"{epoch:3d} | {total_loss/n:7.4f} {total_recon/n:7.4f} "
242
- f"{spectrum_cv_loss.item():7.5f} | "
243
  f"{test_recon/test_n:7.4f} | "
244
  f"{test_S[0]:6.3f} {test_S[-1]:6.3f} {ratio:6.2f} "
245
- f"{test_erank:6.2f} {test_spec_cv:7.4f}")
 
246
 
247
  # ── Final Analysis ──
248
  print()
@@ -252,6 +238,7 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
252
 
253
  model.eval()
254
  all_S, all_recon_err, all_labels = [], [], []
 
255
 
256
  with torch.no_grad():
257
  for images, labels in test_loader:
@@ -263,20 +250,27 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
263
  .mean(dim=(1, 2, 3)).cpu())
264
  all_labels.append(labels.cpu())
265
 
 
 
 
 
266
  all_S = torch.cat(all_S)
267
  all_recon_err = torch.cat(all_recon_err)
268
  all_labels = torch.cat(all_labels)
269
 
270
  erank = model.effective_rank(all_S)
271
- spec_cv = cv_of(all_S)
272
 
273
- print(f"\n Bottleneck: {keep_k} singular values (D=24)")
274
  print(f" Recon MSE: {all_recon_err.mean():.6f} Β± {all_recon_err.std():.6f}")
275
  print(f" Effective rank: {erank.mean():.2f} Β± {erank.std():.2f}")
276
- print(f" Spectrum CV: {spec_cv:.4f} (target: {BINDING_CONSTANT})")
277
- print(f" Delta from binding constant: {abs(spec_cv - BINDING_CONSTANT):.4f}")
 
 
 
278
 
279
- # Singular value profile
280
  S_mean = all_S.mean(0)
281
  total_energy = (S_mean ** 2).sum()
282
  print(f"\n Singular value profile:")
@@ -286,34 +280,32 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
286
  cumulative += e
287
  pct = cumulative / total_energy * 100
288
  bar = "β–ˆ" * int(S_mean[i].item() * 30 / (S_mean[0].item() + 1e-8))
289
- print(f" S[{i:2d}]: {S_mean[i]:8.3f} cum_energy={pct:5.1f}% {bar}")
290
 
291
- # Per-class spectral signatures
292
  cifar_names = ['plane', 'car', 'bird', 'cat', 'deer',
293
  'dog', 'frog', 'horse', 'ship', 'truck']
294
  print(f"\n Per-class:")
295
- print(f" {'class':>6} {'recon':>8} {'erank':>6} {'spec_cv':>7} "
296
- f"{'S0':>6} {'S23':>6} {'ratio':>6}")
297
  for c in range(10):
298
  mask = all_labels == c
299
  rc = all_recon_err[mask].mean().item()
300
  er = erank[mask].mean().item()
301
- sc = cv_of(all_S[mask])
302
  s0 = all_S[mask, 0].mean().item()
303
- s23 = all_S[mask, -1].mean().item()
304
- ratio = s0 / (s23 + 1e-8)
305
- print(f" {cifar_names[c]:>6} {rc:8.6f} {er:6.2f} {sc:7.4f} "
306
- f"{s0:6.3f} {s23:6.3f} {ratio:6.2f}")
307
 
308
  # Cross-class spectral variance
309
  class_S_means = torch.stack([all_S[all_labels == c].mean(0) for c in range(10)])
310
  s_var = class_S_means.std(0)
311
- print(f"\n Cross-class spectral variance (per component):")
312
- for i in range(keep_k):
313
- bar = "β–ˆ" * int(s_var[i].item() * 50 / (s_var.max().item() + 1e-8))
314
- print(f" S[{i:2d}]: var={s_var[i]:.4f} {bar}")
 
315
 
316
- # ── Save reconstruction grid ──
317
  print(f"\n Saving reconstruction grid...")
318
  import matplotlib
319
  matplotlib.use('Agg')
@@ -338,9 +330,8 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
338
  S = out['svd']['S'][selected_idx]
339
  Vt = out['svd']['Vt'][selected_idx]
340
 
341
- mode_counts = [1, 4, 8, 16, 24]
342
- mode_counts = [m for m in mode_counts if m <= keep_k]
343
- mode_counts = list(dict.fromkeys(mode_counts))
344
  prog_recons = []
345
  for n_modes in mode_counts:
346
  r = model.decode_from_svd(U[:, :, :n_modes], S[:, :n_modes], Vt[:, :n_modes, :])
@@ -352,7 +343,6 @@ def train(epochs=50, lr=1e-3, cv_weight=0.1, device='cuda'):
352
  n_samples = len(selected_idx)
353
  n_cols = 2 + len(mode_counts)
354
  fig, axes = plt.subplots(n_samples, n_cols, figsize=(n_cols * 1.5, n_samples * 1.5))
355
-
356
  col_titles = ['Original'] + [f'{m} mode{"s" if m > 1 else ""}' for m in mode_counts] + ['|Error|Γ—5']
357
 
358
  for i in range(n_samples):
 
1
  """
2
+ SVAE β€” Structural Binding Constant
3
+ =====================================
4
+ Matrix (V, 24): V rows in D=24 space.
5
+ At D=24, CV β‰ˆ 0.29154 BY CONSTRUCTION β€” no loss needed.
6
 
7
+ The sweep proved it:
8
+ V=200, D=24 β†’ CV=0.2914
9
+ V=1024, D=24 β†’ CV=0.2916
10
+ V=1992, D=24 β†’ CV=0.2911
11
+ V is irrelevant. D determines CV.
12
 
13
+ The encoder produces a (V, 24) matrix.
14
+ The rows ARE an embedding: V tokens in D=24 space.
15
+ Their CV is ~0.29 by the dimensional law.
16
+ The SVD decomposes this embedding into its spectral structure.
17
+ The decoder reconstructs from the decomposition.
18
+
19
+ No CV loss. Monitor only. The geometry is inherent.
20
 
21
  pip install "git+https://github.com/AbstractEyes/geolip-core.git"
22
  """
 
34
  print("Using geolip-core SVD (Gram + eigh)")
35
  except ImportError:
36
  HAS_GEOLIP = False
37
+ print("geolip-core not found, fallback to torch.svd_lowrank")
 
38
 
39
 
40
+ # ── CM for monitoring (not loss) ──
41
 
42
  def cayley_menger_vol2(points):
43
  B, N, D = points.shape
 
55
 
56
 
57
  def cv_of(emb, n_samples=200):
 
58
  if emb.dim() != 2 or emb.shape[0] < 5:
59
  return 0.0
60
  N, D = emb.shape
 
68
  return (vols.std() / (vols.mean() + 1e-8)).item()
69
 
70
 
71
+ BINDING_CONSTANT = 0.29154
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  # ── Data ──
 
87
 
88
  # ── SVAE ──
89
 
 
 
90
  class SVAE(nn.Module):
91
+ def __init__(self, matrix_v=48, D=24):
92
+ """
93
+ matrix_v: number of rows (vocabulary size of the implicit embedding)
94
+ D: embedding dimension = number of singular values = 24 for binding constant
95
+ """
96
  super().__init__()
97
+ self.matrix_v = matrix_v # V β€” number of embedding rows
98
+ self.D = D # D β€” embedding dimension
 
99
  self.img_dim = 3 * 32 * 32
100
+ self.mat_dim = matrix_v * D
101
 
 
102
  self.encoder = nn.Sequential(
103
+ nn.Linear(self.img_dim, 512),
104
  nn.GELU(),
105
+ nn.Linear(512, 512),
106
  nn.GELU(),
107
+ nn.Linear(512, self.mat_dim),
108
  )
 
 
109
  self.decoder = nn.Sequential(
110
+ nn.Linear(self.mat_dim, 512),
111
  nn.GELU(),
112
+ nn.Linear(512, 512),
113
  nn.GELU(),
114
+ nn.Linear(512, self.img_dim),
115
  )
116
 
117
  def encode(self, images):
118
  B = images.shape[0]
119
+ M = self.encoder(images.reshape(B, -1)).reshape(B, self.matrix_v, self.D)
120
 
121
  if HAS_GEOLIP:
122
  U, S, Vh = geolip_svd(M)
123
  else:
124
+ U, S, V = torch.svd_lowrank(M, q=self.D)
125
  Vh = V.transpose(1, 2)
126
 
127
  return {
128
  'U': U, 'S': S, 'Vt': Vh,
129
+ 'M': M, # the embedding matrix β€” rows are V points in D=24
130
  }
131
 
132
  def decode_from_svd(self, U, S, Vt):
 
148
 
149
  # ── Training ──
150
 
151
+ def train(epochs=50, lr=1e-3, device='cuda'):
152
  device = torch.device(device if torch.cuda.is_available() else 'cpu')
153
  train_loader, test_loader = get_cifar10(batch_size=256)
154
 
155
+ D = 24
156
+ V = 48
157
+ model = SVAE(matrix_v=V, D=D).to(device)
158
  opt = torch.optim.Adam(model.parameters(), lr=lr)
159
  sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
160
 
161
  total_params = sum(p.numel() for p in model.parameters())
162
+ print(f"SVAE β€” Structural Binding Constant")
163
+ print(f" Matrix: ({V}, {D}) β€” {V} rows in D={D} space")
164
+ print(f" Expected row CV β‰ˆ {BINDING_CONSTANT} (no loss, by construction)")
165
+ print(f" SVD: {'geolip-core' if HAS_GEOLIP else 'torch.svd_lowrank'}")
166
+ print(f" Compression: {model.img_dim} β†’ {D} ({model.img_dim // D}:1)")
167
  print(f" Params: {total_params:,}")
 
168
  print("=" * 85)
169
+ print(f"{'ep':>3} | {'loss':>7} {'recon':>7} | "
170
  f"{'t_recon':>7} | "
171
+ f"{'S0':>6} {'SD':>6} {'ratio':>6} {'erank':>6} | "
172
+ f"{'row_cv':>7} {'Ξ”bc':>7}")
173
  print("-" * 85)
174
 
175
  for epoch in range(1, epochs + 1):
176
  model.train()
177
+ total_loss, n = 0, 0
178
 
179
  for images, labels in train_loader:
180
  images = images.to(device)
181
  opt.zero_grad()
182
  out = model(images)
183
 
184
+ loss = F.mse_loss(out['recon'], images)
 
 
 
 
 
 
 
185
  loss.backward()
186
  opt.step()
187
 
188
  total_loss += loss.item() * len(images)
 
189
  n += len(images)
190
 
191
  sched.step()
 
195
  test_recon, test_n = 0, 0
196
  test_S = None
197
  test_erank = 0
198
+ row_cvs = []
199
  nb = 0
200
 
201
  with torch.no_grad():
 
206
  test_n += len(images)
207
  test_erank += model.effective_rank(out['svd']['S']).mean().item()
208
 
209
+ # CV of matrix rows: each M[i] is (V, D) β€” V points in D=24
210
+ # Sample a few to keep it fast
211
+ if nb < 5:
212
+ for b in range(min(4, len(images))):
213
+ row_cvs.append(cv_of(out['svd']['M'][b]))
214
 
215
  if test_S is None:
216
  test_S = out['svd']['S'].mean(0).cpu()
 
219
  nb += 1
220
 
221
  test_erank /= nb
 
222
  test_S /= nb
223
  ratio = (test_S[0] / (test_S[-1] + 1e-8)).item()
224
+ mean_row_cv = sum(row_cvs) / len(row_cvs) if row_cvs else 0
225
+ delta_bc = abs(mean_row_cv - BINDING_CONSTANT)
226
 
227
+ print(f"{epoch:3d} | {total_loss/n:7.4f} {total_loss/n:7.4f} | "
 
228
  f"{test_recon/test_n:7.4f} | "
229
  f"{test_S[0]:6.3f} {test_S[-1]:6.3f} {ratio:6.2f} "
230
+ f"{test_erank:6.2f} | "
231
+ f"{mean_row_cv:7.4f} {delta_bc:7.4f}")
232
 
233
  # ── Final Analysis ──
234
  print()
 
238
 
239
  model.eval()
240
  all_S, all_recon_err, all_labels = [], [], []
241
+ all_row_cvs = []
242
 
243
  with torch.no_grad():
244
  for images, labels in test_loader:
 
250
  .mean(dim=(1, 2, 3)).cpu())
251
  all_labels.append(labels.cpu())
252
 
253
+ # Row CV for a sample of images
254
+ for b in range(min(8, len(images))):
255
+ all_row_cvs.append(cv_of(out['svd']['M'][b]))
256
+
257
  all_S = torch.cat(all_S)
258
  all_recon_err = torch.cat(all_recon_err)
259
  all_labels = torch.cat(all_labels)
260
 
261
  erank = model.effective_rank(all_S)
262
+ mean_row_cv = sum(all_row_cvs) / len(all_row_cvs)
263
 
264
+ print(f"\n Architecture: ({V}, {D}) β€” {V} rows Γ— D={D}")
265
  print(f" Recon MSE: {all_recon_err.mean():.6f} Β± {all_recon_err.std():.6f}")
266
  print(f" Effective rank: {erank.mean():.2f} Β± {erank.std():.2f}")
267
+ print(f"\n Row CV (matrix rows as D={D} embedding):")
268
+ print(f" Measured: {mean_row_cv:.4f}")
269
+ print(f" Target: {BINDING_CONSTANT}")
270
+ print(f" Delta: {abs(mean_row_cv - BINDING_CONSTANT):.4f}")
271
+ print(f" {'βœ“ AT BINDING CONSTANT' if abs(mean_row_cv - BINDING_CONSTANT) < 0.01 else 'βœ— Not at binding constant'}")
272
 
273
+ # Spectrum profile
274
  S_mean = all_S.mean(0)
275
  total_energy = (S_mean ** 2).sum()
276
  print(f"\n Singular value profile:")
 
280
  cumulative += e
281
  pct = cumulative / total_energy * 100
282
  bar = "β–ˆ" * int(S_mean[i].item() * 30 / (S_mean[0].item() + 1e-8))
283
+ print(f" S[{i:2d}]: {S_mean[i]:8.3f} cum={pct:5.1f}% {bar}")
284
 
285
+ # Per-class
286
  cifar_names = ['plane', 'car', 'bird', 'cat', 'deer',
287
  'dog', 'frog', 'horse', 'ship', 'truck']
288
  print(f"\n Per-class:")
289
+ print(f" {'class':>6} {'recon':>8} {'erank':>6} {'S0':>7} {'SD':>7} {'ratio':>6}")
 
290
  for c in range(10):
291
  mask = all_labels == c
292
  rc = all_recon_err[mask].mean().item()
293
  er = erank[mask].mean().item()
 
294
  s0 = all_S[mask, 0].mean().item()
295
+ sd = all_S[mask, -1].mean().item()
296
+ ratio = s0 / (sd + 1e-8)
297
+ print(f" {cifar_names[c]:>6} {rc:8.6f} {er:6.2f} {s0:7.3f} {sd:7.3f} {ratio:6.2f}")
 
298
 
299
  # Cross-class spectral variance
300
  class_S_means = torch.stack([all_S[all_labels == c].mean(0) for c in range(10)])
301
  s_var = class_S_means.std(0)
302
+ print(f"\n Cross-class S variance (top 5 most discriminative):")
303
+ _, top_idx = s_var.topk(5)
304
+ for idx in top_idx:
305
+ i = idx.item()
306
+ print(f" S[{i:2d}]: var={s_var[i]:.4f}")
307
 
308
+ # ── Reconstruction grid ──
309
  print(f"\n Saving reconstruction grid...")
310
  import matplotlib
311
  matplotlib.use('Agg')
 
330
  S = out['svd']['S'][selected_idx]
331
  Vt = out['svd']['Vt'][selected_idx]
332
 
333
+ mode_counts = [1, 4, 8, 16, D]
334
+ mode_counts = list(dict.fromkeys([m for m in mode_counts if m <= D]))
 
335
  prog_recons = []
336
  for n_modes in mode_counts:
337
  r = model.decode_from_svd(U[:, :, :n_modes], S[:, :n_modes], Vt[:, :n_modes, :])
 
343
  n_samples = len(selected_idx)
344
  n_cols = 2 + len(mode_counts)
345
  fig, axes = plt.subplots(n_samples, n_cols, figsize=(n_cols * 1.5, n_samples * 1.5))
 
346
  col_titles = ['Original'] + [f'{m} mode{"s" if m > 1 else ""}' for m in mode_counts] + ['|Error|Γ—5']
347
 
348
  for i in range(n_samples):