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
| import torch import torch.nn as nn import numpy as np from typing import Tuple
class EEGXplainer: """ EEG-Xplain 统一归因框架 三类归因方法: 1. 梯度归因 (Gradient-based) 2. 扰动归因 (Perturbation-based) 3. 激活归因 (Activation-based) 三个维度: 1. 空间(通道)— 哪些EEG通道最重要 2. 时序(时间)— 哪些时间段最相关 3. 频域(节律)— 哪些频段贡献最大 """ def __init__(self, model: nn.Module): self.model = model self.model.eval() def gradient_attribution( self, eeg_input: torch.Tensor, target_class: int, ) -> torch.Tensor: """ 梯度归因:输入对输出的梯度 Args: eeg_input: (B, C, T) EEG信号 target_class: 目标类别 Returns: attribution: (B, C, T) 归因分数 """ eeg_input.requires_grad_(True) output = self.model(eeg_input) target_score = output[:, target_class].sum() gradients = torch.autograd.grad( target_score, eeg_input, retain_graph=True, create_graph=False, )[0] attribution = gradients * eeg_input return attribution.detach() def perturbation_attribution( self, eeg_input: torch.Tensor, target_class: int, method: str = "channel", ) -> torch.Tensor: """ 扰动归因:逐步遮蔽输入区域,观察输出变化 method: - "channel": 逐通道遮蔽(空间) - "time": 逐时间段遮蔽(时序) - "frequency": 逐频段遮蔽(频域) """ B, C, T = eeg_input.shape baseline_output = torch.softmax( self.model(eeg_input), dim=1 )[:, target_class] attribution = torch.zeros(C, T) if method == "channel": for c in range(C): perturbed = eeg_input.clone() perturbed[:, c, :] = 0 perturbed_output = torch.softmax( self.model(perturbed), dim=1 )[:, target_class] attribution[c, :] = (baseline_output - perturbed_output).mean().item() elif method == "time": window_size = T // 20 for c in range(C): for w in range(0, T, window_size): perturbed = eeg_input.clone() perturbed[:, c, w:w+window_size] = 0 perturbed_output = torch.softmax( self.model(perturbed), dim=1 )[:, target_class] drop = (baseline_output - perturbed_output).mean().item() attribution[c, w:w+window_size] = drop elif method == "frequency": fft_input = torch.fft.rfft(eeg_input, dim=-1) freq_bands = { "delta": (0.5, 4), "theta": (4, 8), "alpha": (8, 13), "beta": (13, 30), "gamma": (30, 100), } freq_attribution = {} for band, (low, high) in freq_bands.items(): perturbed_fft = fft_input.clone() freqs = torch.fft.rfftfreq(T, d=1/250) mask = (freqs >= low) & (freqs <= high) perturbed_fft[:, :, mask] = 0 perturbed = torch.fft.irfft(perturbed_fft, n=T, dim=-1) perturbed_output = torch.softmax( self.model(perturbed), dim=1 )[:, target_class] freq_attribution[band] = (baseline_output - perturbed_output).mean().item() return freq_attribution return attribution def activation_attribution( self, eeg_input: torch.Tensor, target_class: int, ) -> dict: """ 激活归因:分析中间层激活模式 """ activations = {} def hook_fn(name): def fn(module, input, output): activations[name] = output.detach() return fn hooks = [] for name, module in self.model.named_modules(): if "attention" in name or "encoder" in name: hooks.append(module.register_forward_hook(hook_fn(name))) output = self.model(eeg_input) for h in hooks: h.remove() return { "layer_activations": activations, "attention_weights": self._extract_attention(activations), } def _extract_attention(self, activations: dict) -> torch.Tensor: """提取注意力权重""" for name, act in activations.items(): if "attention" in name and act.dim() >= 3: return act.mean(dim=1) return None
class LLMReportGenerator: """ LLM 自然语言报告生成 将结构化归因输出转为可读报告 """ TEMPLATE = """ EEG 模型决策分析报告 ===================== 输入信号: {channels}通道, {duration}秒 模型: {model_name} 预测类别: {prediction} (置信度: {confidence:.1%}) 空间分析: - 关键通道: {top_channels} - 脑区分布: {brain_regions} 时序分析: - 关键时间段: {key_segments} - 最相关时间窗: {peak_window} 频域分析: - 主导节律: {dominant_rhythm} ({rhythm_contribution:.1%}) - 节律贡献排序: {rhythm_ranking} 可靠性评估: - AOPC: {aopc:.3f} (越高越可靠) - 方法一致性: {consistency}/3 神经生理验证: - 与已知标志一致: {physio_match} - 潜在伪相关: {artifacts} 结论: {summary} """ def generate(self, attributions: dict, prediction: str) -> str: return self.TEMPLATE.format( channels=attributions.get("num_channels", "N/A"), duration=attributions.get("duration", "N/A"), model_name=attributions.get("model", "N/A"), prediction=prediction, confidence=attributions.get("confidence", 0), top_channels=attributions.get("top_channels", "N/A"), brain_regions=attributions.get("brain_regions", "N/A"), key_segments=attributions.get("key_segments", "N/A"), peak_window=attributions.get("peak_window", "N/A"), dominant_rhythm=attributions.get("dominant_rhythm", "N/A"), rhythm_contribution=attributions.get("rhythm_contribution", 0), rhythm_ranking=attributions.get("rhythm_ranking", "N/A"), aopc=attributions.get("aopc", 0), consistency=attributions.get("consistency", 0), physio_match=attributions.get("physio_match", "N/A"), artifacts=attributions.get("artifacts", "N/A"), summary=attributions.get("summary", "N/A"), )
if __name__ == "__main__": class DummyEEGModel(nn.Module): def __init__(self, channels=19, num_classes=3): super().__init__() self.conv = nn.Conv1d(channels, 64, 3, 1, 1) self.fc = nn.Linear(64, num_classes) def forward(self, x): x = self.conv(x) return self.fc(x.mean(dim=-1)) model = DummyEEGModel(channels=19) xplainer = EEGXplainer(model) eeg = torch.randn(4, 19, 500) grad_attr = xplainer.gradient_attribution(eeg, target_class=1) print(f"梯度归因: {grad_attr.shape}") chan_attr = xplainer.perturbation_attribution(eeg, 1, "channel") print(f"通道归因: {chan_attr.shape}") freq_attr = xplainer.perturbation_attribution(eeg, 1, "frequency") print(f"频域归因: {freq_attr}") report_gen = LLMReportGenerator() report = report_gen.generate({ "num_channels": 19, "duration": "2.0s", "model": "LaBraM", "confidence": 0.87, "top_channels": "F3, F4, Cz", "brain_regions": "前额叶, 中央区", "dominant_rhythm": "alpha", "rhythm_contribution": 0.42, "rhythm_ranking": "alpha(42%) > beta(28%) > theta(18%) > delta(8%) > gamma(4%)", "aopc": 0.234, "consistency": 3, "physio_match": "是(α不对称性与情绪一致)", "artifacts": "无", "summary": "模型决策基于前额叶α不对称性,与已知情绪标志一致", }, "stress") print(report)
|