File size: 33,060 Bytes
eb3bffd
 
79e8e52
 
eb3bffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
 
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
 
eb3bffd
79e8e52
 
 
 
 
34fad27
 
 
 
 
 
eb3bffd
 
 
 
 
 
 
 
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
 
 
 
 
 
 
79e8e52
 
 
 
 
eb3bffd
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb3bffd
 
34fad27
 
 
 
 
 
 
 
 
 
eb3bffd
34fad27
 
 
 
eb3bffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
79e8e52
34fad27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb3bffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
eb3bffd
 
 
 
 
 
 
 
 
 
 
 
79e8e52
 
eb3bffd
 
 
 
 
 
 
 
 
 
79e8e52
 
 
eb3bffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
79e8e52
 
 
 
 
 
34fad27
 
 
 
 
 
 
 
79e8e52
34fad27
79e8e52
 
 
 
 
 
 
 
 
 
 
34fad27
79e8e52
 
 
 
 
34fad27
79e8e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34fad27
 
79e8e52
 
 
 
 
 
 
 
 
 
34fad27
 
 
 
eb3bffd
 
 
 
 
 
79e8e52
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
"""ewc.py — V6/V7: Elastic Weight Consolidation with Kohonen 4D per-neuron support
+ Fisher accumulation (online EWC with EMA decay).

═══════════════════════════════════════════════════════════════════════════════
V7 — FISHER ACCUMULATION (ONLINE EWC)
═══════════════════════════════════════════════════════════════════════════════

V6 mantinha `online_ewc: bool = True` e `online_decay: float = 0.99` na
configuração, mas NUNCA os utilizava — a cada chamada de `compute_fisher()`,
a Fisher anterior era SOBRESCRITA pela nova (perda total do conhecimento
de tarefas anteriores). Isto explica o sintoma observado na análise V7:

  Fisher_w_mean = 0.0  (EWC sem acumulação Fisher — possível bug)

A correção V7 implementa a acumulação online clássica (Schwarz et al. 2018):

  F_new = decay · F_old + (1 - decay) · F_computed

onde:
  - F_old     : Fisher acumulada de tarefas anteriores (persistente)
  - F_computed: Fisher computada nesta tarefa (a partir de gradients²)
  - decay     : fator EMA (default 0.99 — preserva 99% do passado)
  - F_new     : Fisher acumulada após esta tarefa

Analogamente, os pesos ótimos (anchors) seguem EMA:
  W*_new = decay · W*_old + (1 - decay) · W_current

Isto garante que:
  1. Tarefas antigas continuem a ser "ancoradas" mesmo após muitas tarefas
     novas (evita catastrophic forgetting multi-tarefa).
  2. A Fisher seja suave (sem saltos bruscos) — estável sob gradient noise.
  3. A penalty V6 (clássica + Kohonen per-neuron + topológica) é aplicada
     sobre a Fisher ACUMULADA, não sobre a Fisher da última tarefa.

V6 — EWC + KOHONEN 4D PER-NEURON WEIGHTS
═══════════════════════════════════════════════════════════════════════════════

O usuário observou que o SOM 4D Kohonen (orq_cell.py V6) exige ajustes
matemáticos no EWC porque:

  "cada neurônio da grade tem peso próprio"

No EWC clássico (Kirkpatrick 2017), a Fisher Information Matrix é uma
diagonal que associa cada parâmetro θ_i a uma "importância" F_i. Os parâmetros
importantes para tarefas anteriores são penalizados quando se movem.

No V6, o OrqCell tem 8 neurônios SOM, cada um com:
  - Um vetor de cache w_n ∈ R^d_cache (peso próprio do neurônio)
  - Acesso compartilhado a proj_in, cross_attn, FFN

A novidade matemática é:

  1. **Fisher por neurônio**: cada neurônio n tem sua própria Fisher F_n (um
     vetor d_cache-dimensional). A penalidade EWC é:
        L_ewc_kohonen = λ * Σ_n Σ_i F_{n,i} (w_{n,i} - w*_{n,i})^2
     Isto penaliza a mudança individual de cada neurônio — preservando a
     topologia aprendida pelo SOM.

  2. **Anchoring do BMU**: quando uma nova tarefa chega, o BMU atual é
     identificado para cada sample. A Fisher do BMU é reforçada (multiplicada
     por γ_bmu > 1), porque o BMU é o neurônio mais "responsável" pela tarefa
     anterior e portanto o mais sensível a mudanças.

  3. **Topological regularization**: além da penalidade por neurônio, EWC V6
     adiciona uma penalidade que preserva a distância entre neurônios
     adjacentes no grid 4D:
        L_topo = λ_topo * Σ_(i,j)∈edges ||d_grid(i,j) - d_orig(i,j)||^2
     onde d_grid é a distância no grid 4D (fixa) e d_orig é a distância
     atual entre os pesos dos neurônios. Isto garante que a topologia do SOM
     não colapse com a tarefa nova.

  4. **Compatibilidade W8A8 em eval**: quando o modelo está quantizado W8A8
     e em eval mode, o EWC não computa Fisher (não há backward em eval).
     Apenas a penalidade forward é aplicada nos pesos do mapa. Isto permite
     avaliar o quão bem o modelo retém conhecimento de tarefas anteriores
     sem re-quantizar.

Matemática
──────────
  L_total = L_task + λ * Σ_n Σ_i F_{n,i} (w_{n,i} - w*_{n,i})^2
              + λ_topo * Σ_(i,j)∈edges (||w_i - w_j|| - d_grid(i,j))^2

  λ adaptativo: λ_t = λ_0 * ||∇L_task|| / (||∇L_ewc|| + ε)
"""
from __future__ import annotations
import gc
import math
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, field

