Tutorial 3: Network Topologies#

This tutorial explores complex network topologies in braintools.conn. You’ll learn how to create biologically-plausible network architectures with specific graph properties.


1. Introduction to Network Topologies #

Network topology refers to the global structure of connections in a network, beyond local connectivity rules.

Why Network Topology Matters#

  • Computational Efficiency: Topology affects information processing speed and capacity

  • Robustness: Network resilience to damage depends on topology

  • Biological Realism: Real brains exhibit specific topological features (small-world, modularity)

  • Functional Specialization: Modular structures support specialized processing

Common Network Properties#

  • Clustering Coefficient: Local connectivity density

  • Path Length: Average distance between neurons

  • Degree Distribution: How connections are distributed

  • Modularity: Presence of distinct communities

Let’s start by importing the necessary libraries:

import brainunit as u
import matplotlib.pyplot as plt
import numpy as np

from braintools import conn, visualize as vis
from braintools.init import Constant, Normal

# Set random seed for reproducibility
np.random.seed(42)

# Configure matplotlib
plt.rcParams['figure.figsize'] = (12, 4)
plt.rcParams['font.size'] = 10

print("✓ Imports successful")
✓ Imports successful

2. Small-World Networks #

Small-world networks combine high local clustering with short global path lengths, a hallmark of brain connectivity.

2.1 The Watts-Strogatz Model#

The Watts-Strogatz model creates small-world networks by:

  1. Starting with a regular ring lattice (high clustering, long paths)

  2. Rewiring edges with probability p (introducing shortcuts)

Parameter p controls the transition:

  • p = 0: Regular lattice

  • 0 < p < 1: Small-world

  • p = 1: Random graph

# Create small-world network
n_neurons = 500

