MHRN-Space / src /playground /analysis.py
ThomasHeisig's picture
Sync GitHub main e8a04aea24121d15707238534e635cf0ceee8eaf (part 8)
250836c verified
Raw History Blame Contribute Delete
16 kB
"""Descriptive analysis for non-canonical Playground sessions.
All metrics are exploratory diagnostics. They are not scientific evidence,
do not enter the research registry, and never contribute to maturity.
"""
from __future__ import annotations
import math
from collections import Counter, deque
from collections.abc import Mapping, Sequence
import numpy as np
from .instruments.probes_nd import dimension_utilization, occupancy_nd
def _mean(values: Sequence[float]) -> float:
return float(sum(values) / len(values)) if values else 0.0
def _std(values: Sequence[float]) -> float:
if len(values) < 2:
return 0.0
mean = _mean(values)
return math.sqrt(sum((value - mean) ** 2 for value in values) / len(values))
def _spike_trains(
spikes: Sequence[Mapping[str, int]], n_neurons: int
) -> list[list[int]]:
trains: list[list[int]] = [[] for _ in range(n_neurons)]
for spike in spikes:
neuron_id = int(spike.get("neuron_id", -1))
tick = int(spike.get("tick", -1))
if 0 <= neuron_id < n_neurons and tick >= 0:
trains[neuron_id].append(tick)
return trains
def _isi_metrics(trains: Sequence[Sequence[int]], dt_ms: float) -> dict[str, object]:
isi: list[float] = []
neuron_cv: list[float] = []
burst_count = 0
for train in trains:
local = [
(train[index] - train[index - 1]) * dt_ms for index in range(1, len(train))
]
isi.extend(local)
if len(local) >= 2:
mean = _mean(local)
if mean > 0:
neuron_cv.append(_std(local) / mean)
burst_count += sum(1 for value in local if value <= 10.0)
return {
"isi_mean_ms": _mean(isi),
"isi_std_ms": _std(isi),
"isi_cv_mean": _mean(neuron_cv),
"burst_interval_count": burst_count,
"burst_fraction": burst_count / max(len(isi), 1),
}
def _latency_metrics(
trains: Sequence[Sequence[int]], ticks: int, dt_ms: float
) -> dict[str, object]:
first = [train[0] for train in trains if train]
last = [train[-1] for train in trains if train]
return {
"first_spike_latency_ms": min(first, default=ticks) * dt_ms,
"median_first_spike_latency_ms": (
float(np.median(first)) * dt_ms if first else None
),
"last_spike_ms": max(last, default=-1) * dt_ms if last else None,
"persistence_fraction": (
max(last, default=-1) / max(ticks - 1, 1) if last else 0.0
),
"propagation_depth_proxy": len({tick for train in trains for tick in train}),
"recurrently_active_neurons": sum(1 for train in trains if len(train) >= 2),
}
def _population_dynamics(
tick_counts: Sequence[int], n_neurons: int, dt_ms: float
) -> dict[str, object]:
if not tick_counts:
return {
"synchrony_index": 0.0,
"population_fano": 0.0,
"dominant_frequency_hz": 0.0,
"spectral_power": [],
}
values = np.asarray(tick_counts, dtype=float)
mean = float(values.mean())
variance = float(values.var())
synchrony = float(values.max() / max(n_neurons, 1))
centered = values - mean
frequencies = np.fft.rfftfreq(len(values), d=dt_ms / 1000.0)
spectrum = np.abs(np.fft.rfft(centered)) ** 2
if len(spectrum) > 1:
index = int(np.argmax(spectrum[1:]) + 1)
dominant = float(frequencies[index])
else:
dominant = 0.0
sample = [
{"hz": float(frequencies[index]), "power": float(spectrum[index])}
for index in range(min(len(spectrum), 128))
]
return {
"synchrony_index": synchrony,
"population_fano": variance / mean if mean > 0 else 0.0,
"dominant_frequency_hz": dominant,
"spectral_power": sample,
}
def _adjacency(
n: int, edges: Sequence[Sequence[int]]
) -> tuple[list[set[int]], list[set[int]]]:
outgoing: list[set[int]] = [set() for _ in range(n)]
incoming: list[set[int]] = [set() for _ in range(n)]
for edge in edges:
if len(edge) < 2:
continue
source, target = int(edge[0]), int(edge[1])
if 0 <= source < n and 0 <= target < n and source != target:
outgoing[source].add(target)
incoming[target].add(source)
return outgoing, incoming
def _components(outgoing: Sequence[set[int]]) -> list[int]:
undirected = [set(values) for values in outgoing]
for source, targets in enumerate(outgoing):
for target in targets:
undirected[target].add(source)
seen: set[int] = set()
sizes: list[int] = []
for node in range(len(undirected)):
if node in seen:
continue
queue = [node]
seen.add(node)
size = 0
while queue:
current = queue.pop()
size += 1
for target in undirected[current]:
if target not in seen:
seen.add(target)
queue.append(target)
sizes.append(size)
return sorted(sizes, reverse=True)
def _clustering(outgoing: Sequence[set[int]]) -> float:
values: list[float] = []
for node, neighbours in enumerate(outgoing):
neighbours = set(neighbours)
for source, targets in enumerate(outgoing):
if node in targets:
neighbours.add(source)
degree = len(neighbours)
if degree < 2:
continue
links = 0
ordered = list(neighbours)
for index, left in enumerate(ordered):
for right in ordered[index + 1 :]:
if right in outgoing[left] or left in outgoing[right]:
links += 1
values.append((2.0 * links) / (degree * (degree - 1)))
return _mean(values)
def _mean_path(outgoing: Sequence[set[int]], sample_limit: int = 64) -> float:
n = len(outgoing)
if n < 2:
return 0.0
sources = list(range(min(n, sample_limit)))
distances: list[int] = []
for source in sources:
queue: deque[tuple[int, int]] = deque([(source, 0)])
seen = {source}
while queue:
node, depth = queue.popleft()
for target in outgoing[node]:
if target in seen:
continue
seen.add(target)
distances.append(depth + 1)
queue.append((target, depth + 1))
return _mean([float(value) for value in distances])
def _cycle_proxy(outgoing: Sequence[set[int]]) -> dict[str, object]:
reciprocal = 0
for source, targets in enumerate(outgoing):
reciprocal += sum(1 for target in targets if source in outgoing[target])
reciprocal //= 2
self_return_3 = 0
for source, targets in enumerate(outgoing):
for middle in targets:
for end in outgoing[middle]:
if source in outgoing[end]:
self_return_3 += 1
return {
"reciprocal_pairs": reciprocal,
"three_cycle_count_proxy": self_return_3 // 3,
}
def _topology_metrics(
coordinates: Sequence[Sequence[float]],
edges: Sequence[Sequence[int]],
modules: int,
) -> dict[str, object]:
n = len(coordinates)
outgoing, incoming = _adjacency(n, edges)
in_degree = [len(values) for values in incoming]
out_degree = [len(values) for values in outgoing]
components = _components(outgoing)
distances = [
math.sqrt(
sum(
(float(coordinates[source][axis]) - float(coordinates[target][axis]))
** 2
for axis in range(
min(len(coordinates[source]), len(coordinates[target]))
)
)
)
for source, target in (
(int(edge[0]), int(edge[1])) for edge in edges if len(edge) >= 2
)
if 0 <= source < n and 0 <= target < n
]
internal = 0
valid_edges = 0
for edge in edges:
if len(edge) < 2:
continue
source, target = int(edge[0]), int(edge[1])
if 0 <= source < n and 0 <= target < n:
valid_edges += 1
if source % max(modules, 1) == target % max(modules, 1):
internal += 1
hub_threshold = _mean([float(value) for value in out_degree]) + 2.0 * _std(
[float(value) for value in out_degree]
)
return {
"in_degree": in_degree,
"out_degree": out_degree,
"degree_histogram": dict(Counter(in_degree + out_degree)),
"mean_in_degree": _mean([float(value) for value in in_degree]),
"mean_out_degree": _mean([float(value) for value in out_degree]),
"max_in_degree": max(in_degree, default=0),
"max_out_degree": max(out_degree, default=0),
"hub_neurons": [
index for index, value in enumerate(out_degree) if value >= hub_threshold
],
"clustering_coefficient": _clustering(outgoing),
"mean_path_length": _mean_path(outgoing),
"connected_components": components,
"largest_component_fraction": (
components[0] / max(n, 1) if components else 0.0
),
"module_internal_edge_fraction": internal / max(valid_edges, 1),
"edge_distance_mean": _mean(distances),
"edge_distance_std": _std(distances),
"edge_distance_min": min(distances, default=0.0),
"edge_distance_max": max(distances, default=0.0),
**_cycle_proxy(outgoing),
}
def _dimensionality(
coordinates: Sequence[Sequence[float]],
) -> dict[str, object]:
if not coordinates:
return {
"coordinate_dimensions": 0,
"pca_explained_variance_ratio": [],
"effective_dimensionality": 0.0,
"participation_ratio": 0.0,
"dimension_variance": [],
}
width = max(len(point) for point in coordinates)
matrix = np.zeros((len(coordinates), width), dtype=float)
for row, point in enumerate(coordinates):
matrix[row, : len(point)] = [float(value) for value in point]
variances = np.var(matrix, axis=0)
centered = matrix - matrix.mean(axis=0, keepdims=True)
if len(coordinates) > 1:
singular = np.linalg.svd(centered, full_matrices=False, compute_uv=False)
eigen = singular**2
else:
eigen = np.zeros(width, dtype=float)
total = float(eigen.sum())
ratios = (eigen / total).tolist() if total > 0 else [0.0] * len(eigen)
denom = float(np.sum(eigen**2))
participation = (total * total / denom) if denom > 0 else 0.0
return {
"coordinate_dimensions": width,
"pca_explained_variance_ratio": [float(value) for value in ratios],
"effective_dimensionality": float(participation),
"participation_ratio": float(participation),
"dimension_variance": [float(value) for value in variances],
"dimension_utilization": dimension_utilization(coordinates),
"occupancy": {
"bins_per_dimension": 4,
"occupied_cells": len(occupancy_nd(coordinates, bins=4)),
"distribution": occupancy_nd(coordinates, bins=4),
"interpretation": (
"Structural distribution only; no quality or superiority score."
),
},
}
def analyze_result(
result: Mapping[str, object],
*,
initial_weight: float,
final_weights: Sequence[float],
initial_edge_count: int,
final_edge_count: int,
structural_added: int = 0,
structural_removed: int = 0,
runtime_seconds: float = 0.0,
) -> dict[str, object]:
config = result.get("config", {})
topology = result.get("topology", {})
monitors = result.get("monitors", {})
if not isinstance(config, Mapping):
config = {}
if not isinstance(topology, Mapping):
topology = {}
if not isinstance(monitors, Mapping):
monitors = {}
n = int(config.get("n_neurons", 0))
ticks = int(config.get("ticks", 0))
dt_ms = float(config.get("dt_ms", 1.0))
spikes_raw = monitors.get("spikes", [])
spikes = (
[item for item in spikes_raw if isinstance(item, Mapping)]
if isinstance(spikes_raw, Sequence)
else []
)
tick_counts_raw = monitors.get("tick_spike_counts", [])
tick_counts = (
[int(value) for value in tick_counts_raw]
if isinstance(tick_counts_raw, Sequence)
else []
)
trains = _spike_trains(spikes, n)
coordinates_raw = topology.get("coordinates", [])
coordinates = (
[item for item in coordinates_raw if isinstance(item, Sequence)]
if isinstance(coordinates_raw, Sequence)
else []
)
edges_raw = topology.get("edges", [])
edges = (
[item for item in edges_raw if isinstance(item, Sequence)]
if isinstance(edges_raw, Sequence)
else []
)
weights = [float(value) for value in final_weights]
changed = [value - initial_weight for value in weights]
saturation = sum(1 for value in weights if value <= 1e-12 or value >= 100.0 - 1e-12)
total_spikes = len(spikes)
duration_s = max(ticks * dt_ms / 1000.0, 1e-9)
return {
"classification": {
"class": "PLAYGROUND",
"scientific_evidence": False,
"interpretation": "descriptive_exploration_only",
},
"spike_time": {
**_isi_metrics(trains, dt_ms),
**_latency_metrics(trains, ticks, dt_ms),
**_population_dynamics(tick_counts, n, dt_ms),
},
"network": _topology_metrics(
coordinates,
edges,
int(config.get("modules", 1)),
),
"plasticity": {
"weight_change_mean": _mean(changed),
"weight_change_std": _std(changed),
"weight_min": min(weights, default=0.0),
"weight_max": max(weights, default=0.0),
"potentiated_fraction": (
sum(1 for value in changed if value > 1e-12) / max(len(changed), 1)
),
"depressed_fraction": (
sum(1 for value in changed if value < -1e-12) / max(len(changed), 1)
),
"weight_saturation_fraction": saturation / max(len(weights), 1),
"initial_edge_count": initial_edge_count,
"final_edge_count": final_edge_count,
"structural_edges_added": structural_added,
"structural_edges_removed": structural_removed,
},
"dimensionality": _dimensionality(coordinates),
"performance": {
"runtime_seconds": runtime_seconds,
"simulated_seconds": duration_s,
"neuron_updates_per_second": (
n * ticks / runtime_seconds if runtime_seconds > 0 else None
),
"spikes_per_runtime_second": (
total_spikes / runtime_seconds if runtime_seconds > 0 else None
),
"synaptic_edges": final_edge_count,
},
}
def ensemble_summary(results: Sequence[Mapping[str, object]]) -> dict[str, object]:
if not results:
return {"runs": 0}
mean_rates: list[float] = []
active: list[float] = []
spikes: list[float] = []
for result in results:
metrics = result.get("metrics", {})
if not isinstance(metrics, Mapping):
continue
mean_rates.append(float(metrics.get("mean_rate_hz", 0.0)))
active.append(float(metrics.get("active_fraction", 0.0)))
spikes.append(float(metrics.get("total_spikes", 0.0)))
return {
"runs": len(results),
"seed_to_seed": {
"mean_rate_hz_mean": _mean(mean_rates),
"mean_rate_hz_std": _std(mean_rates),
"active_fraction_mean": _mean(active),
"active_fraction_std": _std(active),
"total_spikes_mean": _mean(spikes),
"total_spikes_std": _std(spikes),
},
"note": "Exploratory robustness summary; not a preregistered replication.",
}