import torch
import torch.nn as nn
import torch.nn.functional as F


@dataclass
class EWCConfigV6:
    """Configuração EWC V6 com suporte a Kohonen 4D per-neuron.

    V6.1 adiciona `skip_som_filled_neurons`: quando True (default), o EWC
    NÃO computa Fisher nem aplica penalidade para neurônios do SOM que
    já estão "filled" (neuron_filled_mask=True). Isto evita a dupla
    penalização — o SOM já está consolidando esses pesos via regra de
    Kohonen, então o EWC não precisa "ancorar" aquela região do mapa.

    O usuário observou: "Elastic Weight Consolidation que deve ser
    matematicamente aprimorado para não usar pesos já existentes em SOM".
    """
    lambda_ewc: float = 100.0
    lambda_topo: float = 10.0           # penalidade topológica
    fisher_n_samples: int = 32
    fisher_batch_size: int = 4
    online_ewc: bool = True
    online_decay: float = 0.99          # V7 — agora EFETIVAMENTE usado (era decorativo)
    fisher_smoothing_alpha: float = 0.1
    adaptive_lambda: bool = True
    fisher_clip_eps: float = 1e-8
    bmu_reinforcement_gamma: float = 2.0  # γ para reforço do BMU na Fisher
    eval_mode_penalty: bool = True        # aplicar penalidade em eval mode (sem Fisher)
    # ───────────────────────────────────────────────────────────────────────
    # V6.1 — Skip SOM-filled neurons (não reutilizar pesos do SOM)
    # ───────────────────────────────────────────────────────────────────────
    skip_som_filled_neurons: bool = True  # default True (user requirement)
    # Skip_mask é lido do OrqCell.neuron_filled_mask em compute_fisher()
    # e aplicado em compute_penalty() para zerar a penalidade dos filled.
    # ───────────────────────────────────────────────────────────────────────
    # V7 — Fisher accumulation diagnostics (apenas leitura, não-configurável)
    # ───────────────────────────────────────────────────────────────────────
    # fisher_accumulation_active: se True, compute_fisher() faz EMA entre
    #   F_old e F_computed (V7 — ativação do online_ewc).
    # fisher_ema_initialized: True após a 1ª compute_fisher (não há F_old
    #   na 1ª chamada — apenas inicializa).
    fisher_accumulation_active: bool = True  # V7 — ativa por padrão


