superneuroabm 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.
@@ -0,0 +1,770 @@
1
+ """
2
+ Synthetic network generation for weak scaling tests.
3
+
4
+ This module provides functions to generate spiking neural networks that are
5
+ optimized for METIS partitioning, ensuring:
6
+ 1. Balanced agent distribution across workers
7
+ 2. Minimal cross-worker communication
8
+ 3. Similar computational load per worker
9
+ """
10
+
11
+ import networkx as nx
12
+ import numpy as np
13
+ from typing import Optional, Dict, Tuple, List
14
+
15
+
16
+ def generate_clustered_network(
17
+ num_clusters: int,
18
+ neurons_per_cluster: int,
19
+ intra_cluster_prob: float = 0.3,
20
+ inter_cluster_prob: float = 0.01,
21
+ external_input_prob: float = 0.2,
22
+ soma_breed: str = "lif_soma",
23
+ soma_config: str = "config_0",
24
+ synapse_breed: str = "single_exp_synapse",
25
+ synapse_config: str = "no_learning_config_0",
26
+ excitatory_ratio: float = 0.8,
27
+ weight_exc: float = 14.0,
28
+ weight_inh: float = -10.0,
29
+ seed: Optional[int] = None
30
+ ) -> nx.DiGraph:
31
+ """
32
+ Generate a clustered spiking neural network optimized for METIS partitioning.
33
+
34
+ This creates multiple clusters with high intra-cluster connectivity and
35
+ low inter-cluster connectivity, which METIS can efficiently partition.
36
+
37
+ Args:
38
+ num_clusters: Number of clusters (should match number of workers)
39
+ neurons_per_cluster: Number of neurons in each cluster
40
+ intra_cluster_prob: Connection probability within cluster (default: 0.3)
41
+ inter_cluster_prob: Connection probability between clusters (default: 0.01)
42
+ external_input_prob: Probability of external input per neuron (default: 0.2)
43
+ soma_breed: Neuron type ("lif_soma" or "izh_soma")
44
+ soma_config: Configuration name for somas
45
+ synapse_breed: Synapse type
46
+ synapse_config: Configuration name for synapses
47
+ excitatory_ratio: Ratio of excitatory neurons (default: 0.8)
48
+ weight_exc: Weight for excitatory synapses (default: 14.0)
49
+ weight_inh: Weight for inhibitory synapses (default: -10.0)
50
+ seed: Random seed for reproducibility
51
+
52
+ Returns:
53
+ NetworkX DiGraph with neuron and synapse attributes
54
+
55
+ Example:
56
+ >>> # Create network for 4 workers with 1000 neurons each
57
+ >>> graph = generate_clustered_network(
58
+ ... num_clusters=4,
59
+ ... neurons_per_cluster=1000,
60
+ ... seed=42
61
+ ... )
62
+ >>> # Total neurons: 4000, optimized for 4 workers
63
+ """
64
+ if seed is not None:
65
+ np.random.seed(seed)
66
+
67
+ graph = nx.DiGraph()
68
+ total_neurons = num_clusters * neurons_per_cluster
69
+
70
+ print(f"[SyntheticNet] Generating clustered network:")
71
+ print(f" - Clusters: {num_clusters}")
72
+ print(f" - Neurons per cluster: {neurons_per_cluster}")
73
+ print(f" - Total neurons: {total_neurons}")
74
+ print(f" - Intra-cluster p: {intra_cluster_prob}")
75
+ print(f" - Inter-cluster p: {inter_cluster_prob}")
76
+
77
+ # Create neurons organized by cluster
78
+ neuron_ids = []
79
+ for cluster_id in range(num_clusters):
80
+ cluster_neurons = []
81
+ for i in range(neurons_per_cluster):
82
+ neuron_id = cluster_id * neurons_per_cluster + i
83
+
84
+ # Determine if excitatory or inhibitory
85
+ is_excitatory = i < int(neurons_per_cluster * excitatory_ratio)
86
+
87
+ # Add neuron node
88
+ graph.add_node(
89
+ neuron_id,
90
+ soma_breed=soma_breed,
91
+ config=soma_config,
92
+ cluster=cluster_id, # Store cluster ID for analysis
93
+ type="excitatory" if is_excitatory else "inhibitory",
94
+ tags=[f"cluster_{cluster_id}"]
95
+ )
96
+ cluster_neurons.append(neuron_id)
97
+
98
+ neuron_ids.append(cluster_neurons)
99
+
100
+ # Add intra-cluster connections
101
+ intra_edges = 0
102
+ for cluster_id, cluster_neurons in enumerate(neuron_ids):
103
+ for pre in cluster_neurons:
104
+ for post in cluster_neurons:
105
+ if pre != post and np.random.random() < intra_cluster_prob:
106
+ # Get neuron types
107
+ pre_type = graph.nodes[pre]["type"]
108
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
109
+
110
+ graph.add_edge(
111
+ pre,
112
+ post,
113
+ synapse_breed=synapse_breed,
114
+ config=synapse_config,
115
+ overrides={"hyperparameters": {"weight": weight}},
116
+ connection_type="intra_cluster"
117
+ )
118
+ intra_edges += 1
119
+
120
+ # Add inter-cluster connections
121
+ inter_edges = 0
122
+ for cluster_i in range(num_clusters):
123
+ for cluster_j in range(num_clusters):
124
+ if cluster_i != cluster_j:
125
+ for pre in neuron_ids[cluster_i]:
126
+ for post in neuron_ids[cluster_j]:
127
+ if np.random.random() < inter_cluster_prob:
128
+ pre_type = graph.nodes[pre]["type"]
129
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
130
+
131
+ graph.add_edge(
132
+ pre,
133
+ post,
134
+ synapse_breed=synapse_breed,
135
+ config=synapse_config,
136
+ overrides={"hyperparameters": {"weight": weight}},
137
+ connection_type="inter_cluster"
138
+ )
139
+ inter_edges += 1
140
+
141
+ # Add external inputs
142
+ external_inputs = 0
143
+ for cluster_id, cluster_neurons in enumerate(neuron_ids):
144
+ for post in cluster_neurons:
145
+ if np.random.random() < external_input_prob:
146
+ graph.add_edge(
147
+ -1, # External input
148
+ post,
149
+ synapse_breed=synapse_breed,
150
+ config=synapse_config,
151
+ overrides={"hyperparameters": {"weight": weight_exc}},
152
+ connection_type="external",
153
+ tags=["input_synapse"],
154
+ cluster=cluster_id # Assign to post-synaptic neuron's cluster
155
+ )
156
+ external_inputs += 1
157
+
158
+ # Print statistics
159
+ total_edges = intra_edges + inter_edges
160
+ theoretical_edge_cut = inter_edges / total_edges if total_edges > 0 else 0
161
+
162
+ print(f"[SyntheticNet] Network statistics:")
163
+ print(f" - Total edges: {total_edges}")
164
+ print(f" - Intra-cluster edges: {intra_edges} ({100*intra_edges/total_edges:.1f}%)")
165
+ print(f" - Inter-cluster edges: {inter_edges} ({100*inter_edges/total_edges:.1f}%)")
166
+ print(f" - External inputs: {external_inputs}")
167
+ print(f" - Theoretical edge cut (with perfect partition): {theoretical_edge_cut:.4f}")
168
+
169
+ return graph
170
+
171
+
172
+ def generate_clustered_network_constant_comm(
173
+ num_clusters: int,
174
+ neurons_per_cluster: int,
175
+ intra_cluster_prob: Optional[float] = None,
176
+ intra_cluster_degree: Optional[int] = None,
177
+ cross_cluster_edges: int = 2000,
178
+ num_neighbor_clusters: Optional[int] = None,
179
+ external_input_prob: float = 0.2,
180
+ soma_breed: str = "lif_soma",
181
+ soma_config: str = "config_0",
182
+ synapse_breed: str = "single_exp_synapse",
183
+ synapse_config: str = "no_learning_config_0",
184
+ excitatory_ratio: float = 0.8,
185
+ weight_exc: float = 14.0,
186
+ weight_inh: float = -10.0,
187
+ seed: Optional[int] = None
188
+ ) -> nx.DiGraph:
189
+ """
190
+ Generate a clustered network for PROPER WEAK SCALING with constant per-worker work.
191
+
192
+ This function creates networks suitable for weak scaling tests where:
193
+ 1. Per-worker workload remains constant as workers scale (linear scaling)
194
+ 2. Per-worker communication remains constant (truly constant communication)
195
+ 3. Per-worker contextualization overhead remains constant
196
+
197
+ IMPORTANT: For proper weak scaling, use intra_cluster_degree (NOT intra_cluster_prob):
198
+ - intra_cluster_degree: Each neuron connects to a FIXED number of neurons (O(n) edges per worker)
199
+ - intra_cluster_prob: Each neuron connects with probability p (O(n²) edges per worker - NOT weak scaling!)
200
+
201
+ CRITICAL: Use num_neighbor_clusters to control contextualization overhead:
202
+ - If None (default), each cluster connects to ALL other clusters (NOT TRUE weak scaling!)
203
+ - If specified, each cluster forms BIDIRECTIONAL pairs with K neighbors
204
+ - Recommended: Set to 1 for minimal constant cross-cluster communication
205
+
206
+ Args:
207
+ num_clusters: Number of clusters (should match number of workers)
208
+ neurons_per_cluster: Number of neurons in each cluster
209
+ intra_cluster_prob: Connection probability within cluster (creates O(n²) edges - NOT for weak scaling!)
210
+ intra_cluster_degree: Average degree per neuron (creates O(n) edges - PROPER weak scaling!)
211
+ cross_cluster_edges: Edges in EACH direction for bidirectional pairs (default: 2000)
212
+ num_neighbor_clusters: Number of bidirectional neighbor pairs (None = all neighbors)
213
+ external_input_prob: Probability of external input per neuron (default: 0.2)
214
+ soma_breed: Neuron type ("lif_soma" or "izh_soma")
215
+ soma_config: Configuration name for somas
216
+ synapse_breed: Synapse type
217
+ synapse_config: Configuration name for synapses
218
+ excitatory_ratio: Ratio of excitatory neurons (default: 0.8)
219
+ weight_exc: Weight for excitatory synapses (default: 14.0)
220
+ weight_inh: Weight for inhibitory synapses (default: -10.0)
221
+ seed: Random seed for reproducibility
222
+
223
+ Returns:
224
+ NetworkX DiGraph with neuron and synapse attributes
225
+
226
+ Example (PROPER weak scaling):
227
+ >>> # Create network with constant degree (linear edge count)
228
+ >>> graph = generate_clustered_network_constant_comm(
229
+ ... num_clusters=4,
230
+ ... neurons_per_cluster=10000,
231
+ ... intra_cluster_degree=10, # Each neuron connects to 10 others
232
+ ... cross_cluster_edges=2000,
233
+ ... seed=42
234
+ ... )
235
+ >>> # Per worker: 10,000 neurons × 10 degree = 100,000 edges (linear!)
236
+ """
237
+ # Validate parameters
238
+ if intra_cluster_prob is None and intra_cluster_degree is None:
239
+ raise ValueError("Must specify either intra_cluster_prob or intra_cluster_degree")
240
+ if intra_cluster_prob is not None and intra_cluster_degree is not None:
241
+ raise ValueError("Cannot specify both intra_cluster_prob and intra_cluster_degree")
242
+
243
+ # Set default for num_neighbor_clusters if not specified
244
+ if num_neighbor_clusters is None:
245
+ # Default: connect to all other clusters (backward compatibility but NOT true weak scaling)
246
+ num_neighbor_clusters = num_clusters - 1 if num_clusters > 1 else 0
247
+ print(f"[Warning] num_neighbor_clusters not specified, using all-to-all ({num_neighbor_clusters} neighbors). For TRUE weak scaling, set num_neighbor_clusters=1.")
248
+ else:
249
+ # Validate num_neighbor_clusters
250
+ max_neighbors = num_clusters - 1
251
+ if num_neighbor_clusters > max_neighbors:
252
+ print(f"[Warning] num_neighbor_clusters ({num_neighbor_clusters}) > max possible ({max_neighbors}). Using {max_neighbors}.")
253
+ num_neighbor_clusters = max_neighbors
254
+
255
+ if seed is not None:
256
+ np.random.seed(seed)
257
+
258
+ graph = nx.DiGraph()
259
+ total_neurons = num_clusters * neurons_per_cluster
260
+
261
+ print(f"[SyntheticNet] Generating clustered network for WEAK SCALING:")
262
+ print(f" - Clusters: {num_clusters}")
263
+ print(f" - Neurons per cluster: {neurons_per_cluster}")
264
+ print(f" - Total neurons: {total_neurons}")
265
+
266
+ if intra_cluster_degree is not None:
267
+ print(f" - Intra-cluster degree: {intra_cluster_degree} edges/neuron (O(n) edges - PROPER weak scaling!)")
268
+ expected_intra_edges = num_clusters * neurons_per_cluster * intra_cluster_degree
269
+ print(f" - Expected intra-cluster edges: {expected_intra_edges:,}")
270
+ else:
271
+ print(f" - Intra-cluster prob: {intra_cluster_prob} (O(n²) edges - NOT proper weak scaling!)")
272
+ expected_intra_edges = int(num_clusters * neurons_per_cluster * (neurons_per_cluster - 1) * intra_cluster_prob)
273
+ print(f" - Expected intra-cluster edges: {expected_intra_edges:,}")
274
+
275
+ print(f" - Neighbor clusters per worker: {num_neighbor_clusters} (DIRECTED RING)")
276
+ print(f" - Cross-cluster edges per neighbor: {cross_cluster_edges}")
277
+ # With directed ring, each cluster sends cross_cluster_edges to K neighbors
278
+ total_cross_edges_per_worker = cross_cluster_edges * num_neighbor_clusters if num_clusters > 1 else 0
279
+ print(f" - Total cross-cluster edges per worker (outgoing): {total_cross_edges_per_worker} (constant!)")
280
+ if num_clusters > 1:
281
+ print(f" - Each worker also receives {total_cross_edges_per_worker} edges from {num_neighbor_clusters} sender(s)")
282
+
283
+ # Create neurons organized by cluster
284
+ neuron_ids = []
285
+ for cluster_id in range(num_clusters):
286
+ cluster_neurons = []
287
+ for i in range(neurons_per_cluster):
288
+ neuron_id = cluster_id * neurons_per_cluster + i
289
+
290
+ # Determine if excitatory or inhibitory
291
+ is_excitatory = i < int(neurons_per_cluster * excitatory_ratio)
292
+
293
+ # Add neuron node
294
+ graph.add_node(
295
+ neuron_id,
296
+ soma_breed=soma_breed,
297
+ config=soma_config,
298
+ cluster=cluster_id,
299
+ type="excitatory" if is_excitatory else "inhibitory",
300
+ tags=[f"cluster_{cluster_id}"]
301
+ )
302
+ cluster_neurons.append(neuron_id)
303
+
304
+ neuron_ids.append(cluster_neurons)
305
+
306
+ # Add intra-cluster connections
307
+ intra_edges = 0
308
+ for cluster_id, cluster_neurons in enumerate(neuron_ids):
309
+ if intra_cluster_degree is not None:
310
+ # CONSTANT DEGREE approach (proper weak scaling!)
311
+ # Each neuron connects to exactly intra_cluster_degree random targets
312
+ for pre in cluster_neurons:
313
+ # Sample random targets (excluding self)
314
+ possible_targets = [n for n in cluster_neurons if n != pre]
315
+ num_targets = min(intra_cluster_degree, len(possible_targets))
316
+
317
+ if num_targets > 0:
318
+ targets = np.random.choice(possible_targets, size=num_targets, replace=False)
319
+
320
+ for post in targets:
321
+ pre_type = graph.nodes[pre]["type"]
322
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
323
+
324
+ graph.add_edge(
325
+ pre,
326
+ post,
327
+ synapse_breed=synapse_breed,
328
+ config=synapse_config,
329
+ overrides={"hyperparameters": {"weight": weight}},
330
+ connection_type="intra_cluster",
331
+ cluster=cluster_id
332
+ )
333
+ intra_edges += 1
334
+ else:
335
+ # PROBABILITY approach (creates O(n²) edges - NOT proper weak scaling!)
336
+ for pre in cluster_neurons:
337
+ for post in cluster_neurons:
338
+ if pre != post and np.random.random() < intra_cluster_prob:
339
+ pre_type = graph.nodes[pre]["type"]
340
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
341
+
342
+ graph.add_edge(
343
+ pre,
344
+ post,
345
+ synapse_breed=synapse_breed,
346
+ config=synapse_config,
347
+ overrides={"hyperparameters": {"weight": weight}},
348
+ connection_type="intra_cluster",
349
+ cluster=cluster_id
350
+ )
351
+ intra_edges += 1
352
+
353
+ # Add inter-cluster connections with CONSTANT per-worker communication
354
+ # Using BIDIRECTIONAL PAIRS topology: each cluster pairs with K neighbors bidirectionally
355
+ # This ensures each cluster has exactly K unique communication partners (not 2K)
356
+ inter_edges = 0
357
+ for cluster_i in range(num_clusters):
358
+ if num_clusters == 1 or num_neighbor_clusters == 0:
359
+ continue # No other clusters to connect to
360
+
361
+ # Select K neighbor clusters using DIRECTED RING topology
362
+ # Strategy: cluster i connects to next K clusters in ring (unidirectional)
363
+ # For K=1: 0→1→2→3→0 (directed ring, keeps network connected)
364
+ # Each cluster sends to K neighbors and receives from K neighbors
365
+ # With K=1: each cluster has 2 unique communication partners (send-to, receive-from)
366
+ # - 2 workers: same neighbor for send/receive → 1 unique partner
367
+ # - 4+ workers: different neighbors for send/receive → 2 unique partners (constant!)
368
+
369
+ target_clusters = []
370
+ for offset in range(1, num_neighbor_clusters + 1):
371
+ cluster_j = (cluster_i + offset) % num_clusters
372
+ target_clusters.append(cluster_j)
373
+
374
+ # Send cross_cluster_edges to each of the K target clusters
375
+ for cluster_j in target_clusters:
376
+ edges_to_add = cross_cluster_edges
377
+ max_possible = neurons_per_cluster * neurons_per_cluster
378
+ edges_to_add = min(edges_to_add, max_possible)
379
+
380
+ all_possible_edges = [
381
+ (pre, post)
382
+ for pre in neuron_ids[cluster_i]
383
+ for post in neuron_ids[cluster_j]
384
+ ]
385
+
386
+ if len(all_possible_edges) > 0:
387
+ selected_edges = np.random.choice(
388
+ len(all_possible_edges),
389
+ size=min(edges_to_add, len(all_possible_edges)),
390
+ replace=False
391
+ )
392
+
393
+ for edge_idx in selected_edges:
394
+ pre, post = all_possible_edges[edge_idx]
395
+ pre_type = graph.nodes[pre]["type"]
396
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
397
+
398
+ graph.add_edge(
399
+ pre,
400
+ post,
401
+ synapse_breed=synapse_breed,
402
+ config=synapse_config,
403
+ overrides={"hyperparameters": {"weight": weight}},
404
+ connection_type="inter_cluster",
405
+ cluster=cluster_i
406
+ )
407
+ inter_edges += 1
408
+
409
+ # Add external inputs
410
+ external_inputs = 0
411
+ for cluster_id, cluster_neurons in enumerate(neuron_ids):
412
+ for post in cluster_neurons:
413
+ if np.random.random() < external_input_prob:
414
+ graph.add_edge(
415
+ -1, # External input
416
+ post,
417
+ synapse_breed=synapse_breed,
418
+ config=synapse_config,
419
+ overrides={"hyperparameters": {"weight": weight_exc}},
420
+ connection_type="external",
421
+ tags=["input_synapse"],
422
+ cluster=cluster_id # Assign to post-synaptic neuron's cluster
423
+ )
424
+ external_inputs += 1
425
+
426
+ # Print statistics
427
+ total_edges = intra_edges + inter_edges
428
+ theoretical_edge_cut = inter_edges / total_edges if total_edges > 0 else 0
429
+
430
+ # Calculate total agent count (neurons + synapses)
431
+ total_neurons = num_clusters * neurons_per_cluster
432
+ total_synapses = total_edges + external_inputs # All edges become synapse agents
433
+ total_agents = total_neurons + total_synapses
434
+ agents_per_worker = total_agents / num_clusters if num_clusters > 0 else 0
435
+
436
+ print(f"[SyntheticNet] Network statistics:")
437
+ print(f" - Total neurons: {total_neurons:,}")
438
+ print(f" - Total edges: {total_edges:,}")
439
+ print(f" - Intra-cluster: {intra_edges:,} ({100*intra_edges/total_edges:.1f}%)")
440
+ print(f" - Inter-cluster: {inter_edges:,} ({100*inter_edges/total_edges:.1f}%)")
441
+ print(f" - External inputs: {external_inputs:,}")
442
+ print(f" - Theoretical edge cut (with perfect partition): {theoretical_edge_cut:.4f}")
443
+ print(f"\n[SyntheticNet] AGENT COUNT (for weak scaling verification):")
444
+ print(f" - Total agents: {total_agents:,} ({total_neurons:,} neurons + {total_synapses:,} synapses)")
445
+ print(f" - Agents per worker: {agents_per_worker:,.0f}")
446
+ print(f" - Scaling: {agents_per_worker:,.0f} agents/worker × {num_clusters} workers = {total_agents:,} total")
447
+
448
+ return graph
449
+
450
+
451
+ def generate_grid_network(
452
+ grid_size: Tuple[int, int],
453
+ connection_radius: int = 1,
454
+ connection_prob: float = 0.5,
455
+ external_input_prob: float = 0.1,
456
+ soma_breed: str = "lif_soma",
457
+ soma_config: str = "config_0",
458
+ synapse_breed: str = "single_exp_synapse",
459
+ synapse_config: str = "no_learning_config_0",
460
+ excitatory_ratio: float = 0.8,
461
+ weight_exc: float = 14.0,
462
+ weight_inh: float = -10.0,
463
+ seed: Optional[int] = None
464
+ ) -> nx.DiGraph:
465
+ """
466
+ Generate a grid-structured network with local connectivity.
467
+
468
+ This creates a 2D grid of neurons where each neuron connects primarily
469
+ to its neighbors within a given radius. This topology is naturally
470
+ partitionable and METIS-friendly.
471
+
472
+ Args:
473
+ grid_size: (rows, cols) dimensions of the grid
474
+ connection_radius: Euclidean distance for connectivity (default: 1)
475
+ connection_prob: Probability of connection within radius (default: 0.5)
476
+ external_input_prob: Probability of external input per neuron
477
+ soma_breed: Neuron type
478
+ soma_config: Configuration name for somas
479
+ synapse_breed: Synapse type
480
+ synapse_config: Configuration name for synapses
481
+ excitatory_ratio: Ratio of excitatory neurons
482
+ weight_exc: Weight for excitatory synapses
483
+ weight_inh: Weight for inhibitory synapses
484
+ seed: Random seed for reproducibility
485
+
486
+ Returns:
487
+ NetworkX DiGraph with grid structure
488
+
489
+ Example:
490
+ >>> # Create 100x100 grid (10,000 neurons)
491
+ >>> graph = generate_grid_network(
492
+ ... grid_size=(100, 100),
493
+ ... connection_radius=2,
494
+ ... seed=42
495
+ ... )
496
+ """
497
+ if seed is not None:
498
+ np.random.seed(seed)
499
+
500
+ rows, cols = grid_size
501
+ total_neurons = rows * cols
502
+ graph = nx.DiGraph()
503
+
504
+ print(f"[SyntheticNet] Generating grid network:")
505
+ print(f" - Grid size: {rows}x{cols} ({total_neurons} neurons)")
506
+ print(f" - Connection radius: {connection_radius}")
507
+ print(f" - Connection probability: {connection_prob}")
508
+
509
+ # Create neurons in grid layout
510
+ neuron_positions = {}
511
+ for i in range(rows):
512
+ for j in range(cols):
513
+ neuron_id = i * cols + j
514
+ is_excitatory = neuron_id < int(total_neurons * excitatory_ratio)
515
+
516
+ graph.add_node(
517
+ neuron_id,
518
+ soma_breed=soma_breed,
519
+ config=soma_config,
520
+ position=(i, j),
521
+ type="excitatory" if is_excitatory else "inhibitory"
522
+ )
523
+ neuron_positions[neuron_id] = (i, j)
524
+
525
+ # Add connections based on distance
526
+ edge_count = 0
527
+ for pre_id, (pre_i, pre_j) in neuron_positions.items():
528
+ for post_id, (post_i, post_j) in neuron_positions.items():
529
+ if pre_id != post_id:
530
+ # Calculate Euclidean distance
531
+ dist = np.sqrt((pre_i - post_i)**2 + (pre_j - post_j)**2)
532
+
533
+ if dist <= connection_radius and np.random.random() < connection_prob:
534
+ pre_type = graph.nodes[pre_id]["type"]
535
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
536
+
537
+ graph.add_edge(
538
+ pre_id,
539
+ post_id,
540
+ synapse_breed=synapse_breed,
541
+ config=synapse_config,
542
+ overrides={"hyperparameters": {"weight": weight}}
543
+ )
544
+ edge_count += 1
545
+
546
+ # Add external inputs
547
+ external_inputs = 0
548
+ for neuron_id in neuron_positions.keys():
549
+ if np.random.random() < external_input_prob:
550
+ graph.add_edge(
551
+ -1, # External input
552
+ neuron_id,
553
+ synapse_breed=synapse_breed,
554
+ config=synapse_config,
555
+ overrides={"hyperparameters": {"weight": weight_exc}},
556
+ tags=["input_synapse"]
557
+ )
558
+ external_inputs += 1
559
+
560
+ print(f"[SyntheticNet] Network statistics:")
561
+ print(f" - Total edges: {edge_count}")
562
+ print(f" - External inputs: {external_inputs}")
563
+ print(f" - Avg degree: {2*edge_count/total_neurons:.2f}")
564
+
565
+ return graph
566
+
567
+
568
+ def generate_ring_of_clusters(
569
+ num_clusters: int,
570
+ neurons_per_cluster: int,
571
+ intra_cluster_prob: float = 0.3,
572
+ adjacent_cluster_prob: float = 0.05,
573
+ external_input_prob: float = 0.2,
574
+ soma_breed: str = "lif_soma",
575
+ soma_config: str = "config_0",
576
+ synapse_breed: str = "single_exp_synapse",
577
+ synapse_config: str = "no_learning_config_0",
578
+ excitatory_ratio: float = 0.8,
579
+ weight_exc: float = 14.0,
580
+ weight_inh: float = -10.0,
581
+ seed: Optional[int] = None
582
+ ) -> nx.DiGraph:
583
+ """
584
+ Generate a ring of clusters network.
585
+
586
+ This creates clusters arranged in a ring topology, where each cluster
587
+ connects primarily to itself and its immediate neighbors in the ring.
588
+ This provides a balanced structure with predictable cross-cluster
589
+ communication patterns.
590
+
591
+ Args:
592
+ num_clusters: Number of clusters in the ring
593
+ neurons_per_cluster: Number of neurons per cluster
594
+ intra_cluster_prob: Connection probability within cluster
595
+ adjacent_cluster_prob: Connection probability to adjacent clusters
596
+ external_input_prob: Probability of external input per neuron
597
+ soma_breed: Neuron type
598
+ soma_config: Configuration name for somas
599
+ synapse_breed: Synapse type
600
+ synapse_config: Configuration name for synapses
601
+ excitatory_ratio: Ratio of excitatory neurons
602
+ weight_exc: Weight for excitatory synapses
603
+ weight_inh: Weight for inhibitory synapses
604
+ seed: Random seed for reproducibility
605
+
606
+ Returns:
607
+ NetworkX DiGraph with ring-of-clusters structure
608
+
609
+ Example:
610
+ >>> # Create ring of 8 clusters with 500 neurons each
611
+ >>> graph = generate_ring_of_clusters(
612
+ ... num_clusters=8,
613
+ ... neurons_per_cluster=500,
614
+ ... seed=42
615
+ ... )
616
+ """
617
+ if seed is not None:
618
+ np.random.seed(seed)
619
+
620
+ graph = nx.DiGraph()
621
+ total_neurons = num_clusters * neurons_per_cluster
622
+
623
+ print(f"[SyntheticNet] Generating ring-of-clusters network:")
624
+ print(f" - Clusters: {num_clusters}")
625
+ print(f" - Neurons per cluster: {neurons_per_cluster}")
626
+ print(f" - Total neurons: {total_neurons}")
627
+
628
+ # Create neurons organized by cluster
629
+ neuron_ids = []
630
+ for cluster_id in range(num_clusters):
631
+ cluster_neurons = []
632
+ for i in range(neurons_per_cluster):
633
+ neuron_id = cluster_id * neurons_per_cluster + i
634
+ is_excitatory = i < int(neurons_per_cluster * excitatory_ratio)
635
+
636
+ graph.add_node(
637
+ neuron_id,
638
+ soma_breed=soma_breed,
639
+ config=soma_config,
640
+ cluster=cluster_id,
641
+ type="excitatory" if is_excitatory else "inhibitory",
642
+ tags=[f"cluster_{cluster_id}"]
643
+ )
644
+ cluster_neurons.append(neuron_id)
645
+
646
+ neuron_ids.append(cluster_neurons)
647
+
648
+ # Add intra-cluster connections
649
+ intra_edges = 0
650
+ for cluster_neurons in neuron_ids:
651
+ for pre in cluster_neurons:
652
+ for post in cluster_neurons:
653
+ if pre != post and np.random.random() < intra_cluster_prob:
654
+ pre_type = graph.nodes[pre]["type"]
655
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
656
+
657
+ graph.add_edge(
658
+ pre, post,
659
+ synapse_breed=synapse_breed,
660
+ config=synapse_config,
661
+ overrides={"hyperparameters": {"weight": weight}}
662
+ )
663
+ intra_edges += 1
664
+
665
+ # Add connections to adjacent clusters in ring
666
+ inter_edges = 0
667
+ for cluster_id in range(num_clusters):
668
+ # Connect to next cluster in ring
669
+ next_cluster = (cluster_id + 1) % num_clusters
670
+
671
+ for pre in neuron_ids[cluster_id]:
672
+ for post in neuron_ids[next_cluster]:
673
+ if np.random.random() < adjacent_cluster_prob:
674
+ pre_type = graph.nodes[pre]["type"]
675
+ weight = weight_exc if pre_type == "excitatory" else weight_inh
676
+
677
+ graph.add_edge(
678
+ pre, post,
679
+ synapse_breed=synapse_breed,
680
+ config=synapse_config,
681
+ overrides={"hyperparameters": {"weight": weight}}
682
+ )
683
+ inter_edges += 1
684
+
685
+ # Add external inputs
686
+ external_inputs = 0
687
+ for cluster_neurons in neuron_ids:
688
+ for post in cluster_neurons:
689
+ if np.random.random() < external_input_prob:
690
+ graph.add_edge(
691
+ -1, # External input
692
+ post,
693
+ synapse_breed=synapse_breed,
694
+ config=synapse_config,
695
+ overrides={"hyperparameters": {"weight": weight_exc}},
696
+ tags=["input_synapse"]
697
+ )
698
+ external_inputs += 1
699
+
700
+ total_edges = intra_edges + inter_edges
701
+ print(f"[SyntheticNet] Network statistics:")
702
+ print(f" - Total edges: {total_edges}")
703
+ print(f" - Intra-cluster edges: {intra_edges}")
704
+ print(f" - Inter-cluster edges: {inter_edges}")
705
+ print(f" - External inputs: {external_inputs}")
706
+
707
+ return graph
708
+
709
+
710
+ def analyze_network_partition(
711
+ graph: nx.DiGraph,
712
+ partition_dict: Dict[int, int]
713
+ ) -> Dict[str, any]:
714
+ """
715
+ Analyze network partition quality.
716
+
717
+ Args:
718
+ graph: NetworkX graph
719
+ partition_dict: Mapping from node_id to worker_rank
720
+
721
+ Returns:
722
+ Dictionary with partition statistics
723
+ """
724
+ num_workers = max(partition_dict.values()) + 1
725
+
726
+ # Count nodes per worker
727
+ nodes_per_worker = [0] * num_workers
728
+ for rank in partition_dict.values():
729
+ nodes_per_worker[rank] += 1
730
+
731
+ # Count edges within and between workers
732
+ intra_worker_edges = 0
733
+ inter_worker_edges = 0
734
+ edges_per_worker = [0] * num_workers
735
+
736
+ for u, v in graph.edges():
737
+ if u in partition_dict and v in partition_dict:
738
+ u_rank = partition_dict[u]
739
+ v_rank = partition_dict[v]
740
+
741
+ if u_rank == v_rank:
742
+ intra_worker_edges += 1
743
+ edges_per_worker[u_rank] += 1
744
+ else:
745
+ inter_worker_edges += 1
746
+
747
+ total_edges = intra_worker_edges + inter_worker_edges
748
+ edge_cut_ratio = inter_worker_edges / total_edges if total_edges > 0 else 0
749
+
750
+ # Calculate balance metrics
751
+ avg_nodes = np.mean(nodes_per_worker)
752
+ std_nodes = np.std(nodes_per_worker)
753
+ avg_edges = np.mean(edges_per_worker)
754
+ std_edges = np.std(edges_per_worker)
755
+
756
+ return {
757
+ "num_workers": num_workers,
758
+ "nodes_per_worker": nodes_per_worker,
759
+ "avg_nodes": avg_nodes,
760
+ "std_nodes": std_nodes,
761
+ "node_imbalance": std_nodes / avg_nodes if avg_nodes > 0 else 0,
762
+ "edges_per_worker": edges_per_worker,
763
+ "avg_edges": avg_edges,
764
+ "std_edges": std_edges,
765
+ "edge_imbalance": std_edges / avg_edges if avg_edges > 0 else 0,
766
+ "total_edges": total_edges,
767
+ "intra_worker_edges": intra_worker_edges,
768
+ "inter_worker_edges": inter_worker_edges,
769
+ "edge_cut_ratio": edge_cut_ratio
770
+ }