|
|
| """
|
| bio_mechanisms.py — Mecanismos bio-inspirados para el Classroom de PamparV3.
|
|
|
| 5 mecanismos basados en neurociencia real:
|
| 1. Neuromodulación — dopamina/norepinefrina ajustan LR dinámicamente
|
| 2. LTP — fortalece LateralGate.scale de streams consistentes
|
| 3. Sleep Replay — consolidación periódica (REM aleatorio + SWS ordenado)
|
| 4. Neurogenesis — inyecta LoRA adapters en StreamFFN para conocimiento nuevo
|
| 5. Synaptic Pruning — poda conexiones laterales débiles
|
|
|
| Uso:
|
| from bio_mechanisms import BioOrchestrator
|
|
|
| bio = BioOrchestrator(model, config, optimizer, replay_buffer)
|
| # Después de cada lección:
|
| bio.after_lesson(lesson_result, terr_acts_history)
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import math
|
| import random
|
| from collections import deque
|
| from dataclasses import dataclass, field
|
| from typing import Optional
|
|
|
| import torch
|
| import torch.nn as nn
|
| import torch.nn.functional as F
|
|
|
|
|
|
|
|
|
|
|
|
|
| class Neuromodulator:
|
| """
|
| Modula el learning rate según el resultado de la lección.
|
|
|
| - Dopamina (recompensa): sube tras éxito → consolida aprendizaje
|
| - Norepinefrina (alerta): sube tras error/novedad → aumenta plasticidad
|
|
|
| El LR efectivo se escala: lr_effective = lr_base × modulation_factor
|
| """
|
|
|
| def __init__(
|
| self, baseline_lr: float, min_mult: float = 0.3, max_mult: float = 1.5
|
| ):
|
| self.baseline_lr = baseline_lr
|
| self.min_mult = min_mult
|
| self.max_mult = max_mult
|
|
|
|
|
| self.dopamine: float = 1.0
|
| self.norepinephrine: float = 1.0
|
|
|
|
|
| self._recent_correct: deque[bool] = deque(maxlen=10)
|
| self._recent_losses: deque[float] = deque(maxlen=10)
|
|
|
| def update(self, correct: bool, loss: float, level: int) -> float:
|
| """
|
| Actualiza neuromoduladores y retorna el factor de modulación del LR.
|
|
|
| Returns:
|
| factor multiplicativo para el LR (ej: 1.5 = 50% más LR)
|
| """
|
| self._recent_correct.append(correct)
|
| self._recent_losses.append(loss)
|
|
|
|
|
| decay = 0.8
|
| self.dopamine *= decay
|
| self.norepinephrine *= decay
|
|
|
|
|
| recent_errors = sum(1 for c in self._recent_correct if not c)
|
| if recent_errors >= 7:
|
| self.norepinephrine *= 0.7
|
|
|
| if correct:
|
|
|
| self.dopamine += 0.3 * (1.0 + level * 0.1)
|
|
|
| self.norepinephrine *= 0.8
|
| else:
|
|
|
| self.norepinephrine += 0.2
|
|
|
|
|
| if len(self._recent_losses) > 3:
|
| avg_loss = sum(self._recent_losses) / len(self._recent_losses)
|
| if loss > avg_loss * 1.5:
|
| self.norepinephrine += 0.1
|
|
|
|
|
|
|
|
|
| factor = 0.5 * self.dopamine + 0.7 * self.norepinephrine
|
|
|
|
|
| factor = max(self.min_mult, min(self.max_mult, factor))
|
|
|
| return factor
|
|
|
| def apply_to_optimizer(
|
| self, optimizer: torch.optim.Optimizer, factor: float
|
| ) -> None:
|
| """Aplica el factor de modulación a todos los param groups."""
|
| for group in optimizer.param_groups:
|
|
|
| if "baseline_lr" in group:
|
| group["lr"] = group["baseline_lr"] * factor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class LTPManager:
|
| """
|
| Fortalece las conexiones laterales (LateralGate.scale) de streams
|
| que se activan consistentemente juntos.
|
|
|
| Regla de Hebb: "Neurons that fire together wire together."
|
| Si un stream tiene alta activación territorial repetidamente,
|
| su scale en LateralGate crece → más comunicación lateral.
|
| """
|
|
|
| def __init__(self, n_streams: int = 4, n_levels: int = 5):
|
| self.n_streams = n_streams
|
| self.n_levels = n_levels
|
|
|
|
|
| self._activation_accum: list[torch.Tensor] = [
|
| torch.zeros(n_streams) for _ in range(n_levels)
|
| ]
|
| self._count: int = 0
|
| self._apply_every: int = 5
|
|
|
| def accumulate(self, terr_acts_per_level: list[torch.Tensor]) -> None:
|
| """
|
| Acumula activaciones territoriales de la última lección.
|
|
|
| Args:
|
| terr_acts_per_level: lista de [B, L, 4] por nivel, o un solo [B, L, 4]
|
| """
|
| self._count += 1
|
|
|
| for lvl_idx, terr_acts in enumerate(terr_acts_per_level):
|
| if lvl_idx >= self.n_levels:
|
| break
|
|
|
| mean_act = terr_acts.detach().float().mean(dim=(0, 1))
|
| self._activation_accum[lvl_idx] += mean_act.cpu()
|
|
|
| def should_apply(self) -> bool:
|
| """Retorna True si es momento de aplicar LTP."""
|
| return self._count > 0 and self._count % self._apply_every == 0
|
|
|
| @torch.no_grad()
|
| def apply(self, model: nn.Module, strength: float = 0.02) -> dict[str, float]:
|
| """
|
| Fortalece LateralGate.scale según activaciones acumuladas.
|
|
|
| Returns:
|
| dict con cambios aplicados por nivel
|
| """
|
| if self._count == 0:
|
| return {}
|
|
|
| changes: dict[str, float] = {}
|
|
|
| for name, module in model.named_modules():
|
| if not hasattr(module, "scale") or "lateral" not in name.lower():
|
| continue
|
|
|
|
|
| lvl_idx = self._extract_level_index(name)
|
| if lvl_idx is None or lvl_idx >= self.n_levels:
|
| continue
|
|
|
|
|
| avg_act = self._activation_accum[lvl_idx] / self._count
|
| avg_act = avg_act / (avg_act.max() + 1e-8)
|
|
|
|
|
|
|
| delta = strength * avg_act.to(module.scale.device)
|
| module.scale.data += delta
|
|
|
|
|
| module.scale.data.clamp_(0.01, 0.5)
|
|
|
| changes[name] = delta.mean().item()
|
|
|
|
|
| self._activation_accum = [
|
| torch.zeros(self.n_streams) for _ in range(self.n_levels)
|
| ]
|
| self._count = 0
|
|
|
| return changes
|
|
|
| def _extract_level_index(self, name: str) -> Optional[int]:
|
| """Extrae el índice del nivel desde el nombre del módulo."""
|
|
|
| parts = name.split(".")
|
| for i, part in enumerate(parts):
|
| if part == "niveles" and i + 1 < len(parts):
|
| try:
|
| return int(parts[i + 1])
|
| except ValueError:
|
| pass
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class SleepConsolidator:
|
| """
|
| Consolidación periódica que simula las fases del sueño:
|
|
|
| - REM: replay aleatorio de experiencias recientes (creatividad/generalización)
|
| - SWS (Slow-Wave Sleep): replay ordenado por importancia (consolidación fuerte)
|
|
|
| Se ejecuta cada N lecciones, hace un mini-entrenamiento con replay puro.
|
| """
|
|
|
| def __init__(self, every_n: int = 15, rem_ratio: float = 0.6):
|
| self.every_n = every_n
|
| self.rem_ratio = rem_ratio
|
| self._lesson_count = 0
|
|
|
| def should_sleep(self) -> bool:
|
| """¿Es hora de dormir?"""
|
| self._lesson_count += 1
|
| return self._lesson_count % self.every_n == 0
|
|
|
| def consolidate(
|
| self,
|
| model: nn.Module,
|
| optimizer: torch.optim.Optimizer,
|
| replay_buffer: object,
|
| device: torch.device,
|
| n_steps: int = 3,
|
| ) -> float:
|
| """
|
| Ejecuta consolidación de sueño.
|
|
|
| Args:
|
| model: PamparV3
|
| optimizer: el optimizador con LR diferencial
|
| replay_buffer: ReplayBuffer con .buffer y .sample()
|
| device: dispositivo
|
| n_steps: pasos de consolidación
|
|
|
| Returns:
|
| loss promedio durante consolidación
|
| """
|
| buffer = getattr(replay_buffer, "buffer", [])
|
| if len(buffer) < 4:
|
| return 0.0
|
|
|
| model.train()
|
| total_loss = 0.0
|
|
|
|
|
| sleep_lr_factor = 0.3
|
| original_lrs: list[float] = []
|
| for group in optimizer.param_groups:
|
| original_lrs.append(group["lr"])
|
| group["lr"] = group["lr"] * sleep_lr_factor
|
|
|
| for step in range(n_steps):
|
|
|
| n_rem = max(1, int(len(buffer) * self.rem_ratio))
|
| rem_samples = random.sample(list(buffer), min(n_rem, len(buffer)))
|
|
|
|
|
| sws_samples = sorted(
|
| list(buffer),
|
| key=lambda x: x.get("level", 1),
|
| reverse=True,
|
| )
|
| n_sws = max(1, len(buffer) - n_rem)
|
| sws_samples = sws_samples[:n_sws]
|
|
|
|
|
| all_samples = rem_samples + sws_samples
|
|
|
| optimizer.zero_grad()
|
| batch_loss = torch.tensor(0.0, device=device)
|
| n = 0
|
|
|
| for sample in all_samples:
|
| input_ids = sample["input_ids"].to(device)
|
| labels = sample["labels"].to(device)
|
| if input_ids.dim() == 1:
|
| input_ids = input_ids.unsqueeze(0)
|
| labels = labels.unsqueeze(0)
|
| if input_ids.shape[-1] < 3:
|
| continue
|
|
|
| inp = input_ids[:, :-1]
|
| tgt = labels[:, 1:]
|
| logits, _, _ = model(inp)
|
|
|
| loss = F.cross_entropy(
|
| logits.reshape(-1, logits.size(-1)),
|
| tgt.reshape(-1),
|
| ignore_index=-100,
|
| )
|
| batch_loss = batch_loss + loss
|
| n += 1
|
|
|
| if n > 0:
|
| batch_loss = batch_loss / n
|
| batch_loss.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| optimizer.step()
|
| total_loss += batch_loss.item()
|
|
|
|
|
| for group, orig_lr in zip(optimizer.param_groups, original_lrs):
|
| group["lr"] = orig_lr
|
|
|
| return total_loss / max(1, n_steps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class StreamLoRA(nn.Module):
|
| """
|
| Adapter LoRA minimalista para StreamFFN.
|
|
|
| Inyecta una rama paralela de bajo rango que captura conocimiento nuevo
|
| sin modificar los pesos originales (como neuronas nuevas en el hipocampo).
|
|
|
| Original: y = FFN(x)
|
| Con LoRA: y = FFN(x) + scale * B(A(x))
|
|
|
| Params: dim × rank + rank × dim ≈ 640×8×2 = 10K por adapter
|
| """
|
|
|
| def __init__(self, dim: int, rank: int = 8):
|
| super().__init__()
|
| self.down = nn.Linear(dim, rank, bias=False)
|
| self.up = nn.Linear(rank, dim, bias=False)
|
| self.scale = nn.Parameter(torch.tensor(0.01))
|
|
|
|
|
| nn.init.kaiming_normal_(self.down.weight, a=math.sqrt(5))
|
| nn.init.zeros_(self.up.weight)
|
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| """Retorna solo el delta LoRA (se suma al output original)."""
|
| return self.scale * self.up(F.silu(self.down(x)))
|
|
|
|
|
| class NeurogenesisManager:
|
| """
|
| Gestiona la creación y activación de LoRA adapters en StreamFFN.
|
|
|
| Solo crea adapters cuando detecta que un stream necesita aprender
|
| algo genuinamente nuevo (alta pérdida + baja activación territorial).
|
| """
|
|
|
| def __init__(self, dim: int = 640, rank: int = 8, max_adapters: int = 8):
|
| self.dim = dim
|
| self.rank = rank
|
| self.max_adapters = max_adapters
|
| self._adapters: dict[str, StreamLoRA] = {}
|
| self._hooked: bool = False
|
|
|
| @property
|
| def adapter_count(self) -> int:
|
| return len(self._adapters)
|
|
|
| def should_grow(self, loss: float, threshold: float = 4.0) -> bool:
|
| """Determina si necesitamos crear neuronas nuevas."""
|
| return loss > threshold and self.adapter_count < self.max_adapters
|
|
|
| def create_adapter(
|
| self, model: nn.Module, level_idx: int, stream_idx: int, device: torch.device
|
| ) -> Optional[str]:
|
| """
|
| Crea un LoRA adapter para un StreamFFN específico.
|
|
|
| Returns:
|
| nombre del adapter creado, o None si ya existe/límite alcanzado
|
| """
|
| key = f"lora_L{level_idx}_S{stream_idx}"
|
| if key in self._adapters or self.adapter_count >= self.max_adapters:
|
| return None
|
|
|
| adapter = StreamLoRA(self.dim, self.rank).to(device)
|
| self._adapters[key] = adapter
|
|
|
|
|
| if not hasattr(model, "_bio_lora_adapters"):
|
| model._bio_lora_adapters = nn.ModuleDict()
|
| model._bio_lora_adapters[key] = adapter
|
|
|
| return key
|
|
|
| def get_adapter(self, level_idx: int, stream_idx: int) -> Optional[StreamLoRA]:
|
| """Retorna el adapter para un nivel/stream, si existe."""
|
| key = f"lora_L{level_idx}_S{stream_idx}"
|
| return self._adapters.get(key)
|
|
|
| def add_adapters_to_optimizer(
|
| self, optimizer: torch.optim.Optimizer, lr: float
|
| ) -> None:
|
| """Añade los parámetros de los nuevos adapters al optimizador."""
|
| existing_params = set()
|
| for group in optimizer.param_groups:
|
| for p in group["params"]:
|
| existing_params.add(id(p))
|
|
|
| new_params = []
|
| for adapter in self._adapters.values():
|
| for p in adapter.parameters():
|
| if id(p) not in existing_params:
|
| new_params.append(p)
|
|
|
| if new_params:
|
| optimizer.add_param_group(
|
| {
|
| "params": new_params,
|
| "lr": lr,
|
| "label": "neurogenesis_lora",
|
| }
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class SynapticPruner:
|
| """
|
| Poda conexiones laterales débiles (LateralGate.scale bajo).
|
|
|
| En el cerebro, ~50% de las sinapsis se eliminan durante el desarrollo.
|
| Aquí, si un LateralGate.scale cae por debajo del umbral durante
|
| varias lecciones consecutivas, lo reducimos agresivamente.
|
|
|
| Esto libera "capacidad" y evita ruido de conexiones irrelevantes.
|
| """
|
|
|
| def __init__(self, every_n: int = 30, threshold: float = 0.03, decay: float = 0.5):
|
| self.every_n = every_n
|
| self.threshold = threshold
|
| self.decay = decay
|
| self._lesson_count = 0
|
|
|
| def should_prune(self) -> bool:
|
| """¿Es momento de podar?"""
|
| self._lesson_count += 1
|
| return self._lesson_count % self.every_n == 0
|
|
|
| @torch.no_grad()
|
| def prune(self, model: nn.Module) -> dict[str, list[int]]:
|
| """
|
| Poda conexiones laterales débiles.
|
|
|
| Returns:
|
| dict con streams podados por nivel
|
| """
|
| pruned: dict[str, list[int]] = {}
|
|
|
| for name, module in model.named_modules():
|
| if not hasattr(module, "scale") or "lateral" not in name.lower():
|
| continue
|
|
|
| scale = module.scale.data
|
| weak_mask = scale < self.threshold
|
|
|
| if weak_mask.any():
|
|
|
| module.scale.data[weak_mask] *= self.decay
|
|
|
|
|
| pruned_streams = weak_mask.nonzero(as_tuple=True)[0].tolist()
|
| pruned[name] = pruned_streams
|
|
|
| return pruned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @dataclass
|
| class BioState:
|
| """Estado observable de los mecanismos bio para logging/UI."""
|
|
|
| dopamine: float = 1.0
|
| norepinephrine: float = 1.0
|
| lr_factor: float = 1.0
|
| ltp_applied: bool = False
|
| ltp_changes: dict = field(default_factory=dict)
|
| sleep_triggered: bool = False
|
| sleep_loss: float = 0.0
|
| adapters_created: int = 0
|
| adapters_total: int = 0
|
| pruned_streams: dict = field(default_factory=dict)
|
|
|
|
|
| class BioOrchestrator:
|
| """
|
| Coordina los 5 mecanismos bio-inspirados.
|
|
|
| Se llama una vez después de cada lección con el resultado y las
|
| activaciones territoriales. Él decide qué mecanismos activar.
|
| """
|
|
|
| def __init__(
|
| self,
|
| model: nn.Module,
|
| optimizer: torch.optim.Optimizer,
|
| replay_buffer: object,
|
| device: torch.device,
|
| baseline_lr: float = 5e-6,
|
| dim: int = 640,
|
| n_streams: int = 4,
|
| n_levels: int = 5,
|
| sleep_every: int = 15,
|
| prune_every: int = 30,
|
| ):
|
| self.model = model
|
| self.optimizer = optimizer
|
| self.replay_buffer = replay_buffer
|
| self.device = device
|
|
|
|
|
| self.neuromod = Neuromodulator(baseline_lr)
|
| self.ltp = LTPManager(n_streams, n_levels)
|
| self.sleep = SleepConsolidator(every_n=sleep_every)
|
| self.neurogenesis = NeurogenesisManager(dim=dim, rank=8, max_adapters=8)
|
| self.pruner = SynapticPruner(every_n=prune_every)
|
|
|
|
|
| for group in optimizer.param_groups:
|
| group["baseline_lr"] = group["lr"]
|
|
|
| def after_lesson(
|
| self,
|
| correct: bool,
|
| loss: float,
|
| level: int,
|
| terr_acts_per_level: Optional[list[torch.Tensor]] = None,
|
| ) -> BioState:
|
| """
|
| Hook principal — se llama después de cada lección.
|
|
|
| Args:
|
| correct: si el alumno acertó
|
| loss: loss CE de la lección
|
| level: nivel del curriculum
|
| terr_acts_per_level: activaciones territoriales por nivel (opcional)
|
|
|
| Returns:
|
| BioState con el estado de todos los mecanismos
|
| """
|
| state = BioState()
|
|
|
|
|
| factor = self.neuromod.update(correct, loss, level)
|
| self.neuromod.apply_to_optimizer(self.optimizer, factor)
|
| state.dopamine = self.neuromod.dopamine
|
| state.norepinephrine = self.neuromod.norepinephrine
|
| state.lr_factor = factor
|
|
|
|
|
| if terr_acts_per_level is not None:
|
| self.ltp.accumulate(terr_acts_per_level)
|
| if self.ltp.should_apply():
|
| changes = self.ltp.apply(self.model)
|
| state.ltp_applied = True
|
| state.ltp_changes = changes
|
|
|
|
|
| if self.sleep.should_sleep():
|
| sleep_loss = self.sleep.consolidate(
|
| self.model, self.optimizer, self.replay_buffer, self.device
|
| )
|
| state.sleep_triggered = True
|
| state.sleep_loss = sleep_loss
|
|
|
|
|
| if self.neurogenesis.should_grow(loss):
|
|
|
| if terr_acts_per_level:
|
| last_terr = terr_acts_per_level[-1]
|
| mean_act = last_terr.detach().float().mean(dim=(0, 1))
|
| weakest_stream = mean_act.argmin().item()
|
|
|
| deepest_level = len(terr_acts_per_level) - 1
|
| key = self.neurogenesis.create_adapter(
|
| self.model, deepest_level, weakest_stream, self.device
|
| )
|
| if key:
|
| state.adapters_created = 1
|
|
|
| self.neurogenesis.add_adapters_to_optimizer(
|
| self.optimizer, lr=self.neuromod.baseline_lr * factor
|
| )
|
| state.adapters_total = self.neurogenesis.adapter_count
|
|
|
|
|
| if self.pruner.should_prune():
|
| pruned = self.pruner.prune(self.model)
|
| state.pruned_streams = pruned
|
|
|
| return state
|
|
|