class EWCV6:
    """EWC V6 com suporte a SOM 4D Kohonen (orq_cell.OrqCell).

    Detecta automaticamente a presença de OrqCell no modelo e ativa o modo
    per-neuron. Caso contrário, degrada para EWC clássico (compatibilidade).
    """

    def __init__(self, model: nn.Module, config: Optional[EWCConfigV6] = None):
        self.model = model
        self.cfg = config or EWCConfigV6()
        # Fisher clássico (parâmetros gerais)
        self.fisher: Dict[str, torch.Tensor] = {}
        self.optimal_weights: Dict[str, torch.Tensor] = {}
        # Fisher por neurônio (orq_cell)
        self.neuron_fisher: Dict[str, torch.Tensor] = {}  # {orq_name: (n_neurons, d_cache)}
        self.neuron_optimal: Dict[str, torch.Tensor] = {}  # {orq_name: (n_neurons, d_cache)}
        # Topologia original (para regularização)
        self.neuron_grid_dist: Dict[str, torch.Tensor] = {}  # {orq_name: (n_neurons, n_neurons)}
        # ───────────────────────────────────────────────────────────────────
        # V6.1 — fisher_neuron_mask: marca quais neurônios têm Fisher > 0
        # (apenas os não-filled no SOM, para evitar dupla penalização).
        # Se skip_som_filled_neurons=True (default), neuron_fisher é zerado
        # para neurônios que estão em OrqCell.neuron_filled_mask=True.
        # ───────────────────────────────────────────────────────────────────
        self.fisher_neuron_mask: Dict[str, torch.Tensor] = {}  # {orq_name: (n_neurons,) bool}
        # Stats
        self.n_tasks = 0
        self._last_task_grad_norm: float = 0.0
        self._last_ewc_grad_norm: float = 0.0
        self._adaptive_lambda: float = self.cfg.lambda_ewc
        # ───────────────────────────────────────────────────────────────────
        # V7 — Fisher accumulation state (online EWC with EMA)
        # ───────────────────────────────────────────────────────────────────
        # _fisher_ema_initialized: False até a 1ª compute_fisher() concluir.
        #   Antes disso, compute_fisher() apenas inicializa F = F_computed.
        #   Após isso, F = decay·F_old + (1-decay)·F_computed.
        self._fisher_ema_initialized: bool = False
        # Diagnóstico: média da Fisher acumulada (para auditoria)
        self._fisher_w_mean: float = 0.0   # média da Fisher clássica (scalar)
        self._fisher_n_mean: float = 0.0   # média da Fisher per-neuron (scalar)
        # Detectar OrqCells no modelo
        self.orq_cells: Dict[str, nn.Module] = self._find_orq_cells()

    # =====================================================================
    # Detecção de OrqCell
    # =====================================================================
    def _find_orq_cells(self) -> Dict[str, nn.Module]:
        """Encontra todas as instâncias de OrqCell no modelo."""
        cells = {}
        try:
            # Tenta importar OrqCell para isinstance check
            from ..model.orq_cell import OrqCell
        except (ImportError, AttributeError):
            return cells
        for name, module in self.model.named_modules():
            if isinstance(module, OrqCell):
                cells[name] = module
        return cells

    # =====================================================================
    # Compute Fisher Information
    # =====================================================================
    def compute_fisher(
        self,
        data_iter,
        n_samples: Optional[int] = None,
        bmu_indices: Optional[List[int]] = None,
    ) -> None:
        """Computa Fisher Information Matrix diagonal.

        Para OrqCell, computa Fisher por neurônio (n_neurons, d_cache).
        Para outros parâmetros, computa Fisher clássico (shape do parâmetro).

        Args:
            data_iter: iterável de (inputs, targets) — função closure que
                       retorna um batch
            n_samples: nº de samples a usar (default: cfg.fisher_n_samples)
            bmu_indices: lista de BMU IDs por sample (para reforço γ_bmu)
        """
        n_samples = n_samples or self.cfg.fisher_n_samples
        bs = self.cfg.fisher_batch_size

        # Inicializa Fisher acumulado
        fisher_accum: Dict[str, torch.Tensor] = {}
        neuron_fisher_accum: Dict[str, torch.Tensor] = {}

        self.model.eval()  # fisher em eval para não atualizar BN/dropout

        n_used = 0
        sample_idx = 0
        for batch in data_iter:
            if n_used >= n_samples:
                break
            inputs, targets = batch
            self.model.zero_grad()
            try:
                # Tenta forward com returns padrão
                outputs = self.model(inputs)
                if isinstance(outputs, tuple):
                    logits = outputs[0]
                else:
                    logits = outputs
                # Loss para Fisher: cross-entropy ou MSE dependendo da shape
                if logits.dim() == targets.dim() + 1:
                    loss = F.cross_entropy(logits.view(-1, logits.size(-1)),
                                            targets.view(-1))
                else:
                    loss = F.mse_loss(logits, targets.float())
                loss.backward()
            except (RuntimeError, ValueError):
                continue

            # Acumula Fisher para cada parâmetro
            for name, p in self.model.named_parameters():
                if p.grad is None:
                    continue
                g2 = p.grad.detach().pow(2)
                if name not in fisher_accum:
                    fisher_accum[name] = g2.clone()
                else:
                    fisher_accum[name] += g2

            # Acumula Fisher por neurônio para OrqCells
            for orq_name, orq_cell in self.orq_cells.items():
                if not hasattr(orq_cell, "neuron_cache"):
                    continue
                # neuron_cache: (n_neurons, d_cache) — parâmetro único
                param_name = f"{orq_name}.neuron_cache"
                if param_name not in fisher_accum:
                    continue
                # Fisher já acumulada acima; vamos separá-la por neurônio
                # (fisher_accum[param_name] tem shape (n_neurons, d_cache))
                # Aplicar reforço do BMU se disponível
                bmu_id = None
                if bmu_indices is not None and sample_idx < len(bmu_indices):
                    bmu_id = bmu_indices[sample_idx]
                fisher_per_neuron = fisher_accum[param_name].clone()
                if bmu_id is not None and 0 <= bmu_id < fisher_per_neuron.size(0):
                    # γ_bmu reforço no BMU
                    fisher_per_neuron[bmu_id] *= self.cfg.bmu_reinforcement_gamma
                if orq_name not in neuron_fisher_accum:
                    neuron_fisher_accum[orq_name] = fisher_per_neuron
                else:
                    neuron_fisher_accum[orq_name] += fisher_per_neuron

            n_used += bs
            sample_idx += 1
            if n_used % (bs * 10) == 0:
                gc.collect(0)

        # Normaliza pelo nº de samples
        if n_used > 0:
            for name in fisher_accum:
                fisher_accum[name] /= max(1, n_used // bs)
            for orq_name in neuron_fisher_accum:
                neuron_fisher_accum[orq_name] /= max(1, n_used // bs)

        # Smooth + clip Fisher (compute F_computed — smoothed/clipped)
        fisher_computed = self._smooth_and_clip(fisher_accum)
        # V6.1 — Aplicar skip mask também no Fisher CLÁSSICO para
        # orq_cell.neuron_cache (evita dupla penalização: o classic Fisher
        # e o Kohonen per-neuron Fisher apontam para o MESMO parâmetro).
        # Sem isto, o classic Fisher penalizaria neuron_cache mesmo com
        # skip_som_filled_neurons=True, anulando o efeito da skip mask.
        if self.cfg.skip_som_filled_neurons:
            for orq_name, orq_cell in self.orq_cells.items():
                if not hasattr(orq_cell, "neuron_filled_mask"):
                    continue
                param_name = f"{orq_name}.neuron_cache"
                if param_name not in fisher_computed:
                    continue
                filled = orq_cell.neuron_filled_mask.bool()
                keep_mask = (~filled).float()  # 1 onde EWC atua, 0 onde skip
                # Zera o Fisher das posições filled no classic Fisher
                fisher_computed[param_name] = fisher_computed[param_name] * keep_mask.unsqueeze(-1)

        # V7 ── Fisher accumulation (online EWC with EMA) ─────────────────
        # F_new = decay · F_old + (1 - decay) · F_computed
        # Se for a 1ª chamada (não inicializado), apenas copia F_computed.
        # Aplicado APÓS smoothing/clip/skip_mask para herdar as propriedades
        # de estabilidade numérica (log-clip evita explosão de Fisher EMA).
        if self.cfg.online_ewc and self.cfg.fisher_accumulation_active \
                and self._fisher_ema_initialized and len(self.fisher) > 0:
            decay = float(self.cfg.online_decay)
            for name, f_new_computed in fisher_computed.items():
                f_old = self.fisher.get(name)
                if f_old is not None and f_old.shape == f_new_computed.shape:
                    fisher_computed[name] = (
                        decay * f_old + (1.0 - decay) * f_new_computed
                    )
                # else: novo parâmetro — apenas usa F_computed (cold start)
        # Aplica Fisher final (acumulada ou cold-start)
        self.fisher = fisher_computed

        # ── Per-neuron Fisher accumulation (V7) ──────────────────────────
        # Mesma lógica EMA para neuron_fisher (orq_cell).
        neuron_fisher_computed: Dict[str, torch.Tensor] = {}
        self.fisher_neuron_mask = {}  # V6.1
        for orq_name, f_tensor in neuron_fisher_accum.items():
            # V6.1 — Aplicar skip mask: neurônios filled no SOM NÃO recebem Fisher
            orq_cell = self.orq_cells.get(orq_name)
            skip_mask = None
            if (self.cfg.skip_som_filled_neurons and
                    orq_cell is not None and
                    hasattr(orq_cell, "neuron_filled_mask")):
                # filled_mask=True → skip (zerar Fisher). Inverter para "keep_mask"
                filled = orq_cell.neuron_filled_mask.bool()
                skip_mask = ~filled  # True onde EWC DEVE atuar (não-filled)
                # Aplicar: zero out Fisher dos filled
                f_tensor = f_tensor * skip_mask.unsqueeze(-1).float()
                self.fisher_neuron_mask[orq_name] = skip_mask.detach().clone()
            else:
                # Sem skip mask: todos os neurônios são elegíveis
                if f_tensor.size(0) > 0:
                    self.fisher_neuron_mask[orq_name] = torch.ones(
                        f_tensor.size(0), dtype=torch.bool
                    )
            f_smoothed = self._smooth_and_clip_tensor(f_tensor)
            # V7 — EMA accumulation sobre neuron_fisher
            if self.cfg.online_ewc and self.cfg.fisher_accumulation_active \
                    and self._fisher_ema_initialized \
                    and orq_name in self.neuron_fisher:
                f_old = self.neuron_fisher[orq_name]
                if f_old.shape == f_smoothed.shape:
                    decay = float(self.cfg.online_decay)
                    f_smoothed = decay * f_old + (1.0 - decay) * f_smoothed
            neuron_fisher_computed[orq_name] = f_smoothed
        self.neuron_fisher = neuron_fisher_computed

        # Salvar pesos ótimos atuais (anchors) — V7 EMA accumulation
        # W*_new = decay · W*_old + (1 - decay) · W_current
        # Se 1ª chamada, apenas W*_new = W_current (cold start).
        new_optimal_weights = {
            name: p.detach().clone()
            for name, p in self.model.named_parameters()
            if name in self.fisher
        }
        if (self.cfg.online_ewc and self.cfg.fisher_accumulation_active
                and self._fisher_ema_initialized and len(self.optimal_weights) > 0):
            decay = float(self.cfg.online_decay)
            for name, w_new in new_optimal_weights.items():
                w_old = self.optimal_weights.get(name)
                if w_old is not None and w_old.shape == w_new.shape:
                    new_optimal_weights[name] = (
                        decay * w_old + (1.0 - decay) * w_new
                    )
        self.optimal_weights = new_optimal_weights

        # V7 — EMA accumulation para neuron_optimal (orq_cell anchors)
        for orq_name, orq_cell in self.orq_cells.items():
            if orq_name in self.neuron_fisher:
                new_w_n = orq_cell.neuron_cache.detach().clone()
                if (self.cfg.online_ewc and self.cfg.fisher_accumulation_active
                        and self._fisher_ema_initialized
                        and orq_name in self.neuron_optimal):
                    w_old = self.neuron_optimal[orq_name]
                    if w_old.shape == new_w_n.shape:
                        decay = float(self.cfg.online_decay)
                        new_w_n = decay * w_old + (1.0 - decay) * new_w_n
                self.neuron_optimal[orq_name] = new_w_n
                # Salvar topologia do grid (fixa — NÃO sofre EMA, é constante)
                if hasattr(orq_cell, "grid_dist"):
                    self.neuron_grid_dist[orq_name] = orq_cell.grid_dist.detach().clone()

        # V7 — Diagnóstico: média das Fishers acumuladas
        if self.fisher:
            try:
                all_means = [float(f.mean().item()) for f in self.fisher.values()]
                self._fisher_w_mean = sum(all_means) / len(all_means) if all_means else 0.0
            except (RuntimeError, ValueError):
                self._fisher_w_mean = 0.0
        if self.neuron_fisher:
            try:
                all_n_means = [float(f.mean().item()) for f in self.neuron_fisher.values()]
                self._fisher_n_mean = sum(all_n_means) / len(all_n_means) if all_n_means else 0.0
            except (RuntimeError, ValueError):
                self._fisher_n_mean = 0.0

        # V7 — Marca que a Fisher foi inicializada (próxima compute_fisher fará EMA)
        self._fisher_ema_initialized = True

        self.n_tasks += 1
        self.model.zero_grad()
        gc.collect(0)

    # =====================================================================
    # Smoothing + Clipping (V5/V13.2数学)
    # =====================================================================
    def _smooth_and_clip(self, fisher: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
        out = {}
        for name, f in fisher.items():
            out[name] = self._smooth_and_clip_tensor(f)
        return out

    def _smooth_and_clip_tensor(self, f: torch.Tensor) -> torch.Tensor:
        # Fisher smoothing: (1-α) F + α mean(F)
        if self.cfg.fisher_smoothing_alpha > 0:
            mean_f = f.mean()
            f = (1 - self.cfg.fisher_smoothing_alpha) * f + self.cfg.fisher_smoothing_alpha * mean_f
        # Log clipping: log(1 + F/ε) / log(1 + max(F)/ε)
        eps = self.cfg.fisher_clip_eps
        f_max = f.max().clamp(min=eps)
        f = torch.log1p(f / eps) / torch.log1p(f_max / eps)
        return f

    # =====================================================================
    # Compute EWC penalty
    # =====================================================================
    def compute_penalty(
        self,
        bmu_indices: Optional[torch.Tensor] = None,
        eval_mode: bool = False,
    ) -> Tuple[torch.Tensor, Dict[str, float]]:
        """Computa a penalidade EWC V6 (clássica + Kohonen per-neuron + topológica).

        Args:
            bmu_indices: (batch,) — índices BMU atuais (para reforço)
            eval_mode: se True, apenas forward penalty (sem Fisher backward)

        Returns:
            penalty: tensor escalar
            metrics: dict com breakdown da penalidade
        """
        if not self.fisher and not self.neuron_fisher:
            return torch.tensor(0.0, device=next(self.model.parameters()).device), {}

        # V6.1 — Short-circuit: se TODOS os neurônios do SOM estão filled,
        # a skip mask zera todo o Fisher Kohonen. E se o classic Fisher
        # também foi mascarado para orq_cell.neuron_cache (feito em
        # compute_fisher), resta apenas o classic Fisher dos outros params.
        # Para evitar NaN por instabilidade numérica na topological penalty
        # (sqrt de diferenças de normas), pulamos o topo penalty quando
        # todos os neurônios estão filled — a topologia já está sendo
        # preservada pelo próprio SOM (BMU + vizinhos puxados na direção
        # do input, Gaussian Λ).
        all_filled = all(
            hasattr(oc, "neuron_filled_mask") and bool(oc.neuron_filled_mask.all().item())
            for oc in self.orq_cells.values()
        ) if self.orq_cells else False
        if all_filled and self.cfg.skip_som_filled_neurons:
            # SOM cobre tudo — EWC não tem nada a ancorar
            device = next(self.model.parameters()).device
            metrics = {
                "total_pen": 0.0,
                "n_masked_som_neurons": sum(
                    int(oc.neuron_filled_mask.sum().item())
                    for oc in self.orq_cells.values()
                    if hasattr(oc, "neuron_filled_mask")
                ),
                "skipped_reason": "all_som_neurons_filled",
            }
            return torch.tensor(0.0, device=device), metrics

        device = next(self.model.parameters()).device
        metrics: Dict[str, float] = {}
        total_penalty = torch.tensor(0.0, device=device)

        # 1. Penalidade clássica (parâmetros gerais)
        classic_pen = torch.tensor(0.0, device=device)
        n_classic = 0
        for name, p in self.model.named_parameters():
            if name not in self.fisher or name not in self.optimal_weights:
                continue
            f = self.fisher[name].to(device)
            w_star = self.optimal_weights[name].to(device)
            classic_pen = classic_pen + (f * (p - w_star).pow(2)).sum()
            n_classic += 1
        if n_classic > 0:
            classic_pen = classic_pen / max(1, n_classic)
            metrics["classic_pen"] = float(classic_pen.item())
            total_penalty = total_penalty + self._adaptive_lambda * classic_pen

        # 2. Penalidade Kohonen per-neuron
        kohonen_pen = torch.tensor(0.0, device=device)
        n_kohonen = 0
        n_masked_total = 0  # V6.1: contador de neurônios skipped (já no SOM)
        for orq_name, orq_cell in self.orq_cells.items():
            if orq_name not in self.neuron_fisher or orq_name not in self.neuron_optimal:
                continue
            f_n = self.neuron_fisher[orq_name].to(device)  # (n_neurons, d_cache)
            w_star_n = self.neuron_optimal[orq_name].to(device)
            w_n = orq_cell.neuron_cache  # (n_neurons, d_cache)
            # V6.1 — Aplicar fisher_neuron_mask: zero penalty para filled neurons
            keep_mask = self.fisher_neuron_mask.get(orq_name)
            if keep_mask is not None:
                keep_mask_dev = keep_mask.to(device).float()
                f_n_masked = f_n * keep_mask_dev.unsqueeze(-1)
                n_masked_total += int((1 - keep_mask_dev).sum().item())
            else:
                f_n_masked = f_n
            # Σ_n Σ_i F_{n,i} (w_{n,i} - w*_{n,i})^2
            pen_n = (f_n_masked * (w_n - w_star_n).pow(2)).sum()
            # Reforço do BMU: se bmu_indices fornecido, multiplica a penalidade
            # do neurônio BMU por γ_bmu
            if bmu_indices is not None and len(bmu_indices) > 0:
                bmu_counts = torch.zeros(w_n.size(0), device=device)
                for b in bmu_indices.tolist():
                    if 0 <= b < w_n.size(0):
                        bmu_counts[b] += 1.0
                # weight per neuron: 1 + γ*(bmu_counts normalized)
                bmu_weight = 1.0 + (self.cfg.bmu_reinforcement_gamma - 1.0) * (
                    bmu_counts / (bmu_counts.sum() + 1e-8)
                )
                pen_n = (bmu_weight.unsqueeze(-1) * f_n_masked * (w_n - w_star_n).pow(2)).sum()
            kohonen_pen = kohonen_pen + pen_n
            n_kohonen += 1
        if n_kohonen > 0:
            kohonen_pen = kohonen_pen / max(1, n_kohonen)
            metrics["kohonen_pen"] = float(kohonen_pen.item())
            metrics["n_masked_som_neurons"] = n_masked_total  # V6.1
            total_penalty = total_penalty + self._adaptive_lambda * kohonen_pen

        # 3. Penalidade topológica (preserva distâncias no grid 4D)
        topo_pen = torch.tensor(0.0, device=device)
        n_topo = 0
        for orq_name, orq_cell in self.orq_cells.items():
            if orq_name not in self.neuron_grid_dist:
                continue
            grid_d = self.neuron_grid_dist[orq_name].to(device)  # (n,n) — fixa
            w_n = orq_cell.neuron_cache  # (n, d_cache)
            # Distância atual entre neurônios (espaço de pesos)
            sq = (w_n ** 2).sum(dim=-1)
            actual_d = torch.sqrt(
                sq.unsqueeze(0) + sq.unsqueeze(1) - 2 * w_n @ w_n.T
            ).clamp(min=0.0)
            # Penalidade: (actual - grid)^2 — upper triangular
            diff = (actual_d - grid_d).pow(2)
            mask = torch.triu(torch.ones_like(diff), diagonal=1).bool()
            topo_pen = topo_pen + diff[mask].sum()
            n_topo += 1
        if n_topo > 0:
            topo_pen = topo_pen / max(1, n_topo)
            metrics["topo_pen"] = float(topo_pen.item())
            total_penalty = total_penalty + self.cfg.lambda_topo * topo_pen

        metrics["total_pen"] = float(total_penalty.item())
        metrics["adaptive_lambda"] = float(self._adaptive_lambda)
        return total_penalty, metrics

    # =====================================================================
    # Update adaptive lambda (após cada step)
    # =====================================================================
    def update_adaptive_lambda(self, task_loss: torch.Tensor, ewc_penalty: torch.Tensor) -> None:
        """λ_t = λ_0 * ||∇L_task|| / (||∇L_ewc|| + ε)"""
        if not self.cfg.adaptive_lambda:
            return
        try:
            task_grad_norm = 0.0
            ewc_grad_norm = 0.0
            for name, p in self.model.named_parameters():
                if p.grad is None:
                    continue
                g = p.grad.detach()
                task_grad_norm += float(g.norm().item()) ** 2
            ewc_grad_norm = float(ewc_penalty.item()) + 1e-8
            task_grad_norm = math.sqrt(task_grad_norm) + 1e-8
            self._last_task_grad_norm = task_grad_norm
            self._last_ewc_grad_norm = ewc_grad_norm
            # λ adaptive (clipado em [0.1*λ_0, 10*λ_0])
            new_lambda = self.cfg.lambda_ewc * task_grad_norm / ewc_grad_norm
            self._adaptive_lambda = max(
                0.1 * self.cfg.lambda_ewc,
                min(10.0 * self.cfg.lambda_ewc, new_lambda)
            )
        except (RuntimeError, AttributeError):
            pass

    # =====================================================================
    # Status / monitoramento
    # =====================================================================
    def get_status(self) -> Dict[str, Any]:
        n_masked = sum(int((~m).sum().item()) for m in self.fisher_neuron_mask.values()) if self.fisher_neuron_mask else 0
        n_active = sum(int(m.sum().item()) for m in self.fisher_neuron_mask.values()) if self.fisher_neuron_mask else 0
        return {
            "n_tasks": self.n_tasks,
            "n_classic_params": len(self.fisher),
            "n_kohonen_cells": len(self.neuron_fisher),
            "adaptive_lambda": self._adaptive_lambda,
            "lambda_ewc_base": self.cfg.lambda_ewc,
            "lambda_topo": self.cfg.lambda_topo,
            "bmu_reinforcement_gamma": self.cfg.bmu_reinforcement_gamma,
            "last_task_grad_norm": self._last_task_grad_norm,
            "last_ewc_grad_norm": self._last_ewc_grad_norm,
            # V6.1 — info sobre skip mask
            "skip_som_filled_neurons": self.cfg.skip_som_filled_neurons,
            "n_active_ewc_neurons": n_active,    # EWC atua nestes
            "n_masked_som_neurons": n_masked,    # SOM já cobre estes (não duplica)
            # V7 — Fisher accumulation diagnostics
            "online_ewc_active": bool(self.cfg.online_ewc and self.cfg.fisher_accumulation_active),
            "online_decay": float(self.cfg.online_decay),
            "fisher_ema_initialized": bool(self._fisher_ema_initialized),
            "fisher_w_mean": float(self._fisher_w_mean),  # diagnostic
            "fisher_n_mean": float(self._fisher_n_mean),  # diagnostic
        }


__all__ = ["EWCV6", "EWCConfigV6"]