small_world = conn.SmallWorld(
    k=10,  # Each neuron connects to 10 neighbors (5 on each side)
    p=0.1,  # 10% rewiring probability
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_sw = small_world(pre_size=n_neurons, post_size=n_neurons)

print("Small-World Network (Watts-Strogatz):")
print("=" * 50)
print(f"Neurons: {n_neurons}")
print(f"Neighbors (k): 10")
print(f"Rewiring probability (p): 0.1")
print(f"Total connections: {len(result_sw.pre_indices)}")
print(f"Expected: {n_neurons * 10} (n × k)")
print(f"Connections per neuron: {len(result_sw.pre_indices) / n_neurons:.1f}")
Small-World Network (Watts-Strogatz):
==================================================
Neurons: 500
Neighbors (k): 10
Rewiring probability (p): 0.1
Total connections: 5000
Expected: 5000 (n × k)
Connections per neuron: 10.0

2.2 Effect of Rewiring Probability#

Let’s compare networks with different rewiring probabilities:

# Create networks with varying rewiring probability
rewiring_probs = [0.0, 0.05, 0.1, 0.3, 0.5, 1.0]
n_test = 200

results_by_p = {}
for p in rewiring_probs:
    sw = conn.SmallWorld(k=6, p=p, seed=42)
    result = sw(pre_size=n_test, post_size=n_test)
    results_by_p[p] = result

print("Rewiring Probability Effects:")
print("=" * 60)
print(f"{'p':<10} {'Connections':<15} {'Description'}")
print("=" * 60)
for p in rewiring_probs:
    result = results_by_p[p]
    desc = "Regular lattice" if p == 0 else ("Random graph" if p == 1.0 else "Small-world")
    print(f"{p:<10.2f} {len(result.pre_indices):<15} {desc}")
Rewiring Probability Effects:
============================================================
p          Connections     Description
============================================================
0.00       1200            Regular lattice
0.05       1200            Small-world
0.10       1200            Small-world
0.30       1200            Small-world
0.50       1200            Small-world
1.00       1200            Random graph

2.3 Degree Distribution Analysis#

Small-world networks maintain relatively uniform degree distribution:

# Calculate degree distributions for different p values
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()

for idx, p in enumerate(rewiring_probs):
    result = results_by_p[p]
    in_degree = np.bincount(result.post_indices, minlength=n_test)

    title = f"p = {p:.2f} ({['Regular', 'Small-world', 'Small-world', 'Small-world', 'Small-world', 'Random'][idx]})"

    vis.distribution_plot(
        in_degree,
        bins=15,
        alpha=0.7,
        colors=['steelblue'],
        edgecolor='black',
        ax=axes[idx],
        xlabel='In-Degree',
        ylabel='Count',
        title=title
    )

plt.suptitle('Degree Distributions Across Rewiring Probabilities', fontsize=14, y=1.00)
plt.tight_layout()
plt.show()

print("\nKey Observations:")
print("- p=0.0 (Regular): All neurons have exactly k=6 connections")
print("- 0<p<1 (Small-world): Narrow distribution around k=6")
print("- p=1.0 (Random): Still centered on k=6 but with more variance")
../_images/47a4833ac415cc8157cd9ca4e98f7d87fb601c26f39dc4644e97c5d219e44381.png
Key Observations:
- p=0.0 (Regular): All neurons have exactly k=6 connections
- 0<p<1 (Small-world): Narrow distribution around k=6
- p=1.0 (Random): Still centered on k=6 but with more variance

3. Scale-Free Networks #

Scale-free networks have degree distributions following a power law, with a few highly connected “hub” neurons.

3.1 Barabási-Albert Model#

The Barabási-Albert model creates scale-free networks through preferential attachment:

# Create scale-free network
scale_free = conn.ScaleFree(
    m=3,  # Each new neuron connects to 3 existing neurons
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_sf = scale_free(pre_size=n_neurons, post_size=n_neurons)

print("Scale-Free Network (Barabási-Albert):")
print("=" * 50)
print(f"Neurons: {n_neurons}")
print(f"Edges per new neuron (m): 3")
print(f"Total connections: {len(result_sf.pre_indices)}")
print(f"Average degree: {len(result_sf.pre_indices) / n_neurons:.1f}")

# Analyze hub neurons
in_degree_sf = np.bincount(result_sf.post_indices, minlength=n_neurons)
out_degree_sf = np.bincount(result_sf.pre_indices, minlength=n_neurons)
total_degree_sf = in_degree_sf + out_degree_sf

print(f"\nHub Analysis:")
print(f"  Max degree: {np.max(total_degree_sf)}")
print(f"  Min degree: {np.min(total_degree_sf[total_degree_sf > 0])}")
print(f"  Top 5 hubs (degree): {sorted(total_degree_sf, reverse=True)[:5]}")
Scale-Free Network (Barabási-Albert):
==================================================
Neurons: 500
Edges per new neuron (m): 3
Total connections: 2988
Average degree: 6.0

Hub Analysis:
  Max degree: 188
  Min degree: 6
  Top 5 hubs (degree): [np.int64(188), np.int64(148), np.int64(130), np.int64(108), np.int64(64)]

3.2 Scale-Free vs. Small-World Degree Distributions#

Compare the degree distributions:

# Calculate degrees
in_degree_sw = np.bincount(result_sw.post_indices, minlength=n_neurons)

# Plot comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

vis.distribution_plot(
    in_degree_sw,
    bins=20,
    alpha=0.7,
    colors=['steelblue'],
    edgecolor='black',
    ax=axes[0],
    xlabel='In-Degree',
    ylabel='Count',
    title='Small-World Network'
)

vis.distribution_plot(
    in_degree_sf,
    bins=20,
    alpha=0.7,
    colors=['coral'],
    edgecolor='black',
    ax=axes[1],
    xlabel='In-Degree',
    ylabel='Count',
    title='Scale-Free Network'
)

plt.tight_layout()
plt.show()

print("\nKey Differences:")
print("- Small-World: Narrow, bell-shaped distribution (homogeneous connectivity)")
print("- Scale-Free: Heavy-tailed distribution (heterogeneous, with hubs)")
print(f"\nCoefficient of Variation (std/mean):")
print(f"  Small-World: {np.std(in_degree_sw) / np.mean(in_degree_sw):.3f}")
print(f"  Scale-Free: {np.std(in_degree_sf) / np.mean(in_degree_sf):.3f}")
../_images/b142383163b2601fd43378bd05e69c4605e8f0f12563ca5fbe353645a8ce9978.png
Key Differences:
- Small-World: Narrow, bell-shaped distribution (homogeneous connectivity)
- Scale-Free: Heavy-tailed distribution (heterogeneous, with hubs)

Coefficient of Variation (std/mean):
  Small-World: 0.137
  Scale-Free: 1.208

3.3 Log-Log Plot for Power Law#

Scale-free networks exhibit power-law degree distributions:

# Calculate degree distribution
unique_degrees, degree_counts = np.unique(in_degree_sf, return_counts=True)

# Remove zero degree
nonzero_mask = unique_degrees > 0
unique_degrees = unique_degrees[nonzero_mask]
degree_counts = degree_counts[nonzero_mask]

# Plot log-log
fig, ax = plt.subplots(figsize=(10, 6))
ax.loglog(unique_degrees, degree_counts, 'o', markersize=8, alpha=0.7, color='coral')
ax.set_xlabel('Degree (k)', fontsize=12)
ax.set_ylabel('Count P(k)', fontsize=12)
ax.set_title('Scale-Free Network: Log-Log Degree Distribution', fontsize=14)
ax.grid(True, alpha=0.3, which='both')

# Add power law reference line
x_fit = np.array([unique_degrees.min(), 20])
y_fit = 1000 * x_fit ** (-2.)  # Power law with exponent -2
ax.loglog(x_fit, y_fit, '--', color='gray', linewidth=2, alpha=0.5, label='Power law (γ=-2)')
ax.legend()

plt.tight_layout()
plt.show()

print("\nIn a scale-free network, degree distribution follows P(k) ~ k^(-γ)")
print("The log-log plot shows an approximately linear relationship.")
../_images/20064e5bc4a1ede55ed7ba749b5446d4955a7ff184b5578486e888b232321547.png
In a scale-free network, degree distribution follows P(k) ~ k^(-γ)
The log-log plot shows an approximately linear relationship.

4. Regular Networks #

Regular networks have deterministic, structured connectivity where all neurons have the same degree.

4.1 k-Regular Networks#

# Create regular network
regular = conn.Regular(
    degree=8,  # Each neuron has exactly 8 connections
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_regular = regular(pre_size=200, post_size=200)

print("Regular Network:")
print("=" * 50)
print(f"Neurons: 200")
print(f"Degree (k): 8")
print(f"Total connections: {len(result_regular.pre_indices)}")
print(f"Expected: {200 * 8} (n × k)")

# Verify regularity
in_degree_reg = np.bincount(result_regular.post_indices, minlength=200)
print(f"\nRegularity check:")
print(f"  All in-degrees equal to {8}: {np.all(in_degree_reg == 8)}")
print(f"  Unique in-degrees: {np.unique(in_degree_reg)}")
Regular Network:
==================================================
Neurons: 200
Degree (k): 8
Total connections: 1600
Expected: 1600 (n × k)

Regularity check:
  All in-degrees equal to 8: False
  Unique in-degrees: [ 2  3  4  5  6  7  8  9 10 11 12 13 14 15]

4.2 Comparing Regular, Small-World, and Random#

Visualize the structural differences:

# Create small test networks for visualization
n_viz = 40

regular_viz = conn.Regular(degree=4, seed=42)(pre_size=n_viz, post_size=n_viz)
sw_viz = conn.SmallWorld(k=4, p=0.1, seed=42)(pre_size=n_viz, post_size=n_viz)
random_viz = conn.Random(prob=0.1, seed=42)(pre_size=n_viz, post_size=n_viz)


# Create connectivity matrices
def result_to_matrix(result, size):
    matrix = np.zeros((size, size))
    matrix[result.pre_indices, result.post_indices] = 1
    return matrix


matrix_regular = result_to_matrix(regular_viz, n_viz)
matrix_sw = result_to_matrix(sw_viz, n_viz)
matrix_random = result_to_matrix(random_viz, n_viz)

# Plot matrices
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

vis.connectivity_matrix(matrix_regular, cmap='binary', center_zero=False,
                        show_colorbar=False, ax=axes[0], title='Regular (k=4)')
vis.connectivity_matrix(matrix_sw, cmap='binary', center_zero=False,
                        show_colorbar=False, ax=axes[1], title='Small-World (k=4, p=0.1)')
vis.connectivity_matrix(matrix_random, cmap='binary', center_zero=False,
                        show_colorbar=False, ax=axes[2], title='Random (p=0.1)')

plt.tight_layout()
plt.show()

print("\nMatrix Structures:")
print("- Regular: Clear banded structure (local connections)")
print("- Small-World: Bands with scattered long-range connections")
print("- Random: Uniform random distribution")
../_images/b3d3d7b82de4e699ebd92e5cfa7e06fa6be4c57936567c0b8577de58e927fda9.png
Matrix Structures:
- Regular: Clear banded structure (local connections)
- Small-World: Bands with scattered long-range connections
- Random: Uniform random distribution

5. Modular Networks #

Modular networks have distinct communities with dense intra-module and sparse inter-module connections.

5.1 Modular Random Networks#

Create modules with different connection probabilities:

# Create modular network with 4 modules
modular = conn.ModularRandom(
    n_modules=4,  # 4 distinct modules
    intra_prob=0.3,  # 30% connectivity within modules
    inter_prob=0.02,  # 2% connectivity between modules
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_modular = modular(pre_size=400, post_size=400)

print("Modular Random Network:")
print("=" * 50)
print(f"Neurons: 400")
print(f"Modules: 4 (100 neurons each)")
print(f"Intra-module probability: 30%")
print(f"Inter-module probability: 2%")
print(f"Total connections: {len(result_modular.pre_indices)}")
print(f"Average degree: {len(result_modular.pre_indices) / 400:.1f}")
Modular Random Network:
==================================================
Neurons: 400
Modules: 4 (100 neurons each)
Intra-module probability: 30%
Inter-module probability: 2%
Total connections: 14189
Average degree: 35.5

5.2 Custom Module Configurations#

Use ModularGeneral for flexible module sizes and connection probabilities:

# Create custom modular network with different module sizes
module_sizes = [80, 120, 100, 100]  # Unequal module sizes

# Connection probability matrix (4×4 for 4 modules)
prob_matrix = np.array([
    [0.4, 0.05, 0.02, 0.01],  # Module 0: strongly connected internally
    [0.05, 0.3, 0.1, 0.02],  # Module 1: moderate internal, some connection to 2
    [0.02, 0.1, 0.35, 0.05],  # Module 2: moderate internal
    [0.01, 0.02, 0.05, 0.25]  # Module 3: weaker internal connectivity
])

modular_general = conn.ModularGeneral(
    intra_conn=[conn.Random(0.4), conn.Random(0.3), conn.Random(0.35), conn.Random(0.25)],
    inter_conn_pair={
        (0, 1): conn.Random(0.05),
        (0, 2): conn.Random(0.02),
        (0, 3): conn.Random(0.01),
        (1, 2): conn.Random(0.1),
        (1, 3): conn.Random(0.02),
        (2, 3): conn.Random(0.05),
        # bi
        (1, 0): conn.Random(0.05),
        (2, 0): conn.Random(0.02),
        (3, 0): conn.Random(0.01),
        (2, 1): conn.Random(0.1),
        (3, 1): conn.Random(0.02),
        (3, 2): conn.Random(0.05),
    },
)

result_modular_gen = modular_general(pre_size=400, post_size=400)

print("Custom Modular Network (ModularGeneral):")
print("=" * 50)
print(f"Neurons: 400")
print(f"Module sizes: {module_sizes}")
print(f"Total connections: {len(result_modular_gen.pre_indices)}")
print(f"\nConnection probability matrix:")
print(prob_matrix)
Custom Modular Network (ModularGeneral):
==================================================
Neurons: 400
Module sizes: [80, 120, 100, 100]
Total connections: 17810

Connection probability matrix:
[[0.4  0.05 0.02 0.01]
 [0.05 0.3  0.1  0.02]
 [0.02 0.1  0.35 0.05]
 [0.01 0.02 0.05 0.25]]

5.3 Visualizing Modular Structure#

The block-diagonal structure reveals modularity:

# Create smaller modular network for visualization
modular_viz = conn.ModularRandom(
    n_modules=4,
    intra_prob=0.4,
    inter_prob=0.05,
    seed=42
)(pre_size=120, post_size=120)

matrix_modular = result_to_matrix(modular_viz, 120)

# Plot
fig, ax = plt.subplots(figsize=(10, 9))
vis.connectivity_matrix(
    matrix_modular,
    cmap='binary',
    center_zero=False,
    show_colorbar=False,
    ax=ax,
    title='Modular Network Structure (4 modules of 30 neurons each)'
)

# Add module boundary lines
module_size = 30
for i in range(1, 4):
    boundary = i * module_size
    ax.axhline(boundary, color='red', linewidth=2, alpha=0.6)
    ax.axvline(boundary, color='red', linewidth=2, alpha=0.6)

plt.tight_layout()
plt.show()

print("\nRed lines mark module boundaries.")
print("Dense blocks on diagonal = intra-module connections.")
print("Sparse off-diagonal = inter-module connections.")
../_images/571a5909e70651bf5373381685cde770f6afd0e36436925e2e712440a0a39f05.png
Red lines mark module boundaries.
Dense blocks on diagonal = intra-module connections.
Sparse off-diagonal = inter-module connections.

6. Hierarchical Networks #

Hierarchical networks organize neurons into levels with feedforward and feedback connections.

6.1 Creating Hierarchical Structures#

# Create hierarchical network with 3 levels
hierarchical = conn.HierarchicalRandom(
    n_levels=3,  # 3 hierarchical levels
    feedforward_prob=0.3,  # Probability of feedforward connections
    feedback_prob=0.1,  # Probability of feedback connections
    recurrent_prob=0.2,  # Probability of lateral connections within level
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_hierarchical = hierarchical(pre_size=300, post_size=300)

print("Hierarchical Network:")
print("=" * 50)
print(f"Neurons: 300 (100 per level)")
print(f"Levels: 3")
print(f"Forward probability: 30%")
print(f"Backward probability: 10%")
print(f"Lateral probability: 20%")
print(f"Total connections: {len(result_hierarchical.pre_indices)}")
Hierarchical Network:
==================================================
Neurons: 300 (100 per level)
Levels: 3
Forward probability: 30%
Backward probability: 10%
Lateral probability: 20%
Total connections: 13968

6.2 Visualizing Hierarchical Organization#

Hierarchical structure shows as off-diagonal bands:

# Create smaller hierarchical network for visualization
hierarchical_viz = conn.HierarchicalRandom(
    n_levels=4,
    feedforward_prob=0.4,
    feedback_prob=0.15,
    recurrent_prob=0.3,
    seed=42
)(pre_size=120, post_size=120)

matrix_hierarchical = result_to_matrix(hierarchical_viz, 120)

# Plot
fig, ax = plt.subplots(figsize=(10, 9))
vis.connectivity_matrix(
    matrix_hierarchical,
    cmap='binary',
    center_zero=False,
    show_colorbar=False,
    ax=ax,
    title='Hierarchical Network (4 levels of 30 neurons each)'
)

# Add level boundary lines
level_size = 30
for i in range(1, 4):
    boundary = i * level_size
    ax.axhline(boundary, color='blue', linewidth=2, alpha=0.6)
    ax.axvline(boundary, color='blue', linewidth=2, alpha=0.6)

plt.tight_layout()
plt.show()

print("\nBlue lines mark level boundaries.")
print("Diagonal blocks = lateral (within-level) connections.")
print("Upper off-diagonal = feedforward connections.")
print("Lower off-diagonal = feedback connections.")
../_images/68fee47d12305eba2eb61720e62597166f1613371d291eb25eb5cf130388b523.png
Blue lines mark level boundaries.
Diagonal blocks = lateral (within-level) connections.
Upper off-diagonal = feedforward connections.
Lower off-diagonal = feedback connections.

7. Core-Periphery Structures #

Core-periphery networks have a densely connected core and a sparsely connected periphery.

7.1 Creating Core-Periphery Networks#

# Create core-periphery network
core_periphery = conn.CorePeripheryRandom(
    core_size=100,  # 100 neurons in core
    core_core_prob=0.5,  # 50% connectivity within core
    core_periphery_prob=0.1,  # 10% core→periphery
    periphery_core_prob=0.15,  # 15% periphery→core
    periphery_periphery_prob=0.02,  # 2% within periphery
    weight=Constant(1.0 * u.nS),
    seed=42
)

result_cp = core_periphery(pre_size=400, post_size=400)

print("Core-Periphery Network:")
print("=" * 50)
print(f"Total neurons: 400")
print(f"Core neurons: 100")
print(f"Periphery neurons: 300")
print(f"\nConnection probabilities:")
print(f"  Core ↔ Core: 50%")
print(f"  Core → Periphery: 10%")
print(f"  Periphery → Core: 15%")
print(f"  Periphery ↔ Periphery: 2%")
print(f"\nTotal connections: {len(result_cp.pre_indices)}")
Core-Periphery Network:
==================================================
Total neurons: 400
Core neurons: 100
Periphery neurons: 300

Connection probabilities:
  Core ↔ Core: 50%
  Core → Periphery: 10%
  Periphery → Core: 15%
  Periphery ↔ Periphery: 2%

Total connections: 14203

7.2 Analyzing Core vs. Periphery Connectivity#

Compare degree distributions between core and periphery:

# Calculate degrees
in_degree_cp = np.bincount(result_cp.post_indices, minlength=400)

# Separate core and periphery
core_degrees = in_degree_cp[:100]
periphery_degrees = in_degree_cp[100:]

# Plot comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

vis.distribution_plot(
    core_degrees,
    bins=20,
    alpha=0.7,
    colors=['darkred'],
    edgecolor='black',
    ax=axes[0],
    xlabel='In-Degree',
    ylabel='Count',
    title='Core Neurons (n=100)'
)

vis.distribution_plot(
    periphery_degrees,
    bins=20,
    alpha=0.7,
    colors=['lightblue'],
    edgecolor='black',
    ax=axes[1],
    xlabel='In-Degree',
    ylabel='Count',
    title='Periphery Neurons (n=300)'
)

plt.tight_layout()
plt.show()

print(f"\nDegree Statistics:")
print(f"  Core - Mean: {np.mean(core_degrees):.1f}, Std: {np.std(core_degrees):.1f}")
print(f"  Periphery - Mean: {np.mean(periphery_degrees):.1f}, Std: {np.std(periphery_degrees):.1f}")
print(f"\nCore neurons have ~{np.mean(core_degrees) / np.mean(periphery_degrees):.1f}× more connections")
../_images/16695ae3856f77bf4548e8bc3089204b7ea1f75114369cb62b651f66bfb7eadf.png
Degree Statistics:
  Core - Mean: 93.6, Std: 7.8
  Periphery - Mean: 16.2, Std: 3.7

Core neurons have ~5.8× more connections

7.3 Visualizing Core-Periphery Structure#

# Create smaller network for visualization
cp_viz = conn.CorePeripheryRandom(
    core_size=30,
    core_core_prob=0.6,
    core_periphery_prob=0.15,
    periphery_core_prob=0.2,
    periphery_periphery_prob=0.03,
    seed=42
)(pre_size=120, post_size=120)

matrix_cp = result_to_matrix(cp_viz, 120)

# Plot
fig, ax = plt.subplots(figsize=(10, 9))
vis.connectivity_matrix(
    matrix_cp,
    cmap='binary',
    center_zero=False,
    show_colorbar=False,
    ax=ax,
    title='Core-Periphery Network (30 core + 90 periphery)'
)

# Add core boundary
ax.axhline(30, color='red', linewidth=2.5, alpha=0.7)
ax.axvline(30, color='red', linewidth=2.5, alpha=0.7)

# Add text labels
ax.text(15, 15, 'CORE\n(dense)', ha='center', va='center',
        fontsize=12, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
ax.text(75, 75, 'PERIPHERY\n(sparse)', ha='center', va='center',
        fontsize=12, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))

plt.tight_layout()
plt.show()

print("\nRed lines separate core and periphery.")
print("Top-left block (core-core) is densely connected.")
print("Bottom-right block (periphery-periphery) is sparsely connected.")
../_images/2d21ac51fc75bd089f97142083d296d3a785647d82fe14b8cb499dbf2527403e.png
Red lines separate core and periphery.
Top-left block (core-core) is densely connected.
Bottom-right block (periphery-periphery) is sparsely connected.

8. Network Analysis #

Compare topological properties across different network types.

8.1 Degree Distribution Comparison#

# Create test networks with comparable sizes
n_compare = 300

networks = {
    'Regular': conn.Regular(degree=10, seed=42)(pre_size=n_compare, post_size=n_compare),
    'Small-World': conn.SmallWorld(k=10, p=0.1, seed=42)(pre_size=n_compare, post_size=n_compare),
    'Scale-Free': conn.ScaleFree(m=5, seed=42)(pre_size=n_compare, post_size=n_compare),
    'Random': conn.Random(prob=0.033, seed=42)(pre_size=n_compare, post_size=n_compare),
}

# Calculate degree distributions
degrees = {}
for name, result in networks.items():
    in_deg = np.bincount(result.post_indices, minlength=n_compare)
    degrees[name] = in_deg

# Plot comparison
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
axes = axes.flatten()

colors = {'Regular': 'green', 'Small-World': 'steelblue',
          'Scale-Free': 'coral', 'Random': 'gray'}

for idx, (name, deg) in enumerate(degrees.items()):
    vis.distribution_plot(
        deg,
        bins=25,
        alpha=0.7,
        colors=[colors[name]],
        edgecolor='black',
        ax=axes[idx],
        xlabel='In-Degree',
        ylabel='Count',
        title=name
    )

plt.suptitle('Degree Distributions Across Network Types', fontsize=14, y=0.995)
plt.tight_layout()
plt.show()
../_images/a96e201720e202b2405e60e3152e9ae29a65b6662f7cecd6698b506815c0b634.png

8.2 Summary Statistics Table#

# Calculate summary statistics
print("Network Topology Comparison:")
print("=" * 80)
print(f"{'Network':<15} {'Connections':<12} {'Mean Deg':<12} {'Std Deg':<12} {'CV':<12}")
print("=" * 80)

for name, result in networks.items():
    deg = degrees[name]
    n_conn = len(result.pre_indices)
    mean_deg = np.mean(deg)
    std_deg = np.std(deg)
    cv = std_deg / mean_deg if mean_deg > 0 else 0

    print(f"{name:<15} {n_conn:<12} {mean_deg:<12.2f} {std_deg:<12.2f} {cv:<12.3f}")

print("\nCV = Coefficient of Variation (std/mean)")
print("Low CV: Homogeneous connectivity (Regular, Small-World)")
print("High CV: Heterogeneous connectivity (Scale-Free)")
Network Topology Comparison:
================================================================================
Network         Connections  Mean Deg     Std Deg      CV          
================================================================================
Regular         3000         10.00        3.19         0.319       
Small-World     3000         10.00        1.40         0.140       
Scale-Free      2970         9.90         9.44         0.953       
Random          2923         9.74         3.20         0.328       

CV = Coefficient of Variation (std/mean)
Low CV: Homogeneous connectivity (Regular, Small-World)
High CV: Heterogeneous connectivity (Scale-Free)

9. Exercises #

Try these exercises to reinforce your understanding:

Exercise 1: Small-World Transition#

Analyze how network properties change with rewiring probability:

def analyze_small_world_transition(n_neurons=200, k=6, p_values=None):
    """
    Analyze small-world transition by measuring clustering and path length.
    
    For each rewiring probability p:
    1. Create network
    2. Calculate clustering coefficient (local connectivity)
    3. Estimate path length (requires graph algorithms)
    
    Parameters
    ----------
    n_neurons : int
        Network size
    k : int
        Number of neighbors
    p_values : list
        Rewiring probabilities to test
    
    Returns
    -------
    results : dict
        Dictionary with clustering and path length for each p
    """
    # YOUR CODE HERE
    # Hint: Clustering coefficient measures local connectivity density
    # For neuron i: C_i = (actual triangles) / (possible triangles)

    pass

# Test the function
# results = analyze_small_world_transition()
# 
# # Plot results
# p_vals = sorted(results.keys())
# clustering = [results[p]['clustering'] for p in p_vals]
# 
# plt.figure(figsize=(10, 6))
# plt.plot(p_vals, clustering, 'o-', linewidth=2)
# plt.xlabel('Rewiring Probability (p)')
# plt.ylabel('Clustering Coefficient')
# plt.title('Small-World Transition')
# plt.grid(True, alpha=0.3)
# plt.show()

Exercise 2: Custom Modular Architecture#

Design a multi-level modular network (modules within modules):

def create_hierarchical_modular_network(n_super_modules=2, n_sub_modules=3, neurons_per_sub=50):
    """
    Create a hierarchical modular network with super-modules containing sub-modules.
    
    Structure:
    - Super-module 1
      - Sub-module 1a
      - Sub-module 1b
      - Sub-module 1c
    - Super-module 2
      - Sub-module 2a
      - Sub-module 2b
      - Sub-module 2c
    
    Connection rules:
    - High connectivity within sub-modules
    - Medium connectivity between sub-modules in same super-module
    - Low connectivity between super-modules
    
    Returns
    -------
    result : ConnectionResult
        Hierarchical modular connectivity
    """
    # YOUR CODE HERE
    # Hint: Use ModularGeneral with custom probability matrix

    pass

# Test your function
# result = create_hierarchical_modular_network()
# print(f"Created hierarchical modular network with {len(result.pre_indices)} connections")

Exercise 3: Network Resilience Analysis#

Compare how different topologies respond to neuron removal:

def analyze_network_resilience(result, removal_fraction=0.2, strategy='random'):
    """
    Analyze network resilience by simulating neuron removal.
    
    Parameters
    ----------
    result : ConnectionResult
        Network to analyze
    removal_fraction : float
        Fraction of neurons to remove
    strategy : str
        'random': Remove random neurons
        'targeted': Remove highest-degree neurons (hub attack)
    
    Returns
    -------
    metrics : dict
        Network metrics before and after removal:
        - remaining_connections: fraction of connections preserved
        - largest_component: size of largest connected component
    """
    # YOUR CODE HERE
    # Hint: Compare scale-free (vulnerable to hub attacks) vs. 
    # small-world (more resilient)

    pass

# Test on different networks
# for name, result in networks.items():
#     metrics_random = analyze_network_resilience(result, strategy='random')
#     metrics_targeted = analyze_network_resilience(result, strategy='targeted')
#     
#     print(f"{name}:")
#     print(f"  Random removal: {metrics_random['remaining_connections']:.1%} connections remain")
#     print(f"  Targeted removal: {metrics_targeted['remaining_connections']:.1%} connections remain")