""" iAdapter_main.py —— 整合优化版主文件 (数据清洗读取 + 在线多模态预...
Creado el: 7 de julio de 2026
Respondido usando GPT-5.5 Thinking por Chat01
Creado el: 7 de julio de 2026
Respondido usando GPT-5.5 Thinking por Chat01
把 RealTextProvider (Time-MMD 9 域真实文本的读取/清洗/抗泄漏对齐) 直接
融入 iAdapter 主流程, 完全替换模板文本生成 (TrafficTextGenerator 已删除)。
单文件即可运行; adapt_z.py 可选依赖 (存在则复用 set_seed / calculate_metrics /
settings, 缺失则使用本文件等价实现, 不影响结果)。
数据域 (Time-MMD, NeurIPS 2024, github.com/AdityaLab/Time-MMD; 合并格式
来自其配套库 github.com/AdityaLab/MM-TSFlib):
Agriculture, Climate, Economy, Energy, Environment,
Health (=Health_US), Security, SocialGood, Traffic
两种数据布局自动探测:
A) MM-TSFlib 合并: <data_dir>/<Domain>.csv
(OT, Date, start_date, date, end_date,
prior_history_avg, prior_history_std, fact, preds)
B) Time-MMD 原始: <data_dir>/numerical/<D>/<D>.csv
+ <data_dir>/textual/<D>/<D>_report.csv / _search.csv
─────────────────────────────────────────────────────────────────────
相对 adapt_z_multimodal.py 的优化清单 (方法本身不变: 骨干与 LLM 全程冻结,
MoE 异构专家融合 + 双梯度反馈的「在线向量空间多模态预测」):
[O1] 真实文本原生接入: _encode_text 按「窗口末行全局行号」取
RealTextProvider 清洗文本; 抗泄漏与 MM-TSFlib 约束一致 (输入文本
最晚 end_date <= 数值输入最晚 end_date, 未来目标行文本不可见)。
[O2] his_grad 计算改用 torch.autograd.grad(loss, z):
· 原实现 z 不在任何优化器中且从不清零 grad, loss_b.backward()
使 his_grad 成为 无界累加和, 量级随在线步数线性膨胀;
· autograd.grad 不污染骨干/适配器参数梯度, 也无需 retain_graph;
· his_grad 更新策略可选 --his_mode {ema,latest,sum}:
ema (默认, momentum=0.9) 平滑稳定; latest 取最新;
sum 复现旧行为 (仅供对照)。∂L/∂text_emb 同理改 autograd.grad。
[O3] 冻结 LLM 池化缓存: 文本 -> pooled 表征只算一次 (LLM 冻结故合法),
仅小型可训练投影 MLP 每次重算; 缓存跨 5 个消融模式共享,
BERT 前向次数约降一个数量级。simple 编码器同理缓存 token ids。
[O4] 缓冲区瘦身: 不再存 (B,N,D) 的 his_grad expand 副本 (只存每批
一份 (N,D) 快照 + 行映射); x/feature 缓冲只保留切片所需批数。
[O5] 缓冲区文本索引精确化: 原 global_idx - x_b.size(0) 有 pred_len
错位, 现为 global_idx - pred_len - B_b 起 (与数值切片严格对齐)。
[O6] val 阶段不再为整个骨干构造从不 step 的 Adam (mode!='train' 跳过)。
[O7] 图例字典 "iAdapter】" 全角括号 typo 修复; 其余绘图与消融
标签/文件名保持不变, 论文出图管线无缝衔接。
[O8] 骨干 checkpoint 缺失时自动预训练 (早停+保存), 9 域即开即用;
lr 配置按域运行期注入 settings (不修改 adapt_z.py)。
[O9] AddFusionAdapter 的 lr 参数改为直接按 (N,D) 构造
(原先先建 (B,N,D) 再取 [0], 无谓分配); 语义完全一致。
[O10] --preview N 纯文本预览模式 (无 torch/依赖也能跑), 便于先检查
各域清洗质量; --text_source {fact,preds,both} 支持模态来源消融。
消融模式不变 (静态基线移除, 以 M1 为参照):
M1 Unimodal(ADAPT-Z) M2 +Text M3 +Text+Grad M4 MoE Fusion
M5 iAdapter(MoE+TextGrad, ours)
用法:
python iAdapter_main.py --data Traffic
python iAdapter_main.py --data all --text_source fact
python iAdapter_main.py --data Health --modes m1,m5 --his_mode ema
python iAdapter_main.py --data Economy --preview 3 # 仅看清洗文本
"""
from future import annotations
import os
import re
import math
import random
import argparse
import itertools
from collections import Counter
import numpy as np
import pandas as pd
from transformers import (BertConfig, BertModel, BertTokenizer,
GPT2Config, GPT2Model, GPT2Tokenizer,
AutoTokenizer)
_RUNTIME_OK, _IMPORT_ERR = True, None
try:
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from tqdm import tqdm
from torch.utils.data import Dataset, DataLoader
from sklearn.preprocessing import StandardScaler
except Exception as _e: # pragma: no cover
_RUNTIME_OK, _IMPORT_ERR = False, _e
from types import SimpleNamespace
textclass Dataset: # 占位 pass class _ShimModule: # 仅供类定义通过; pass # 实例化受 _RUNTIME_OK 守护 class _ShimNoGrad: # @torch.no_grad() 占位 def __call__(self, f): return f def __enter__(self): return self def __exit__(self, *a): return False nn = SimpleNamespace(Module=_ShimModule) torch = SimpleNamespace(no_grad=lambda: _ShimNoGrad())
try:
from adapt_z import set_seed, calculate_metrics, settings
except Exception:
settings = {}
textdef set_seed(seed: int): random.seed(seed) np.random.seed(seed) if _RUNTIME_OK: torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def calculate_metrics(preds, truths): mae = float(np.mean(np.abs(preds - truths))) mse = float(np.mean((preds - truths) ** 2)) return mae, mse
DOMAINS = ["Agriculture", "Climate", "Economy", "Energy", "Environment",
"Health_US", "Security", "SocialGood", "Traffic"]
_DOMAIN_ALIASES = {d.lower(): d for d in DOMAINS}
_DOMAIN_ALIASES.update({"health": "Health_US", "health_us": "Health_US",
"socialgood": "SocialGood", "social_good": "SocialGood"})
_DOMAIN_TOPIC = {
"Agriculture": "agriculture",
"Climate": "climate and drought",
"Economy": "economy and international trade",
"Energy": "energy and fuel prices",
"Environment": "environment and air quality",
"Health_US": "public health and influenza-like illness",
"Security": "security and disaster assistance",
"SocialGood": "social good and unemployment",
"Traffic": "traffic and transportation",
}
_DOMAIN_WINDOW = {
"Agriculture": (36, 6), "Climate": (36, 6), "Economy": (36, 6),
"Energy": (48, 8), "Environment": (96, 12),
"Health_US": (96, 12), "Security": (36, 6),
"SocialGood": (36, 6), "Traffic": (48, 6),
}
def canonical_domain(name: str) -> str:
key = os.path.splitext(str(name).strip())[0].lower()
if key in _DOMAIN_ALIASES:
return _DOMAIN_ALIASES[key]
raise ValueError(f"Unknown domain '{name}'. Choose from {DOMAINS} "
f"(alias: Health -> Health_US).")
_DATE_SEG = re.compile(r"(\d{4}-\d{2}-\d{2})\s*:\s*")
_DROP_PATTERNS = [re.compile(p, re.IGNORECASE) for p in [
r"^\s*(?:na|n/?a)\b",
r"\bno (?:objective facts?|relevant|direct|specific|useful) ",
r"\bthere (?:is|are|was|were) no\b",
r"\bno (?:predictions?|information|basis|data)\b",
r"\bit is impossible to make\b",
r"\bunable to (?:extract|make|provide|glean)\b",
r"\bcould(?: not|n't) (?:provide|extract|be gleaned)\b",
r"\bsearch results (?:do not|did not|don't|didn't|seem to be|appear to be|are (?:mostly )?unrelated|primarily focused|were not relevant)\b",
r"\bresults (?:are|appear) (?:mostly )?(?:unrelated|irrelevant)\b",
r"\bunfortunately\b",
r"\bno useful information\b",
r"\bnot relevant to (?:making )?predict",
r"^\s*(?:long|short)[- ]?term predictions?\s*:?\s*",
r"^\spredictions you can make for the (?:long|short)[- ]?term future[^:]:?\s*$",
]]
_STRIP_PATTERNS = [re.compile(p, re.IGNORECASE) for p in [
r"here is (?:the |my )?[^:;]{0,140}:",
r"note\s*:[^;]",
r"after reviewing the search results[^:;,.][,:.]?",
r"based on the (?:provided )?search results[^:;,.][,:.]?",
r"since (?:the search results|there is no relevant information)[^;]",
r"the (?:other|majority of the|provided|first) [^;]{0,60}(?:results?|title)[^;]{0,120}(?:relevant|related|irrelevant|discarded|summary)[^;]",
r"therefore,? (?:the predictions and analysis|my output will be|no useful information)[^;]",
r"as a result,? i must conclude[^;]",
r"i (?:was able to|have) (?:extract|found)[^;]{0,160}(?::|.)",
r"predictions you can make for the (?:long|short)[- ]?term future\s(?:)?\s*:\s*",
r"^\s*(?:my )?(?:long|short)[- ]?term predictions?\s*(?:are)?\s*:\s*",
r"^\sthe objective facts about [^:]{0,120}(?:are|situation)\s:\s*",
r"",
r"",
]]
_WS = re.compile(r"\s+")
def _clean_clause(s: str) -> str:
"""清洗单个子句; 无信息量返回 ''。"""
if not s:
return ""
s = re.sub(r"\bNA(?=[A-Z(])", " ", s) # 修复 "NAHere is" 拼接错位
for pat in _DROP_PATTERNS:
if pat.search(s):
return ""
for pat in _STRIP_PATTERNS:
s = pat.sub(" ", s)
s = _WS.sub(" ", s).strip(" ;,.-")
if not s:
return ""
for pat in _DROP_PATTERNS: # 剥离后复检
if pat.search(s):
return ""
return s
def split_date_segments(text: str):
"""'d1: seg1; d2: seg2 ...' -> [(date|None, body), ...] (按出现顺序)。"""
if not isinstance(text, str) or not text.strip():
return []
parts = _DATE_SEG.split(text)
out = []
if parts[0].strip():
out.append((None, parts[0]))
for i in range(1, len(parts) - 1, 2):
out.append((parts[i], parts[i + 1]))
return out
def clean_timemmd_text(raw, max_clauses: int = 6, min_clause_chars: int = 20):
"""
清洗一个 fact/preds 单元格 -> (clean_text, n_kept, n_total)。
保留 "YYYY-MM-DD:" 日期锚点; 信息量优先保留 最新 日期段
(对在线预测最相关), 输出按时间正序。
"""
segs = split_date_segments(raw)
n_total, kept = 0, []
for pos, (d, body) in enumerate(segs):
for clause in body.split(";"):
n_total += 1
c = _clean_clause(clause)
if c and len(c) >= min_clause_chars:
kept.append((pos, d, c))
kept = kept[-max_clauses:] if max_clauses > 0 else kept
pieces, last_d = [], None
for _pos, d, c in kept:
if d and d != last_d:
pieces.append(f"{d}: {c}")
last_d = d
else:
pieces.append(c)
return " ".join(pieces), len(kept), n_total
_TEXT_LIKE_COLS = {"date", "Date", "start_date", "end_date", "fact", "pred",
"preds", "Final_Output", "Final_Search_8", "Final_Search_16"}
_HISTORY_STAT_COLS = {"prior_history_avg", "prior_history_std"}
class RealTextProvider:
"""
按数值行序供给清洗后的真实文本 (替换旧 TrafficTextGenerator):
· self.numeric : (n_rows, C) float32, NaN 已插值/填补, 未标准化;
· self.row_text : 每个数值行清洗后的文本;
· text_for_row(r): 行 r 及其前 text_lookback-1 行的聚合文本
(全在历史侧); 原始布局匹配时强制 text.end_date <= 数值行.end_date
—— 与 MM-TSFlib 抗泄漏约束一致, 未来目标行文本永不可见。
"""
textSIMPLE_MAX_LEN = 48 _VOCAB_CAP = 4000 _TOKEN = re.compile(r"[a-z0-9][a-z0-9\-']*") def __init__(self, domain: str, data_dir: str = "./data", text_source: str = "both", text_lookback: int = 2, max_clauses: int = 6, max_chars: int = 1200, min_clause_chars: int = 20, raw_text_topk: int = 6, raw_lookback_days: int = 60, keep_history_stats: bool = False, ot_only: bool = False, verbose: bool = True): self.domain = canonical_domain(domain) self.data_dir = data_dir if text_source not in ("fact", "preds", "both"): raise ValueError("text_source must be fact/preds/both") self.text_source = text_source self.text_lookback = max(1, text_lookback) self.max_clauses = max_clauses self.max_chars = max_chars self.min_clause_chars = min_clause_chars self.raw_text_topk = raw_text_topk self.raw_lookback_days = raw_lookback_days self.keep_history_stats = keep_history_stats self.ot_only = ot_only self.verbose = verbose self._fallback = (f"No notable {_DOMAIN_TOPIC[self.domain]} " f"developments reported in this period.") layout = self._resolve_paths() if layout[0] == "merged": self._load_merged(layout[1]) else: self._load_raw(*layout[1:]) self._build_row_texts() self._word2idx = None # [O3] 冻结编码器缓存: {cache_key: {global_row: pooled/ids}} self.enc_cache: dict = {} if verbose: self.coverage_report() # ── 路径探测 ── def _resolve_paths(self): d, dd = self.domain, self.data_dir names = [d] + (["Health"] if d == "Health_US" else []) for nm in names: for p in (os.path.join(dd, f"{nm}.csv"), os.path.join(dd, nm, f"{nm}.csv")): if os.path.isfile(p): if self.verbose: print(f"[provider] layout=merged {p}") return ("merged", p) for nm in names: num = os.path.join(dd, "numerical", nm, f"{nm}.csv") rep = os.path.join(dd, "textual", nm, f"{nm}_report.csv") sea = os.path.join(dd, "textual", nm, f"{nm}_search.csv") if os.path.isfile(num) and (os.path.isfile(rep) or os.path.isfile(sea)): if self.verbose: print(f"[provider] layout=raw num={num}") return ("raw", num, rep if os.path.isfile(rep) else None, sea if os.path.isfile(sea) else None) raise FileNotFoundError( f"No data for domain '{d}' under '{dd}'. Expected '{d}.csv' " f"(MM-TSFlib merged) or numerical/{d}/{d}.csv + textual/{d}/*.csv " f"(Time-MMD raw). Data: https://github.com/AdityaLab/Time-MMD") # ── 数值列选择 (NaN 线性插值, 与 TSlib uea.interpolate_missing 同法) ── def _pick_numeric(self, df: pd.DataFrame): drop = set(_TEXT_LIKE_COLS) if not self.keep_history_stats: drop |= _HISTORY_STAT_COLS cols = [c for c in df.columns if c not in drop and pd.api.types.is_numeric_dtype(df[c])] if self.ot_only and "OT" in cols: cols = ["OT"] elif self.ot_only: print("[provider] WARN: ot_only=True but no 'OT' column; using all.") if not cols: raise ValueError(f"No numeric columns for {self.domain}") sub = df[cols].apply( lambda y: y.interpolate(method="linear", limit_direction="both") if y.isna().any() else y) vals = sub.values.astype(np.float32) if np.isnan(vals).any(): mu = np.nanmean(vals, axis=0) for j in range(vals.shape[1]): m = np.isnan(vals[:, j]) if m.any(): vals[m, j] = 0.0 if np.isnan(mu[j]) else mu[j] return vals, cols @staticmethod def _parse_dates(df, prefer=("end_date", "date", "Date", "start_date")): for c in prefer: if c in df.columns: s = pd.to_datetime(df[c], errors="coerce") if s.notna().any(): return s return None # ── 布局 A: MM-TSFlib 合并 ── def _load_merged(self, path): df = pd.read_csv(path) dates = self._parse_dates(df) if dates is not None: order = np.argsort(dates.values.astype("datetime64[ns]"), kind="stable") if not np.all(order == np.arange(len(df))): df = df.iloc[order].reset_index(drop=True) dates = dates.iloc[order].reset_index(drop=True) self.df, self.dates = df, dates self.numeric, self.channels = self._pick_numeric(df) fc = "fact" if "fact" in df.columns else None pc = next((c for c in ("preds", "pred") if c in df.columns), None) self._raw_fact = (df[fc].astype(str).where(df[fc].notna(), "") if fc else pd.Series([""] * len(df))) self._raw_pred = (df[pc].astype(str).where(df[pc].notna(), "") if pc else pd.Series([""] * len(df))) if fc is None and pc is None: print(f"[provider] WARN: no fact/preds column in {path}; " f"neutral fallback text will be used for all rows.") # ── 布局 B: Time-MMD 原始 (区间匹配 + 抗泄漏: text.end <= 数值行.end) ── def _load_raw(self, num_path, rep_path, sea_path): num = pd.read_csv(num_path) num["_end"] = self._parse_dates(num, ("end_date", "date", "Date")) num = num.sort_values("_end", kind="stable").reset_index(drop=True) self.df, self.dates = num, num["_end"] self.numeric, self.channels = self._pick_numeric(num) frames = [] for p in (rep_path, sea_path): if p is None: continue t = pd.read_csv(p) t["_end"] = self._parse_dates(t, ("end_date", "date", "Date")) pc = next((c for c in ("pred", "preds") if c in t.columns), None) frames.append(pd.DataFrame({ "_end": t["_end"], "fact": (t["fact"].astype(str).where(t["fact"].notna(), "") if "fact" in t.columns else ""), "pred": (t[pc].astype(str).where(t[pc].notna(), "") if pc else ""), })) txt = (pd.concat(frames, ignore_index=True) .dropna(subset=["_end"]).sort_values("_end", kind="stable") .reset_index(drop=True)) if frames else pd.DataFrame( columns=["_end", "fact", "pred"]) n, facts, preds = len(num), [""] * len(num), [""] * len(num) ends, j = txt["_end"].values, 0 lb = pd.Timedelta(days=self.raw_lookback_days) for i in range(n): e = num["_end"].iloc[i] if pd.isna(e): continue while j < len(txt) and ends[j] <= np.datetime64(e): j += 1 fp, pp = [], [] for k in range(max(0, j - self.raw_text_topk), j): te = txt["_end"].iloc[k] if e - te > lb: continue dstr = pd.Timestamp(te).strftime("%Y-%m-%d") if txt["fact"].iloc[k].strip(): fp.append(f"{dstr}: {txt['fact'].iloc[k]}") if txt["pred"].iloc[k].strip(): pp.append(f"{dstr}: {txt['pred'].iloc[k]}") facts[i], preds[i] = "; ".join(fp), "; ".join(pp) self._raw_fact, self._raw_pred = pd.Series(facts), pd.Series(preds) # ── 逐行清洗 ── def _build_row_texts(self): n = len(self.numeric) self.row_text, self.row_has_info = [], np.zeros(n, dtype=bool) half = max(2, self.max_clauses // 2) for i in range(n): parts = [] if self.text_source in ("fact", "both"): fc, k, _ = clean_timemmd_text( self._raw_fact.iloc[i], self.max_clauses if self.text_source == "fact" else half, self.min_clause_chars) if k: parts.append("Facts. " + fc) if self.text_source in ("preds", "both"): pc, k, _ = clean_timemmd_text( self._raw_pred.iloc[i], self.max_clauses if self.text_source == "preds" else half, self.min_clause_chars) if k: parts.append("Outlook. " + pc) if parts: self.row_text.append(" ".join(parts)) self.row_has_info[i] = True else: self.row_text.append(self._fallback) # ── 对外接口 ── @property def n_rows(self): return len(self.row_text) def text_for_row(self, r: int) -> str: r = int(min(max(r, 0), self.n_rows - 1)) parts, seen = [], set() for rr in range(max(0, r - self.text_lookback + 1), r + 1): if self.row_has_info[rr] and self.row_text[rr] not in seen: parts.append(self.row_text[rr]) seen.add(self.row_text[rr]) joined = " ".join(parts) if parts else self._fallback if len(joined) > self.max_chars: # 左截, 保最新 joined = joined[-self.max_chars:] sp = joined.find(" ") if 0 < sp < 80: joined = joined[sp + 1:] return joined def set_text_lookback(self, lookback: int): """按当前 horizon 调整聚合的历史文本行数。改变 lookback 会改变 text_for_row 的输出, 故清空冻结编码缓存, 避免命中旧文本。""" new_lb = max(1, int(lookback)) if new_lb != self.text_lookback: self.text_lookback = new_lb self.enc_cache.clear() if self.verbose: print(f"[provider] text_lookback -> {new_lb} " f"(enc_cache cleared)") # ── simple 编码器词表 (真实语料) ── def _ensure_vocab(self): if self._word2idx is not None: return cnt = Counter() for i, t in enumerate(self.row_text): if self.row_has_info[i]: cnt.update(self._TOKEN.findall(t.lower())) common = [w for w, _ in cnt.most_common(self._VOCAB_CAP - 2)] self._word2idx = {"<pad>": 0, "<unk>": 1} self._word2idx.update({w: i + 2 for i, w in enumerate(common)}) @property def vocab_size(self): self._ensure_vocab() return len(self._word2idx) def tokenize(self, text: str, max_len: int | None = None): self._ensure_vocab() L = max_len or self.SIMPLE_MAX_LEN ids = [self._word2idx.get(t, 1) for t in self._TOKEN.findall(text.lower())][:L] return ids + [0] * (L - len(ids)) # ── 诊断 ── def coverage_report(self): n, info = self.n_rows, int(self.row_has_info.sum()) lens = np.array([len(t) for t in self.row_text]) freq = "" if self.dates is not None and self.dates.notna().sum() > 2: freq = (f" median_step=" f"{self.dates.dropna().diff().dt.days.median():.0f}d") print(f"[provider] {self.domain}: rows={n} " f"channels={len(self.channels)} {self.channels[:5]}" f"{'...' if len(self.channels) > 5 else ''} " f"informative={info}/{n} ({100 * info / max(n, 1):.1f}%) " f"clean_chars mean={lens.mean():.0f} max={lens.max()}{freq} " f"source={self.text_source}") def preview(self, k: int = 3): for r in np.linspace(0, self.n_rows - 1, num=min(k, self.n_rows), dtype=int): d = (self.dates.iloc[r].date() if self.dates is not None and pd.notna(self.dates.iloc[r]) else "?") raw = " | ".join(s for s in (str(self._raw_fact.iloc[r])[:150], str(self._raw_pred.iloc[r])[:150]) if s and s != "nan") print(f"\n──[{self.domain}] row {r} date={d} " f"informative={bool(self.row_has_info[r])}") print(f" RAW : {raw[:320]}{'...' if len(raw) > 320 else ''}") print(f" CLEAN : {self.row_text[r][:320]}" f"{'...' if len(self.row_text[r]) > 320 else ''}")
class TimeSeriesDataset(Dataset):
"""Sliding-window dataset. Returns (x, y, x_mark_placeholder, ts_start_idx)."""
def init(self, data: np.ndarray, seq_len: int, pred_len: int):
self.data = data.astype(np.float32)
self.seq_len, self.pred_len = seq_len, pred_len
textdef __len__(self): return max(0, len(self.data) - self.seq_len - self.pred_len + 1) def __getitem__(self, idx): x = self.data[idx: idx + self.seq_len] y = self.data[idx + self.seq_len: idx + self.seq_len + self.pred_len] return (torch.from_numpy(x), torch.from_numpy(y), torch.zeros(self.seq_len, 4), idx)
def auto_window(n_rows: int, seq_len: int, pred_len: int,
min_train: int = 20, min_test: int = 5):
"""按数据长度自动收缩窗口, 保证各 split 有足够样本。"""
s, p = seq_len, pred_len
while True:
t_end = int(n_rows * 0.6)
train_n = t_end - s - p + 1
test_n = (n_rows - max(0, int(n_rows * 0.8) - s)) - s - p + 1
if (train_n >= min_train and test_n >= min_test) or (s <= 8 and p <= 2):
break
if s > 8:
s = max(8, s // 2)
elif p > 2:
p = max(2, p // 2)
else:
break
if (s, p) != (seq_len, pred_len):
print(f"[data] auto-shrink window: seq_len {seq_len}->{s}, "
f"pred_len {pred_len}->{p} (n_rows={n_rows})")
return s, p
def _dominant_period(mat, min_period=4, frac_max=0.5):
"""对所有通道去线性趋势后取平均功率谱, 返回主周期 (无显著周期则 None)。"""
X = np.asarray(mat, dtype=float)
if X.ndim == 1:
X = X[:, None]
n = X.shape[0]
if n < 2 * min_period:
return None
t = np.arange(n)
Xd = np.empty_like(X)
for j in range(X.shape[1]):
col = X[:, j]
m = ~np.isnan(col)
if m.sum() < 2:
Xd[:, j] = 0.0
continue
s = np.polyfit(t[m], col[m], 1) # 去线性趋势, 避免低频伪峰
Xd[:, j] = np.nan_to_num(col - (s[0] * t + s[1]))
power = (np.abs(np.fft.rfft(Xd, axis=0)) ** 2).mean(axis=1)
freqs = np.fft.rfftfreq(n)
power[0] = 0.0 # 去 DC
max_period = int(n * frac_max)
best_p, best_pw = None, -1.0
for k in range(1, len(freqs)):
if freqs[k] <= 0:
continue
p = 1.0 / freqs[k]
if min_period <= p <= max_period and power[k] > best_pw:
best_pw, best_p = power[k], p
return best_p
def adaptive_seq_len(provider, pred_len, , min_mult=2.0, max_mult=6.0,
cover_cycles=2.0, floor=8, frac_cap=0.5, verbose=True):
"""按 (horizon, 数据周期性, 数据长度) 自适应回溯窗口。
· 下界: 至少 min_mult×pred_len; 若检测到主周期 P, 抬到覆盖 cover_cycles 个周期;
· 上界: 不超过 max_mult×pred_len, 也不超过 frac_cap×n_rows;
· 最终仍由 auto_window 按 split 样本量二次收缩。"""
n = provider.n_rows
lo = min_mult * pred_len
P = _dominant_period(provider.numeric)
if P is not None:
lo = max(lo, cover_cycles * P)
hi = min(max_mult * pred_len, frac_cap * n)
L = int(round(min(max(lo, floor), max(hi, floor))))
L = max(L, floor)
if verbose:
print(f"[adaptive] {provider.domain} h={pred_len}: "
f"period={None if P is None else round(P, 1)} "
f"-> seq_len={L} (band[{int(min_multpred_len)},"
f"{int(max_multpred_len)}], cap{int(frac_capn)})")
return L
def load_real_data_mm(provider: RealTextProvider, seq_len: int, pred_len: int):
"""
与原 load_real_data 相同的 60/20/20 切分与 scaler 逻辑, 额外返回每个
split 的原始行偏移 offsets —— 窗口索引 w 的输入窗末行全局行号
= offsets[split] + w + seq_len - 1 (真实文本对齐的关键)。
"""
values = provider.numeric
n = len(values)
t_end, v_end = int(n * 0.6), int(n * 0.8)
scaler = StandardScaler().fit(values[:t_end])
data = scaler.transform(values).astype(np.float32)
textval_start = max(0, t_end - seq_len) test_start = max(0, v_end - seq_len) train = TimeSeriesDataset(data[:t_end], seq_len, pred_len) val = TimeSeriesDataset(data[val_start:v_end], seq_len, pred_len) test = TimeSeriesDataset(data[test_start:], seq_len, pred_len) offsets = {"train": 0, "val": val_start, "test": test_start} print(f"[data] {provider.domain} rows={n} channels={values.shape[1]} " f"train={len(train)} val={len(val)} test={len(test)} " f"offsets={offsets}") if len(train) == 0 or len(test) == 0: raise ValueError( f"Dataset '{provider.domain}' too short (rows={n}) for " f"seq_len={seq_len}, pred_len={pred_len}. Use the full Time-MMD " f"csv or reduce --seq_len/--pred_len.") return train, val, test, scaler, offsets
_DOMAIN_LRS = {
# 长序列 / 通道多 / 波动大 → 适配器学习率偏小,稳一点
"Environment": dict(finetune_lr=1e-3, adapter_lr=5e-4, online_lr=5e-5, adp_online_lr=5e-4),
"Health_US": dict(finetune_lr=1e-3, adapter_lr=5e-4, online_lr=5e-5, adp_online_lr=5e-4),
"Energy": dict(finetune_lr=1e-3, adapter_lr=8e-4, online_lr=8e-5, adp_online_lr=8e-4),
# 中短序列 / 样本较少 → 适配器学习率偏大,收敛快一点
"Agriculture": dict(finetune_lr=1e-3, adapter_lr=1.2e-3, online_lr=1e-4, adp_online_lr=1.2e-3),
"Climate": dict(finetune_lr=1e-3, adapter_lr=1e-3, online_lr=1e-4, adp_online_lr=1e-3),
"Economy": dict(finetune_lr=1e-3, adapter_lr=1e-3, online_lr=1e-4, adp_online_lr=1e-3),
"Security": dict(finetune_lr=1e-3, adapter_lr=1.2e-3, online_lr=1e-4, adp_online_lr=1.2e-3),
"SocialGood": dict(finetune_lr=1e-3, adapter_lr=1.2e-3, online_lr=1e-4, adp_online_lr=1.2e-3),
"Traffic": dict(finetune_lr=1e-3, adapter_lr=1.2e-2, online_lr=1e-4, adp_online_lr=1.e-2),
}
_DEFAULT_LRS = dict(finetune_lr=1e-3, adapter_lr=1e-3,
online_lr=1e-4, adp_online_lr=1e-3)
def ensure_settings(model_name: str, domain: str, overrides=None):
"""按域向 settings 注入 lr 配置(运行期注入, 不改 adapt_z.py)。 [O8]
· 每个域用 _DOMAIN_LRS 单独指定; 缺失回退 _DEFAULT_LRS;
· val 读 adapter_lr, online 读 adp_online_lr + online_lr;
· CLI overrides 优先级最高。"""
dom = canonical_domain(domain)
data_key = dom.lower() # 与 OnlineMM 的查表键一致
tbl = settings.setdefault(model_name, {})
base = dict(_DOMAIN_LRS.get(dom, _DEFAULT_LRS))
if data_key not in tbl:
tbl[data_key] = dict(base)
print(f"[settings] injected '{model_name}/{data_key}': {tbl[data_key]}")
else:
for k, v in base.items(): # 补齐缺失键,不覆盖已有
tbl[data_key].setdefault(k, v)
if overrides:
applied = {k: v for k, v in overrides.items() if v is not None}
if applied:
tbl[data_key].update(applied)
print(f"[settings] overrides applied ({data_key}): {applied}")
def compute_offset_features(x):
"""
分布偏移统计特征 (B, 8): 供 MoE 门控感知 concept drift。
x 已被训练集 StandardScaler 归一化, 故训练分布 ≈ N(0,1)。
"""
B, L, N = x.shape
flat = x.reshape(B, -1)
mean_all = flat.mean(dim=1)
std_all = flat.std(dim=1, unbiased=False)
q90 = torch.quantile(flat, 0.90, dim=1)
q10 = torch.quantile(flat, 0.10, dim=1)
q = max(1, L // 4)
trend = x[:, -q:, :].mean(dim=(1, 2)) - x[:, :q, :].mean(dim=(1, 2))
diff_std = (x[:, 1:, :] - x[:, :-1, :]).std(dim=(1, 2), unbiased=False)
last_level = x[:, -1, :].mean(dim=1)
return torch.stack([
mean_all, mean_all.abs(), std_all, (std_all - 1.0).abs(),
q90 - q10, trend, trend.abs(), diff_std + last_level.abs() * 0.1,
], dim=-1)
class SimpleTextEncoder(nn.Module):
"""轻量可训练文本编码器 (Embedding + Transformer + mean-pool)。"""
def init(self, vocab_size, embed_dim=64, output_dim=64, max_len=48,
nhead=4, num_layers=2, dropout=0.1):
super().init()
self.token_embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.pos_embed = nn.Embedding(max_len, embed_dim)
enc = nn.TransformerEncoderLayer(d_model=embed_dim, nhead=nhead,
dim_feedforward=embed_dim * 2,
dropout=dropout, batch_first=True)
self.transformer = nn.TransformerEncoder(enc, num_layers=num_layers)
self.proj = nn.Linear(embed_dim, output_dim)
self.norm = nn.LayerNorm(output_dim)
textdef forward(self, ids): if ids.dim() == 1: ids = ids.unsqueeze(0) B, L = ids.shape pos = torch.arange(L, device=ids.device).unsqueeze(0) x = self.token_embed(ids) + self.pos_embed(pos) mask = (ids == 0) x = self.transformer(x, src_key_padding_mask=mask) m = (~mask).float().unsqueeze(-1) x = (x * m).sum(1) / m.sum(1).clamp(min=1) return self.norm(self.proj(x))
class LLMTextEncoder(nn.Module):
"""
冻结 LLM 文本编码器 (BERT / GPT2), 仅投影 MLP 可训练 —— 不微调。
[O3] 拆分为 pool() (no_grad, 结果可按行缓存) 与 project() (可训练):
forward(texts) == project(pool(texts))。
"""
_SPECS = {
"GPT2": ("gpt2", "openai-community/gpt2", 768),
"GPT2M": ("gpt2", "openai-community/gpt2-medium", 1024),
"BERT": ("bert", "/home/ubuntu/strongmodel/bert-base-uncased", 768),
}
textdef __init__(self, llm_tag="BERT", output_dim=64, use_fullmodel=False, llm_layers=6, max_length=64, dropout=0.1, device="cpu"): super().__init__() if llm_tag not in self._SPECS: raise ValueError(f"Unknown llm_tag '{llm_tag}'") kind, hf_name, hidden = self._SPECS[llm_tag] self.hidden = hidden self.use_fullmodel = use_fullmodel self.max_length = max_length self._device = torch.device(device) def _load(loader, name, **extra): _script_dir = os.path.dirname(os.path.abspath(__file__)) _basename = name.split("/")[-1] for _local in [os.path.join(_script_dir, _basename), os.path.join(os.getcwd(), _basename)]: _zip = _local + ".zip" if not os.path.isdir(_local) and os.path.isfile(_zip): import zipfile print(f"[LLMTextEncoder] Extracting {_zip}") with zipfile.ZipFile(_zip, "r") as zf: zf.extractall(os.path.dirname(_zip)) if os.path.isdir(_local): try: return loader.from_pretrained(_local, local_files_only=True, **extra) except Exception: pass try: return loader.from_pretrained(name, local_files_only=True, **extra) except Exception: pass return loader.from_pretrained(name, **extra) if kind == "bert": cfg = _load(BertConfig, hf_name) cfg.num_hidden_layers = llm_layers self.llm = _load(BertModel, hf_name, config=cfg, trust_remote_code=True) try: self.tok = _load(BertTokenizer, hf_name) except Exception: self.tok = _load(AutoTokenizer, hf_name) else: cfg = _load(GPT2Config, hf_name) cfg.num_hidden_layers = llm_layers self.llm = _load(GPT2Model, hf_name, config=cfg, trust_remote_code=True) try: self.tok = _load(GPT2Tokenizer, hf_name) except Exception: self.tok = _load(AutoTokenizer, hf_name) if self.tok.pad_token is None: self.tok.pad_token = self.tok.eos_token or "[PAD]" for p in self.llm.parameters(): p.requires_grad = False # ← frozen, never fine-tuned self.llm.eval().to(self._device) self.proj = nn.Sequential( nn.Linear(hidden, max(hidden // 8, output_dim)), nn.ReLU(), nn.Linear(max(hidden // 8, output_dim), output_dim), nn.ReLU(), nn.Dropout(dropout)) self.norm = nn.LayerNorm(output_dim) @torch.no_grad() def pool(self, texts): """texts -> (B, hidden) 冻结池化表征 (逐行缓存的对象)。""" if isinstance(texts, str): texts = [texts] enc = self.tok(texts, return_tensors="pt", padding=True, truncation=True, max_length=self.max_length) input_ids = enc["input_ids"].to(self._device) attn = enc["attention_mask"].to(self._device) emb = self.llm.get_input_embeddings()(input_ids) if self.use_fullmodel: emb = self.llm(inputs_embeds=emb).last_hidden_state mask = attn.unsqueeze(-1).expand_as(emb) return (emb * mask).sum(1) / mask.sum(1).clamp(min=1) def project(self, pooled): """(B, hidden) -> (B, output_dim), 可训练投影。""" return self.norm(self.proj(pooled)) def forward(self, texts): return self.project(self.pool(texts))
class AddFusionAdapter(nn.Module):
"""加性融合适配器: h = h_feat + h_grad (+ h_text)。"""
def init(self, d_model, z_shape, text_dim=64, hidden=64, dropout=0.0,
use_text=True, use_text_grad=True, device="cpu"):
super().init()
self.use_text, self.use_text_grad = use_text, use_text_grad
D_z = z_shape[-1]
self.linear = nn.Linear(D_z, hidden)
self.linear_ = nn.Linear(d_model, hidden)
# [O9] 直接按 (N, D) 构造, 语义同原 torch.ones(z_shape)[0]
self.lr = nn.Parameter(torch.ones(z_shape[1:], dtype=torch.float32))
self.drop = nn.Dropout(dropout)
# 单模态不创建文本相关权重,节省显存
if use_text:
self.text_proj = nn.Linear(text_dim, hidden)
self.txt_lr = nn.Parameter(torch.ones(text_dim) * 0.3)
self.linear2 = nn.Linear(hidden, hidden)
self.linear3 = nn.Linear(hidden, D_z)
self.last_gate = None
self.aux_loss = torch.zeros(())
textdef forward(self, x, his_grad, text_emb=None, his_text_grad=None, offset_feat=None): B = x.size(0) g = (his_grad * self.lr).unsqueeze(0).expand(B, -1, -1) h_g = self.linear_(g) h_f = self.linear(x) # 单模态直接跳过文本分支 if self.use_text and text_emb is not None: N = x.size(1) if self.use_text_grad and his_text_grad is not None: scale = 1.0 + self.txt_lr * his_text_grad text_emb = text_emb * scale.unsqueeze(0) h_t = self.text_proj(text_emb.unsqueeze(1).expand(B, N, -1)) h = h_f + h_g + h_t else: h = h_f + h_g h = self.drop(torch.relu(h)) h = self.drop(torch.relu(self.linear2(h))) return self.linear3(h)
def sparsemax(logits: torch.Tensor, dim: int = -1) -> torch.Tensor:
"""
sparsemax(z)_i = max(z_i - tau, 0), tau 使输出和为 1 (Martins & Astudillo 2016)。
与 softmax 不同, 可输出 精确的 0, 从而自适应关闭部分专家
—— 有效专家数由数据决定, 无需固定 top-k。
"""
z = logits - logits.max(dim=dim, keepdim=True).values
z_sorted, _ = torch.sort(z, dim=dim, descending=True)
z_cumsum = z_sorted.cumsum(dim)
k = torch.arange(1, logits.size(dim) + 1, device=logits.device,
dtype=logits.dtype)
shape = [1] * logits.dim()
shape[dim] = -1
k = k.view(shape)
support = (1 + k * z_sorted) > z_cumsum
k_support = support.to(logits.dtype).sum(dim=dim, keepdim=True).clamp(min=1)
tau = (z_cumsum.gather(dim, (k_support.long() - 1)) - 1.0) / k_support
return torch.clamp(z - tau, min=0.0)
class IdentityExpert(nn.Module):
"""零初始化残差专家: 允许门控选择「不做修正」。"""
def init(self, hidden, d_z):
super().init()
self.out = nn.Linear(hidden, d_z)
nn.init.zeros_(self.out.weight)
nn.init.zeros_(self.out.bias)
textdef forward(self, h, feature, g_h, txt_mod, f_h): return self.out(h)
class TrendExpert(nn.Module):
"""低秩瓶颈 → 平滑趋势型修正。"""
def init(self, hidden, d_z, rank=2):
super().init()
self.pre = nn.Linear(hidden, d_z)
self.down = nn.Linear(d_z, rank, bias=False)
self.up = nn.Linear(rank, d_z, bias=False)
textdef forward(self, h, feature, g_h, txt_mod, f_h): return self.up(self.down(self.pre(h)))
class SeasonalityExpert(nn.Module):
"""Fourier 低频模截断 → 周期型修正。"""
def init(self, hidden, d_z, num_modes=8):
super().init()
self.d_z = d_z
self.n_freq = d_z // 2 + 1
self.num_modes = min(num_modes, self.n_freq)
self.gain = nn.Parameter(torch.zeros(self.num_modes, 2))
self.mix = nn.Linear(hidden, d_z)
textdef forward(self, h, feature, g_h, txt_mod, f_h): Xf = torch.fft.rfft(feature, dim=-1) gain = torch.view_as_complex(self.gain.contiguous()) mask = torch.zeros(self.n_freq, dtype=Xf.dtype, device=Xf.device) mask[: self.num_modes] = 1.0 + gain seasonal = torch.fft.irfft(Xf * mask, n=self.d_z, dim=-1) return seasonal + self.mix(h)
class FluctuationExpert(nn.Module):
"""高通 + 最新 Z 梯度 → 误差驱动高频修正。"""
def init(self, hidden, d_z):
super().init()
self.hp = nn.Linear(d_z, hidden)
self.body = nn.Sequential(nn.Linear(hidden * 2, hidden), nn.GELU(),
nn.Linear(hidden, d_z))
textdef forward(self, h, feature, g_h, txt_mod, f_h): highpass = feature - feature.mean(dim=-1, keepdim=True) return self.body(torch.cat([torch.relu(self.hp(highpass)), g_h], dim=-1))
class TextFiLMExpert(nn.Module):
"""唯一持有文本通道的专家: 文本 → FiLM(γ, β) 调制特征。"""
def init(self, hidden, d_z, text_dim):
super().init()
self.film = nn.Linear(text_dim, hidden * 2)
self.out = nn.Sequential(nn.Linear(hidden, hidden), nn.GELU(),
nn.Linear(hidden, d_z))
textdef forward(self, h, feature, g_h, txt_mod, f_h): gamma, beta = self.film(txt_mod).unsqueeze(1).chunk(2, dim=-1) gamma = torch.tanh(gamma) return self.out(f_h * (1.0 + gamma) + beta)
class IGSoftMoEFusionAdapter(nn.Module):
"""
Importance-Gated Soft MoE (IG-SMoE) 融合适配器 —— 本文核心创新。
相对固定 top-k 的 MoEFusionAdapter:
· 移除所有固定 k / top-k 限制, 全专家软融合;
· 门控 g(·) 接收 [per-variate 特征 + 池化 pooled_x + pooled_text +
his_grad 统计 + offset 分布偏移], 复用 O2/O3/O4/O5 缓存与快照;
· s = g(...) + α·grad_modulation; w = softmax(s/τ) 或 sparsemax(s);
· z_fused = Σ_i w_i·Expert_i (加权求和, 权重即重要性);
· 自适应有效专家数 N_eff = 1/Σ_i w_i² → self.eff_experts;
· 辅助损失 (与旧版「负载均衡·鼓励均匀」相反, 现在鼓励 决断):
aux_mode='entropy': L_aux = λ·H(w) (负熵)
aux_mode='l2' : L_aux = -λ·Σw²
aux_mode='none' : 0
"""
textdef __init__(self, d_model, z_shape, text_dim=64, hidden=64, offset_dim=8, num_modes=8, aux_weight=0.02, gate_activation="softmax", aux_mode="entropy", temperature=1.0, dropout=0.1, device="cpu", use_text=True, use_text_grad=True): super().__init__() assert gate_activation in ("softmax", "sparsemax") assert aux_mode in ("entropy", "l2", "none") D_z = z_shape[-1] self.D_z, self.text_dim = D_z, text_dim self.offset_dim = offset_dim self.temperature = temperature self.gate_activation = gate_activation self.aux_mode, self.aux_weight = aux_mode, aux_weight self.use_text, self.use_text_grad = use_text, use_text_grad self.lr = nn.Parameter(torch.ones(z_shape[1:])) self.txt_lr = nn.Parameter(torch.ones(text_dim)) self.f_proj = nn.Linear(D_z, hidden) self.g_proj = nn.Linear(d_model, hidden) self.t_proj = nn.Linear(text_dim, hidden) self.trunk = nn.Linear(hidden * 3, hidden) self.drop = nn.Dropout(dropout) self.experts = nn.ModuleList([ IdentityExpert(hidden, D_z), TrendExpert(hidden, D_z, rank=2), SeasonalityExpert(hidden, D_z, num_modes=num_modes), FluctuationExpert(hidden, D_z), TextFiLMExpert(hidden, D_z, text_dim), ]) self.num_experts = len(self.experts) # 重要性门控 g(pooled_x, pooled_text, his_grad_stats, offset) self.offset_norm = nn.LayerNorm(offset_dim) self.gate_f = nn.Linear(D_z, hidden) # per-variate 局部特征 self.gate_p = nn.Linear(D_z, hidden) # pooled_x 全局特征 self.gate_g = nn.Linear(d_model, hidden) # his_grad 统计 self.gate_t = nn.Linear(text_dim, hidden) # pooled_text self.gate_o = nn.Linear(offset_dim, hidden) # 分布偏移 (concept drift) self.gate_net = nn.Sequential( nn.Linear(hidden * 5, hidden), nn.ReLU(), nn.Linear(hidden, self.num_experts)) # grad_modulation: 双梯度方向直接调制门控 logits self.grad_mod_z = nn.Linear(d_model, self.num_experts) self.grad_mod_t = nn.Linear(text_dim, self.num_experts) self.alpha = nn.Parameter(torch.tensor(0.1)) self.last_gate = None self.eff_experts = float(self.num_experts) self.aux_loss = torch.zeros((), device=torch.device(device)) def _activate(self, s): if self.gate_activation == "sparsemax": return sparsemax(s, dim=-1) return torch.softmax(s / max(self.temperature, 1e-6), dim=-1) def forward(self, feature, his_grad, text_emb=None, his_text_grad=None, offset_feat=None): B, N, _ = feature.shape device = feature.device if text_emb is None or not self.use_text: text_emb = torch.zeros(B, self.text_dim, device=device) if offset_feat is None: offset_feat = torch.zeros(B, self.offset_dim, device=device) g = (his_grad * self.lr).unsqueeze(0).expand(B, -1, -1) # (B,N,d_model) f_h = torch.relu(self.f_proj(feature)) g_h = torch.relu(self.g_proj(g)) if self.use_text_grad and his_text_grad is not None: direction = his_text_grad / (his_text_grad.norm() + 1e-6) txt_mod = text_emb * (1.0 + self.txt_lr * direction).unsqueeze(0) else: txt_mod = text_emb txt_exp = txt_mod.unsqueeze(1).expand(-1, N, -1) t_h = torch.relu(self.t_proj(txt_exp)) h = self.drop(torch.relu(self.trunk( torch.cat([f_h, g_h, t_h], dim=-1)))) outs = [e(h, feature, g_h, txt_mod, f_h) for e in self.experts] experts = torch.stack(outs, dim=2) # (B,N,E,D_z) # ── 重要性门控 (无 top-k) ── pooled_x = feature.mean(dim=1, keepdim=True).expand(-1, N, -1) o_exp = self.offset_norm(offset_feat).unsqueeze(1).expand(-1, N, -1) gate_h = torch.cat([ torch.relu(self.gate_f(feature)), torch.relu(self.gate_p(pooled_x)), torch.relu(self.gate_g(g)), torch.relu(self.gate_t(txt_exp)), torch.relu(self.gate_o(o_exp)), ], dim=-1) s = self.gate_net(gate_h) # (B,N,E) grad_bias = self.grad_mod_z(g.mean(dim=1)) # (B,E) if self.use_text_grad and his_text_grad is not None: grad_bias = grad_bias + self.grad_mod_t( his_text_grad.unsqueeze(0).expand(B, -1)) s = s + self.alpha * grad_bias.unsqueeze(1) # 广播 (B,1,E) w = self._activate(s) # (B,N,E) self.last_gate = w.detach() eff = 1.0 / (w.pow(2).sum(dim=-1) + 1e-9) self.eff_experts = float(eff.mean().detach()) if self.aux_mode == "entropy": H = -(w * (w + 1e-9).log()).sum(dim=-1).mean() self.aux_loss = self.aux_weight * H elif self.aux_mode == "l2": self.aux_loss = -self.aux_weight * w.pow(2).sum(dim=-1).mean() else: self.aux_loss = torch.zeros((), device=device) return (experts * w.unsqueeze(-1)).sum(dim=2) # (B,N,D_z)
class OnlineMM:
"""
多模态在线预测 (真实文本原生版)。
text关键设计 (方法不变): · update_backbone=False (默认) —— 骨干与投影头在线阶段不更新, 适应完全来自专家自适应融合适配器, 与「在线微调」严格区分; · 双梯度反馈: his_grad = ∂L/∂z, his_text_grad = ∂L/∂text_emb; · few-shot 抗过拟合: dropout / weight_decay / grad clip / patience。 实现优化 (见文件头 [O1]-[O6]): · 两处梯度反馈均改 torch.autograd.grad —— 不污染参数梯度, 无 retain_graph, his_grad 不再无界累加 (--his_mode 可选); · 特征提取前向包 no_grad (adapter 参数梯度不依赖 feature 的图); · 冻结 LLM 池化按全局行号缓存, 跨消融模式共享; · 缓冲区只存所需批数 + his 快照 (N,D), 文本索引与数值切片严格对齐。 """ def __init__(self, model, d_model, args, provider: RealTextProvider, split_offsets: dict, fusion="add", # "moe" | "add" text_encoder_type="BERT", # "BERT"|"GPT2"|"GPT2M"|"simple" text_dim=64, llm_layers=6, use_text=True, use_text_grad=True, update_backbone=False, dropout=0.1, weight_decay=1e-4, grad_clip=1.0, patience=3, top_k=2, aux_weight=0.02, gate_activation="softmax", aux_mode="entropy", temperature=1.0, his_mode="ema", his_momentum=0.9, use_text_cache=True, enc_in =1): # ========== 修复Bug:保存骨干模型为实例属性 self.model ========== self.model = model self.args = args self.device = args.device self.provider = provider self._offsets = dict(split_offsets) self._cur_offset = self._offsets.get("val", 0) self.fusion = fusion self.use_text = use_text self.use_text_grad = use_text_grad self.update_backbone = update_backbone self.grad_clip = grad_clip self.patience = patience assert his_mode in ("ema", "latest", "sum") self.his_mode, self.his_momentum = his_mode, his_momentum self.use_text_cache = use_text_cache self.loss_func = nn.MSELoss() z_shape_list = list(args.z_shape) z_shape_list[0] = max(args.batch_size, args.batch_size2) args.z_shape = tuple(z_shape_list) self.z = nn.Parameter(torch.zeros(args.z_shape, requires_grad=True, device=self.device)) if fusion == "moe": self.adapter = IGSoftMoEFusionAdapter( d_model=d_model, z_shape=args.z_shape, text_dim=text_dim, hidden=64, dropout=dropout, gate_activation=gate_activation, temperature=temperature, aux_mode=aux_mode, aux_weight=aux_weight, use_text=use_text, use_text_grad=use_text_grad, device=self.device).to(self.device) else: # 单模态use_text=False时,适配器自动关闭文本分支 self.adapter = AddFusionAdapter( d_model=d_model, z_shape=args.z_shape, text_dim=text_dim, hidden=64, dropout=dropout, use_text=use_text, use_text_grad=use_text_grad, device=self.device).to(self.device) # ── 文本编码器 (真实文本; simple 用 provider 真实语料词表) [O1] ── self.text_enc = None self._cache_key = None self.text_dim = text_dim # 单模态不加载文本编码器,节省显存 if use_text: if text_encoder_type == "simple": self.text_enc = SimpleTextEncoder( provider.vocab_size, embed_dim=text_dim, output_dim=text_dim, max_len=provider.SIMPLE_MAX_LEN, dropout=dropout).to(self.device) self._cache_key = ("simple_ids", provider.SIMPLE_MAX_LEN) else: self.text_enc = LLMTextEncoder( llm_tag=text_encoder_type, output_dim=text_dim, llm_layers=llm_layers, dropout=dropout, device=self.device).to(self.device) self._cache_key = ("pooled", text_encoder_type, llm_layers) self._adapter_params = list(self.adapter.parameters()) self._text_params = ([p for p in self.text_enc.parameters() if p.requires_grad] if self.text_enc is not None else []) self._weight_decay = weight_decay self._settings_data = args.data.lower() # 训练痕迹, 供可视化 self.gate_history = [] self.text_grad_history = [] # ── helpers ────────────────────────────────────────────── def _rows_of_windows(self, window_idxs): """窗口起点索引 -> 输入窗末行全局行号 (clamp 到有效区间)。 [O1]""" last = self.provider.n_rows - 1 s = self.args.seq_len - 1 return [min(max(self._cur_offset + int(w) + s, 0), last) for w in window_idxs] def _encode_text(self, window_idxs): """真实文本 -> 嵌入; 冻结部分按全局行号缓存 [O3]。""" if not self.use_text or self.text_enc is None: return None rows = self._rows_of_windows(window_idxs) cache = (self.provider.enc_cache.setdefault(self._cache_key, {}) if self.use_text_cache else {}) if isinstance(self.text_enc, LLMTextEncoder): missing = [r for r in dict.fromkeys(rows) if r not in cache] if missing: pooled = self.text_enc.pool( [self.provider.text_for_row(r) for r in missing]) for i, r in enumerate(missing): cache[r] = pooled[i].detach() stacked = torch.stack([cache[r] for r in rows]).to(self.device) return self.text_enc.project(stacked) # simple: 缓存 token ids for r in dict.fromkeys(rows): if r not in cache: cache[r] = self.provider.tokenize( self.provider.text_for_row(r)) ids = torch.tensor([cache[r] for r in rows], dtype=torch.long, device=self.device) return self.text_enc(ids) def _update_his(self, his, g): """[O2] his_grad 更新: ema (默认) / latest / sum (旧行为, 无界)。""" if g is None: return his if self.his_mode == "sum": return his + g if self.his_mode == "latest": return g return self.his_momentum * his + (1.0 - self.his_momentum) * g def _z_grad(self, x_b, y_b): """[O2] ∂L/∂z via autograd.grad: 不累加、不污染参数、无 retain_graph。 返回 (grad(N,D)|None, feature.detach())。""" out = self.model(x_b, z=self.z, z_loc=self.args.z_loc) loss = self.loss_func(out["pred"], y_b) gz = torch.autograd.grad(loss, self.z, allow_unused=True)[0] gz = gz.mean(dim=0).detach() if gz is not None else None return gz, out["feature"].detach() def _compute_text_grad(self, x_b, y_b, feature_b, his_grad, text_emb, his_text_grad=None, offset_b=None): """[O2] ∂L/∂text_emb via autograd.grad (调制上下文与推理一致)。""" txt = text_emb.detach().clone().requires_grad_(True) z = self.adapter(feature_b, his_grad, txt, his_text_grad, offset_b) out = self.model(x_b, z=z, z_loc=self.args.z_loc) loss = self.loss_func(out["pred"], y_b) gt = torch.autograd.grad(loss, txt, allow_unused=True)[0] if gt is not None: return gt.mean(dim=0).detach() return torch.zeros(txt.shape[-1], device=self.device) def _offset(self, x): return (compute_offset_features(x).to(self.device) if self.fusion == "moe" else None) def _keep_batches(self): """[O4] 缓冲区所需批数: 切片只用最近 pred_len+batch 行。""" need = self.args.pred_len + self.args.batch_size return int(math.ceil(need / self.args.batch_size)) + 1 @staticmethod def _buffer_slice(arrs, sl): return np.concatenate(arrs, axis=0)[sl] # 2. 修复缓冲文本索引 def _buf_text_windows(self, next_global_idx, Bb): b0 = max(0, next_global_idx - self.args.pred_len - Bb) return range(b0, b0 + Bb) # ── val / warm-up phase ────────────────────────────────── def val(self, val_loader, mode="val"): print(f"[MM] val (fusion={self.fusion}, text={self.use_text}, " f"text_grad={self.use_text_grad}, his={self.his_mode})") self._cur_offset = self._offsets["val"] lr_key = "finetune_lr" if mode == "train" else "adapter_lr" adp_lr = settings[self.args.model][self._settings_data][lr_key] wd = self._weight_decay all_params = self._adapter_params + self._text_params self.optimizer = optim.Adam(all_params, lr=adp_lr, weight_decay=wd) self.optimizer2 = (optim.Adam( # [O6] 仅 train 需要 self.model.parameters(), lr=settings[self.args.model][self._settings_data]["online_lr"], weight_decay=wd) if mode == "train" else None) his_grad = torch.zeros(self.args.z_shape[1:], device=self.device) # ========== 单模态短路:无文本时直接置None,跳过文本梯度计算 ========== his_text_grad = torch.zeros(self.text_dim, device=self.device) if self.use_text else None preds, truths, x_list = [], [], [] keep = self._keep_batches() global_idx = 0 for _, batch in tqdm(enumerate(val_loader), total=len(val_loader)): x, y = batch[0].to(self.device), batch[1].to(self.device) B = x.size(0) x_list.append(x.detach().cpu().numpy()) truths.append(y.detach().cpu().numpy()) text_emb = self._encode_text(range(global_idx, global_idx + B)) offset = self._offset(x) with torch.no_grad(): # [O2] 特征提取免建图 feat = self.model(x, z=self.z, z_loc=self.args.z_loc)["feature"] z = self.adapter(feat, his_grad, text_emb, his_text_grad, offset) pred = self.model(x, z=z, z_loc=self.args.z_loc)["pred"] preds.append(pred.detach().cpu().numpy()) loss = (self.loss_func(pred, y) + getattr(self.adapter, "aux_loss", 0.0)) self.optimizer.zero_grad() if self.optimizer2: self.optimizer2.zero_grad() loss.backward() if self.grad_clip > 0: nn.utils.clip_grad_norm_(all_params, self.grad_clip) self.optimizer.step() if self.optimizer2: self.optimizer2.step() else: self.model.zero_grad(set_to_none=True) # 清理无主梯度 if len(x_list) > keep: # [O4] x_list = x_list[-keep:] total_seen = sum(a.shape[0] for a in x_list) if total_seen > self.args.pred_len + self.args.batch_size: sl = slice(-self.args.pred_len - self.args.batch_size, -self.args.pred_len) x_b = torch.from_numpy( self._buffer_slice(x_list, sl)).to(self.device) y_b = torch.from_numpy( self._buffer_slice(truths[-keep:], sl)).to(self.device) gz, feat_b = self._z_grad(x_b, y_b) # [O2] his_grad = self._update_his(his_grad, gz) # ========== 单模态短路:use_text=False 直接跳过文本梯度更新 ========== if self.use_text and self.use_text_grad: next_global_idx = global_idx + B emb_buf = self._encode_text( self._buf_text_windows(next_global_idx, x_b.size(0))) if emb_buf is not None: his_text_grad = self._compute_text_grad( x_b, y_b, feat_b, his_grad, emb_buf, his_text_grad, self._offset(x_b)) global_idx += B mae, mse = calculate_metrics(np.concatenate(preds, axis=0), np.concatenate(truths, axis=0)) print(f"[MM] val mae={mae:.4f} mse={mse:.4f}") return mse # ── online prediction phase ────────────────────────────── def online(self, test_loader): print(f"[MM] online (fusion={self.fusion}, text={self.use_text}, " f"text_grad={self.use_text_grad}, " f"update_backbone={self.update_backbone}, his={self.his_mode})") self._cur_offset = self._offsets["test"] if self.args.model == "iTransformer": proj_params = list(self.model.projector.parameters()) else: proj_params = list(self.model.projection.parameters()) wd = self._weight_decay all_adp = self._adapter_params + self._text_params self.optimizer = optim.Adam( all_adp, lr=settings[self.args.model][self._settings_data]["adp_online_lr"], weight_decay=wd) self.optimizer2 = optim.Adam( proj_params, lr=settings[self.args.model][self._settings_data]["online_lr"], weight_decay=wd) his_grad = torch.zeros(self.args.z_shape[1:], device=self.device) his_text_grad = (torch.zeros(self.text_dim, device=self.device) if self.use_text else None) z0 = torch.zeros(self.args.z_shape, device=self.device) # [O4] 复用 preds, truths = [], [] x_list, feat_list = [], [] # 滚动缓冲 (np) snap_list, cnt_list = [], [] # [O4] his 快照 (N,D) + 行数 self.gate_history, self.text_grad_history = [], [] keep = self._keep_batches() global_idx = 0 for _, batch in tqdm(enumerate(test_loader), total=len(test_loader)): x, y = batch[0].to(self.device), batch[1].to(self.device) B = x.size(0) text_emb = self._encode_text(range(global_idx, global_idx + B)) offset = self._offset(x) with torch.no_grad(): # [O2] feat = self.model(x, z=z0, z_loc=self.args.z_loc)["feature"] z = self.adapter(feat, his_grad, text_emb, his_text_grad, offset) pred = self.model(x, z=z, z_loc=self.args.z_loc)["pred"] preds.append(pred.detach().cpu().numpy()) truths.append(y.detach().cpu().numpy()) x_list.append(x.detach().cpu().numpy()) feat_list.append(feat.cpu().numpy()) snap_list.append(his_grad.detach().cpu().numpy()) # (N,D) cnt_list.append(B) if getattr(self.adapter, "last_gate", None) is not None: lg = self.adapter.last_gate self.gate_history.append( lg.mean(dim=tuple(range(lg.dim() - 1))).cpu().numpy()) if len(x_list) > keep: # [O4] x_list, feat_list = x_list[-keep:], feat_list[-keep:] snap_list, cnt_list = snap_list[-keep:], cnt_list[-keep:] total_seen = sum(cnt_list) if total_seen > self.args.pred_len + self.args.batch_size: sl = slice(-self.args.pred_len - self.args.batch_size, -self.args.pred_len) x_b = torch.from_numpy( self._buffer_slice(x_list, sl)).to(self.device) y_b = torch.from_numpy( self._buffer_slice(truths[-keep:], sl)).to(self.device) ft_b = torch.from_numpy( self._buffer_slice(feat_list, sl)).to(self.device) # [O4] 快照按行展开后取切片均值 == 原 hg_b.mean(dim=0) hg_rows = np.repeat(np.stack(snap_list, 0), cnt_list, axis=0)[sl] hg_mean = torch.from_numpy( hg_rows.mean(axis=0)).to(self.device) offset_b = self._offset(x_b) # ── 梯度反馈 1: ∂L/∂z (autograd.grad, 无累加/污染) [O2] ── gz, _ = self._z_grad(x_b, y_b) his_grad = self._update_his(his_grad, gz) # ── 梯度反馈 2: ∂L/∂text_emb (精确缓冲索引 [O5]) ── emb_buf = None if self.use_text: # 3. val / online 中调用时使用 global_idx + B next_global_idx = global_idx + B emb_buf = self._encode_text( self._buf_text_windows(next_global_idx, x_b.size(0))) if self.use_text_grad and emb_buf is not None: his_text_grad = self._compute_text_grad( x_b, y_b, ft_b, hg_mean, emb_buf, his_text_grad, offset_b) self.text_grad_history.append( his_text_grad.cpu().numpy()) # ── 适配器 (专家融合) 更新 —— 骨干默认不动 ── z_up = self.adapter(ft_b, hg_mean, emb_buf, his_text_grad, offset_b) out_up = self.model(x_b, z=z_up, z_loc=self.args.z_loc) loss_up = (self.loss_func(out_up["pred"], y_b) + getattr(self.adapter, "aux_loss", 0.0)) self.optimizer.zero_grad() self.optimizer2.zero_grad() loss_up.backward() if self.grad_clip > 0: nn.utils.clip_grad_norm_(all_adp + proj_params, self.grad_clip) self.optimizer.step() if self.update_backbone: self.optimizer2.step() self.model.zero_grad(set_to_none=True) # 清理无主梯度 global_idx += B pred_arr = np.concatenate(preds, 0) truth_arr = np.concatenate(truths, 0) mae, mse = calculate_metrics(pred_arr, truth_arr) print(f"[MM] online mae={mae:.4f} mse={mse:.4f}") return mae, mse, pred_arr, truth_arr
class _DataEmbed_inv(nn.Module):
def init(self, seq_len, d_model, dropout=0.1):
super().init()
self.proj = nn.Linear(seq_len, d_model)
self.drop = nn.Dropout(dropout)
textdef forward(self, x): return self.drop(self.proj(x.permute(0, 2, 1)))
class _EncLayer(nn.Module):
def init(self, d_model, nhead=4, d_ff=256, dropout=0.1):
super().init()
self.attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout,
batch_first=True)
self.ff = nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(),
nn.Dropout(dropout), nn.Linear(d_ff, d_model),
nn.Dropout(dropout))
self.norm1, self.norm2 = nn.LayerNorm(d_model), nn.LayerNorm(d_model)
textdef forward(self, x): y, _ = self.attn(x, x, x) x = self.norm1(x + y) return self.norm2(x + self.ff(x))
class ITransformerZ(nn.Module):
"""Minimal iTransformer with additive Z injection (checkpoint-compatible)."""
def init(self, seq_len, pred_len, enc_in,
d_model=64, e_layers=2, nhead=4, d_ff=128, dropout=0.1):
super().init()
self.seq_len, self.pred_len, self.d_model = seq_len, pred_len, d_model
self.embedding = _DataEmbed_inv(seq_len, d_model, dropout)
self.layers = nn.ModuleList([_EncLayer(d_model, nhead, d_ff, dropout)
for _ in range(e_layers)])
self.norm = nn.LayerNorm(d_model)
self.projector = nn.Linear(d_model, pred_len)
textdef forward(self, x, z=None, z_loc=None): B, L, N = x.shape mu = x.mean(1, keepdim=True).detach() sigma = torch.sqrt(x.var(1, keepdim=True, unbiased=False) + 1e-5 ).detach() x = (x - mu) / sigma h, feature = self.embedding(x), None for idx, layer in enumerate(self.layers): h = layer(h) if z is not None and z_loc is not None and z_loc == idx + 1: feature = h.clone() h = h + z[:B] if feature is None: feature = h.clone() pred = self.projector(self.norm(h)).permute(0, 2, 1) pred = pred * sigma[:, 0, :].unsqueeze(1) + mu[:, 0, :].unsqueeze(1) return {"pred": pred, "feature": feature}
def pretrain_backbone(bb, train_loader, val_loader, device,
epochs=50, lr=1e-3, patience=3, ckpt_path=None):
"""checkpoint 缺失时的骨干预训练 (早停+保存); 在线阶段骨干仍冻结。 [O8]"""
opt = optim.Adam(bb.parameters(), lr=lr)
lossf = nn.MSELoss()
best, bad, best_state = float("inf"), 0, None
for ep in range(epochs):
bb.train()
tr = 0.0
for xb, yb, *_ in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = lossf(bb(xb)["pred"], yb)
loss.backward()
opt.step()
tr += loss.item()
bb.eval()
vl, m = 0.0, 0
with torch.no_grad():
for xb, yb, *_ in val_loader:
vl += lossf(bb(xb.to(device))["pred"], yb.to(device)).item()
m += 1
vl /= max(m, 1)
print(f"[pretrain] epoch {ep + 1}/{epochs} "
f"train={tr / max(len(train_loader), 1):.4f} val={vl:.4f}")
if vl < best - 1e-6:
best, bad = vl, 0
best_state = {k: v.detach().cpu().clone()
for k, v in bb.state_dict().items()}
else:
bad += 1
if bad >= patience:
print("[pretrain] early stop")
break
if best_state is not None:
bb.load_state_dict(best_state)
if ckpt_path:
torch.save(bb.state_dict(), ckpt_path)
print(f"[pretrain] saved -> {ckpt_path}")
return bb
_PALETTE = ["#9a9a9a", "#e0a840", "#e07040", "#4080c0", "#2e9e55"]
_MODE_COLOR = {
"w/o Text": "#9a9a9a",
"w/o ": "#e0a840",
"w/o Adaptive Fusion": "#e07040",
"w/o Add ": "#4080c0",
"iAdapter": "#2e9e55",
}
_MODE_NICE = {
"w/o Text": "Unimodal ADAPT-Z",
"w/o ": "+ Semantic Prior",
"w/o Adaptive Fusion": "+ Text + Grad",
"w/o Add ": "MoE Fusion",
"iAdapter": "iAdapter (ours)",
}
def _is_ours(mode):
return "iAdapter" in mode
def _is_baseline(mode): # 替换原来靠 "Unimodal" 字符串匹配的逻辑
return mode == "w/o Text"
def _mode_color(mode, i=0):
return _MODE_COLOR.get(mode, _PALETTE[i % len(_PALETTE)])
def _mode_nice(mode):
return _MODE_NICE.get(mode, mode)
def _rolling(a, w):
out = np.full(len(a), np.nan)
for i in range(w - 1, len(a)):
out[i] = a[i - w + 1:i + 1].mean()
return out
def plot_ablation_summary(results, save_path="./image/iAdapter_ablation_summary.png"):
"""MAE / MSE 消融柱状图 (无标题), 标注相对 Unimodal 的收益百分比。"""
names = [r["mode"] for r in results]
maes = [r["mae"] for r in results]
mses = [r["mse"] for r in results]
uni_mae = next((r["mae"] for r in results if _is_baseline(r["mode"])),
maes[0])
uni_mse = next((r["mse"] for r in results if _is_baseline(r["mode"])),
mses[0])
textfig, axes = plt.subplots(1, 2, figsize=(15, 5.2), dpi=130) for ax, vals, base, metric in ((axes[0], maes, uni_mae, "MAE"), (axes[1], mses, uni_mse, "MSE")): colors = [_mode_color(m, i) for i, m in enumerate(names)] edges = ["#1a1a1a" if _is_ours(m) else "white" for m in names] lws = [1.8 if _is_ours(m) else 0.8 for m in names] bars = ax.bar(range(len(names)), vals, color=colors, edgecolor=edges, linewidth=lws, width=0.62, zorder=3) for b, v, m in zip(bars, vals, names): gain = (base - v) / base * 100 tag = f"{v:.4f}" + ("" if abs(gain) < 1e-9 else f"\n({gain:+.1f}%)") ax.text(b.get_x() + b.get_width() / 2, b.get_height(), tag, ha="center", va="bottom", fontsize=8.5, fontweight="bold" if _is_ours(m) else "normal") ax.set_xticks(range(len(names))) ax.set_xticklabels([_mode_nice(m).replace(" ", "\n") for m in names], fontsize=8) ax.set_ylabel(f"{metric} (%: gain vs Unimodal ADAPT-Z)") ax.grid(axis="y", lw=0.4, alpha=0.4, zorder=0) plt.tight_layout() plt.savefig(save_path, bbox_inches="tight") plt.close(fig) print(f"[viz] {save_path}")
def plot_rolling_mae(results, save_path="./image/iAdapter_rolling_mae.png", W=30):
"""上: 滚动 MAE; 下: Δ|Error| = iAdapter − Unimodal 净收益带。无标题。"""
fig, (ax, ax2) = plt.subplots(2, 1, figsize=(14, 8), dpi=130, sharex=True,
gridspec_kw={"height_ratios": [2.0, 1.0]})
step_mae = {}
for ci, r in enumerate(results):
sm = np.mean(np.abs(r["preds"] - r["truths"]), axis=(1, 2))
step_mae[r["mode"]] = sm
ours = _is_ours(r["mode"])
ax.plot(_rolling(sm, W), lw=2.4 if ours else 1.2,
alpha=1.0 if ours else 0.75, zorder=6 if ours else 3,
color=_mode_color(r["mode"], ci), label=_mode_nice(r["mode"]))
ax.set_ylabel(f"Rolling MAE (window={W})")
ax.legend(fontsize=8.5)
ax.grid(True, lw=0.4, alpha=0.4)
textours_key = next((m for m in step_mae if _is_ours(m)), None) uni_key = next((m for m in step_mae if _is_baseline(m)), None) if ours_key and uni_key: raw = step_mae[ours_key] - step_mae[uni_key] diff = _rolling(raw, W) steps = np.arange(len(diff)) ax2.fill_between(steps, np.where(diff < 0, diff, 0), color="#2e9e55", alpha=0.55, label="iAdapter better") ax2.fill_between(steps, np.where(diff >= 0, diff, 0), color="#e05050", alpha=0.45, label="iAdapter worse") ax2.axhline(0, color="black", lw=0.8, ls="--") ax2.plot(diff, color="#666666", lw=0.7, alpha=0.6) ax2.text(0.99, 0.05, f"mean Δ|err| = {np.nanmean(raw):+.4f}", transform=ax2.transAxes, ha="right", va="bottom", fontsize=9) ax2.set_ylabel("Δ|Error| (ours − Unimodal)") ax2.legend(fontsize=8, ncol=2) ax2.set_xlabel("Online step") ax2.grid(True, lw=0.4, alpha=0.4) plt.tight_layout() plt.savefig(save_path, bbox_inches="tight") plt.close(fig) print(f"[viz] {save_path}")
def plot_pred_curves(results, sensor_idx=0,
save_path="./image/iAdapter_pred_curves.png",
view_span=140, zoom_half=35, zoom_center=None):
"""真值 vs 各模式预测(第一预测步)。
不再画整段测试序列(太长、看不清),而是自动截取 iAdapter 相对
Unimodal 逐步误差降低最多的一段 view_span 步作为主图,再在其中取
±zoom_half 步做局部放大。zoom_center 可手动指定中心 step。无标题。
"""
truth = results[0]["truths"]
T = len(truth)
gt = truth[:, 0, sensor_idx]
textours_key = next((r["mode"] for r in results if _is_ours(r["mode"])), None) uni_key = next((r["mode"] for r in results if _is_baseline(r["mode"])), None) # ── 自动定位「好效果」区段: iAdapter 逐步 |误差| 比 Unimodal 低最多的窗口 ── if zoom_center is None and ours_key and uni_key: ours_r = next(r for r in results if r["mode"] == ours_key) uni_r = next(r for r in results if r["mode"] == uni_key) diff = (np.abs(ours_r["preds"][:, 0, sensor_idx] - gt) - np.abs(uni_r["preds"][:, 0, sensor_idx] - gt)) # <0 = iAdapter 更好 W = min(view_span, T) if T >= W and W > 0: roll = np.convolve(diff, np.ones(W) / W, mode="valid") zoom_center = int(np.argmin(roll)) + W // 2 # 收益最大段的中心 else: zoom_center = T // 2 elif zoom_center is None: zoom_center = T // 2 # ── 主视窗: 只截一段 view_span, 而不是全序列 ── half_v = view_span // 2 v0 = max(0, zoom_center - half_v) v1 = min(T - 1, zoom_center + half_v) vt = np.arange(v0, v1 + 1) vsl = slice(v0, v1 + 1) # ── 放大子区段 ── z0 = max(v0, zoom_center - zoom_half) z1 = min(v1, zoom_center + zoom_half) zt = np.arange(z0, z1 + 1) zsl = slice(z0, z1 + 1) fig, (ax_main, ax_zoom) = plt.subplots( 2, 1, figsize=(15, 8), dpi=130, gridspec_kw={"height_ratios": [2.2, 1.0]}) # 上图: 截取段完整曲线 ax_main.plot(vt, gt[vsl], color="#222222", lw=1.8, label="Ground Truth", zorder=7) for ci, r in enumerate(results): ours = _is_ours(r["mode"]) ax_main.plot(vt, r["preds"][vsl, 0, sensor_idx], color=_mode_color(r["mode"], ci), lw=1.9 if ours else 0.9, alpha=0.95 if ours else 0.6, zorder=6 if ours else 3, label=_mode_nice(r["mode"])) # 标出放大框 y_all = np.concatenate([r["preds"][zsl, 0, sensor_idx] for r in results] + [gt[zsl]]) ylo, yhi = float(y_all.min()), float(y_all.max()) pad = (yhi - ylo) * 0.08 + 1e-6 rect = plt.Rectangle((z0, ylo - pad), z1 - z0, yhi - ylo + 2 * pad, lw=1.2, edgecolor="#e07040", facecolor="#e07040", alpha=0.08, zorder=2) ax_main.add_patch(rect) ax_main.annotate("zoom ↓", xy=(zoom_center, yhi + pad * 0.4), ha="center", fontsize=8, color="#e07040") ax_main.set_ylabel(f"Sensor #{sensor_idx} (normalised)") ax_main.legend(fontsize=7.5, ncol=3, loc="best") ax_main.set_xlim(v0, v1) ax_main.grid(True, lw=0.35, alpha=0.35) # 下图: 局部放大 ax_zoom.plot(zt, gt[zsl], color="#222222", lw=2.0, label="Ground Truth", zorder=7) for ci, r in enumerate(results): ours = _is_ours(r["mode"]) ax_zoom.plot(zt, r["preds"][zsl, 0, sensor_idx], color=_mode_color(r["mode"], ci), lw=2.2 if ours else 1.0, alpha=1.0 if ours else 0.55, zorder=6 if ours else 3) ax_zoom.set_ylabel("Zoomed (best-gain)") ax_zoom.set_xlabel("Online step") ax_zoom.set_xlim(z0, z1) ax_zoom.grid(True, lw=0.35, alpha=0.35) # 连接线 from matplotlib.patches import ConnectionPatch for xc in (z0, z1): con = ConnectionPatch( xyA=(xc, ylo - pad), coordsA=ax_main.transData, xyB=(xc, ax_zoom.get_ylim()[1]), coordsB=ax_zoom.transData, color="#e07040", lw=0.8, linestyle="--", alpha=0.6) fig.add_artist(con) plt.tight_layout() plt.savefig(save_path, bbox_inches="tight") plt.close(fig) print(f"[viz] {save_path}")
def plot_gate_usage(gate_history, expert_names=None,
save_path="./image/iAdapter_gate_usage.png"):
"""MoE 专家门控演化 (无标题)。"""
if not gate_history:
return
G = np.array(gate_history) # (T, E)
expert_names = expert_names or ["Identity", "Trend", "Seasonality",
"Fluctuation", "Text-FiLM"]
fig, ax = plt.subplots(figsize=(14, 4.6), dpi=130)
ax.stackplot(np.arange(len(G)), G.T, labels=expert_names,
colors=["#bfbfbf", "#e0a840", "#40b060", "#4080c0",
"#d9985f"], alpha=0.85)
ax.set_xlabel("Online step")
ax.set_ylabel("Mean gate weight")
ax.set_ylim(0, 1)
ax.legend(fontsize=8.5, ncol=5, loc="upper center")
ax.grid(True, lw=0.35, alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, bbox_inches="tight")
plt.close(fig)
print(f"[viz] {save_path}")
def plot_multi_pred_len_summary(all_results: dict,
save_path="./image/iAdapter_multi_predlen.png"):
"""
多预测长度汇总图: 横轴=pred_len, 纵轴=MAE/MSE。
每条线对应一种消融模式, 实线=iAdapter, 虚线=其余。
"""
pred_lens = sorted(all_results.keys())
modes = [r["mode"] for r in next(iter(all_results.values()))]
textfig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5), dpi=130) for ci, mode in enumerate(modes): maes = [next(r["mae"] for r in all_results[pl] if r["mode"] == mode) for pl in pred_lens] mses = [next(r["mse"] for r in all_results[pl] if r["mode"] == mode) for pl in pred_lens] ours = _is_ours(mode) kw = dict(color=_mode_color(mode, ci), lw=2.4 if ours else 1.2, ls="-" if ours else "--", marker="o" if ours else "s", markersize=6 if ours else 4, label=_mode_nice(mode), zorder=6 if ours else 3) ax1.plot(pred_lens, maes, **kw) ax2.plot(pred_lens, mses, **kw) for ax, metric in ((ax1, "MAE"), (ax2, "MSE")): ax.set_xlabel("Prediction horizon (pred_len)") ax.set_ylabel(metric) ax.set_xticks(pred_lens) ax.grid(True, lw=0.4, alpha=0.4) ax.legend(fontsize=8.5) plt.tight_layout() plt.savefig(save_path, bbox_inches="tight") plt.close(fig) print(f"[viz] {save_path}")
ABLATION_MODES = [
("w/o Text", dict(fusion="add", use_text=False, use_text_grad=False)),
("w/o ", dict(fusion="add", use_text=True, use_text_grad=False)),
("w/o Adaptive Fusion", dict(fusion="add", use_text=True, use_text_grad=True)),
("w/o Add ", dict(fusion="moe", use_text=True, use_text_grad=False)),
("iAdapter", dict(fusion="moe", use_text=True, use_text_grad=True)),
]
_MODE_KEYS = {"m1": 0, "m2": 1, "m3": 2, "m4": 3, "m5": 4}
def parse_modes(spec: str):
if spec.strip().lower() in ("all", ""):
return None
idx = sorted({_MODE_KEYS[t.strip().lower()]
for t in spec.split(",") if t.strip()})
return [ABLATION_MODES[i] for i in idx]
def finetune_mm(data="Traffic", data_dir="./data", seq_len=None, pred_lens=None,
seed=2023, batch_size=16, d_model=64, e_layers=2, z_loc=2,
text_encoder="BERT", text_dim=64, llm_layers=6,
dropout=0.1, weight_decay=1e-4, grad_clip=1.0, patience=3,
warmup_rounds=3, his_mode="ema", his_momentum=0.9,
text_source="both", text_lookback=2, max_clauses=6,
max_chars=1200, keep_history_stats=False, ot_only=False,
raw_text_topk=6, raw_lookback_days=60, use_text_cache=True,
modes=None, make_figures=True, ckpt_dir=".",
pretrain_epochs=15, pretrain_lr=3e-4,
lr_overrides=None, csv_path="finally_mm.csv"):
textdevice = "cuda:0" if torch.cuda.is_available() else "cpu" domain = canonical_domain(data) modes = modes or ABLATION_MODES # 1) 真实文本供给器:数值 + 文本一站式;编码缓存跨模式共享 provider = RealTextProvider( domain, data_dir=data_dir, text_source=text_source, text_lookback=text_lookback, max_clauses=max_clauses, max_chars=max_chars, raw_text_topk=raw_text_topk, raw_lookback_days=raw_lookback_days, keep_history_stats=keep_history_stats, ot_only=ot_only ) # 2) pred_lens 规范化:None -> 域默认;int -> 单元素列表 ds_, dp_ = _DOMAIN_WINDOW.get(domain, (36, 6)) if pred_lens is None: pred_lens = [dp_] elif isinstance(pred_lens, int): pred_lens = [pred_lens] else: pred_lens = list(pred_lens) # 小样本场景的自适应设置 if provider.n_rows < 500: d_model = min(d_model, 32) pretrain_epochs = max(pretrain_epochs, 50) dropout = max(dropout, 0.2) batch_size = min(batch_size, 8) os.makedirs(ckpt_dir, exist_ok=True) all_results = {} # {pred_len: [result_dict, ...]} # 3) 多 horizon 主循环 for raw_pred_len in pred_lens: print(f"\n{'#' * 66}\n" f"[MULTI-H] domain={domain} pred_len={raw_pred_len}\n" f"{'#' * 66}") # 每个 pred_len: 先自适应回溯窗口, 再按样本量兜底收缩 base = seq_len if seq_len else adaptive_seq_len(provider, raw_pred_len) sl, pl = auto_window(provider.n_rows, base, raw_pred_len) # 可选: 让文本聚合窗口随 horizon 增大 (仅聚合更多"历史"文本) provider.set_text_lookback( max(text_lookback, math.ceil(pl / max(1, dp_)) + 1)) # 每个 pred_len 独立加载数据 train_set, val_set, test_set, _sc, offsets = load_real_data_mm( provider, sl, pl ) enc_in = provider.numeric.shape[1] print(f"[MM] enc_in={enc_in}, seq_len={sl}, pred_len={pl}") train_loader = DataLoader( train_set, batch_size=batch_size, shuffle=True, drop_last=True ) val_loader = DataLoader( val_set, batch_size=batch_size, shuffle=False ) test_loader = DataLoader( test_set, batch_size=batch_size, shuffle=False ) args = argparse.Namespace( model="iTransformer", data=domain, seq_len=sl, pred_len=pl, seed=seed, device=device, batch_size=batch_size, batch_size2=batch_size, z_loc=z_loc, z_shape=(batch_size, enc_in, d_model), checkpoints="./checkpoints2", enc_in=enc_in ) ensure_settings(args.model, domain, lr_overrides) # 4) 每个 pred_len 独立 checkpoint stem = ( f"iAdapter_backbone_{domain}_L{sl}_h{pl}" f"_dm{d_model}_el{e_layers}_c{enc_in}_seed{seed}.pth" ) ckpt = os.path.join(ckpt_dir, stem) # 兼容旧命名:仅当形状匹配时复用 legacy_names = [ os.path.join( ckpt_dir, f"iAdapter_backbone_{domain}_h{pl}_seed{seed}.pth" ), f"iAdapter_backbone_{domain}_h{pl}_seed{seed}.pth", ] def _ckpt_matches(path): """ 检查 checkpoint 的 embedding.proj.weight 是否与当前 (d_model, seq_len) 匹配,避免 size mismatch。 """ try: sd = torch.load(path, map_location="cpu") w = sd.get("embedding.proj.weight") return w is not None and tuple(w.shape) == (d_model, sl) except Exception: return False if not os.path.exists(ckpt): reused = next( (p for p in legacy_names if os.path.exists(p) and _ckpt_matches(p)), None ) if reused: ckpt = reused print(f"[backbone] reusing compatible legacy checkpoint: {ckpt}") else: stale = [p for p in legacy_names if os.path.exists(p)] if stale: print( f"[backbone] found legacy checkpoint(s) with different " f"shape: {stale} -> ignored." ) print( f"[backbone] checkpoint missing -> pretraining " f"({pretrain_epochs} epochs) target={ckpt}" ) set_seed(seed) bb = ITransformerZ( sl, pl, enc_in, d_model=d_model, e_layers=e_layers ).to(device) pretrain_backbone( bb, train_loader, val_loader, device, epochs=pretrain_epochs, lr=pretrain_lr, patience=patience, ckpt_path=ckpt ) def fresh_backbone(): bb = ITransformerZ( sl, pl, enc_in, d_model=d_model, e_layers=e_layers ) try: bb.load_state_dict(torch.load(ckpt, map_location=device)) except RuntimeError as e: raise RuntimeError( f"Failed to load backbone checkpoint '{ckpt}' for config " f"seq_len={sl}, pred_len={pl}, d_model={d_model}, " f"e_layers={e_layers}, enc_in={enc_in}.\n" f"This usually means the checkpoint was trained with a " f"different seq_len / pred_len / d_model / enc_in. " f"Delete the checkpoint file to re-pretrain, or pass " f"matching arguments.\nOriginal error: {e}" ) from e return bb.to(device).eval() # 5) 当前 pred_len 的消融主循环 results = [] gate_hist_full = None for label, kw in modes: print(f"\n{'=' * 62}\n" f"[RUN:{domain}] h={pl} {label} {kw} " f"(REAL text: {text_source})\n" f"{'=' * 62}") set_seed(seed) args.seq_len = sl args.pred_len = pl args.enc_in = enc_in args.z_shape = (batch_size, enc_in, d_model) adapter = OnlineMM( model=fresh_backbone(), d_model=d_model, args=args, provider=provider, split_offsets=offsets, text_encoder_type=text_encoder, text_dim=text_dim, llm_layers=llm_layers, dropout=dropout, weight_decay=weight_decay, grad_clip=grad_clip, patience=patience, update_backbone=False, his_mode=his_mode, his_momentum=his_momentum, use_text_cache=use_text_cache, enc_in=enc_in, **kw ) # val warm-up + early stopping best_mse, bad = float("inf"), 0 for _ in range(warmup_rounds): mse = adapter.val(val_loader) if mse < best_mse: best_mse, bad = mse, 0 else: bad += 1 print(f"[MM] val not improved ({bad}/{patience})") if bad >= patience: break mae, mse, preds, truths = adapter.online(test_loader) results.append(dict( mode=label, mae=mae, mse=mse, preds=preds, truths=truths, pred_len=pl, gate=list(adapter.gate_history) )) if "iAdapter" in label and adapter.gate_history: gate_hist_full = list(adapter.gate_history) # CSV 追加:增加 pred_len 列 pd.DataFrame([{ "data": domain, "seq_len": sl, "pred_len": pl, "seed": seed, "mode": label, **kw, "text_source": text_source, "text_encoder": text_encoder, "his_mode": his_mode, "mae": mae, "mse": mse, }]).to_csv( csv_path, mode="a", header=not os.path.exists(csv_path), index=False ) all_results[pl] = results # 6) 当前 pred_len 的收益汇总 print(f"\n{'-' * 66}\n" f"[SUMMARY] {domain} h={pl} seed={seed} " f"text={text_source} his={his_mode}\n" f"{'-' * 66}") uni_mae = next( (r["mae"] for r in results if _is_baseline(r["mode"])), results[0]["mae"] ) uni_mse = next( (r["mse"] for r in results if _is_baseline(r["mode"])), results[0]["mse"] ) for r in results: g_mae = (uni_mae - r["mae"]) / uni_mae * 100 g_mse = (uni_mse - r["mse"]) / uni_mse * 100 print( f" {_mode_nice(r['mode']):<40} " f"MAE={r['mae']:.4f} ({g_mae:+.1f}%) " f"MSE={r['mse']:.4f} ({g_mse:+.1f}%)" ) # 7) 当前 pred_len 单独出图 if make_figures: tag = f"_{domain}_h{pl}_s{seed}" plot_ablation_summary( results, f"./image/iAdapter_ablation_summary{tag}.png" ) plot_rolling_mae( results, f"./image/iAdapter_rolling_mae{tag}.png" ) plot_pred_curves( results, sensor_idx=0, save_path=f"./image/iAdapter_pred_curves{tag}.png" ) if gate_hist_full: plot_gate_usage( gate_hist_full, save_path=f"./image/iAdapter_gate_usage{tag}.png" ) # 8) 多 pred_len 汇总图 if make_figures and len(pred_lens) > 1: plot_multi_pred_len_summary( all_results, save_path=f"./image/iAdapter_multi_predlen_{domain}_s{seed}.png" ) return all_results
def report_averages(csv_path="finally_mm.csv",
out_csv="iAdapter_avg_summary.csv"):
"""读取运行 CSV, 汇报各配置平均 MSE/MAE (跨 seed 均值±std) 及每模式总平均。"""
if not os.path.exists(csv_path):
print(f"[report] CSV not found: {csv_path}")
return None
df = pd.read_csv(csv_path)
need = {"data", "pred_len", "mode", "seed", "mae", "mse"}
if not need.issubset(df.columns):
print(f"[report] CSV missing columns: {need - set(df.columns)}")
return None
df = df.drop_duplicates(
subset=["data", "pred_len", "mode", "seed"], keep="last")
textdef _agg(g): return pd.Series({ "mae_mean": g["mae"].mean(), "mae_std": g["mae"].std(ddof=0), "mse_mean": g["mse"].mean(), "mse_std": g["mse"].std(ddof=0), "n_seed": g["seed"].nunique(), }) per = (df.groupby(["data", "pred_len", "mode"], sort=False) .apply(_agg).reset_index()) per_dm = (df.groupby(["data", "mode"], sort=False) .apply(_agg).reset_index()) per_m = (df.groupby(["mode"], sort=False).apply(_agg).reset_index()) # ===== 按数据集(data)×模式(mode): 对各 pred_len 的跨 seed 均值再取平均 ===== per_data_mode = (per.groupby(["data", "mode"], sort=False) .agg(mae_mean=("mae_mean", "mean"), mae_std=("mae_mean", "std"), # pred_len 间波动 mse_mean=("mse_mean", "mean"), mse_std=("mse_mean", "std"), n_seed=("pred_len", "nunique")) # 此列实为 pred_len 数 .reset_index()) per_data_mode[["mae_std", "mse_std"]] = \ per_data_mode[["mae_std", "mse_std"]].fillna(0.0) def _print_block(title, tbl, keys, count_name="seeds"): print(f"\n{'=' * 78}\n[REPORT] {title}\n{'=' * 78}") print(" ".join(f"{k:<12}" for k in keys) + f" {'MAE(mean±std)':<22} {'MSE(mean±std)':<22} {count_name}") for _, r in tbl.iterrows(): kv = " ".join(f"{str(r[k]):<12}" for k in keys) print(f"{kv} {r['mae_mean']:.4f} ± {r['mae_std']:.4f} " f"{r['mse_mean']:.4f} ± {r['mse_std']:.4f} " f"{int(r['n_seed'])}") _print_block("平均 MSE/MAE — 每 (域, pred_len, 模式), 跨 seed", per, ["data", "pred_len", "mode"]) _print_block("平均 MSE/MAE — 每 (域, 模式), 跨 pred_len×seed", per_dm, ["data", "mode"]) _print_block("数据集平均 MSE/MAE — 每 (域, 模式), 各 pred_len 等权 " "(std 为 pred_len 间波动)", per_data_mode, ["data", "mode"], count_name="pred_lens") _print_block("总平均 MSE/MAE — 每模式 (跨 域×pred_len×seed)", per_m, ["mode"]) uni = per_m[per_m["mode"].str.contains("Unimodal|w/o Text", case=False, regex=True)] if len(uni): um, us = uni["mae_mean"].iloc[0], uni["mse_mean"].iloc[0] print(f"\n[REPORT] 相对 Unimodal(ADAPT-Z) 的总平均收益:") for _, r in per_m.iterrows(): print(f" {r['mode']:<28} " f"MAE {(um - r['mae_mean']) / um * 100:+6.2f}% " f"MSE {(us - r['mse_mean']) / us * 100:+6.2f}%") per.to_csv(out_csv, index=False) per_dm.to_csv(out_csv.replace(".csv", "_by_domain.csv"), index=False) per_data_mode.to_csv(out_csv.replace(".csv", "_by_data_mode.csv"), index=False) per_m.to_csv(out_csv.replace(".csv", "_by_mode.csv"), index=False) print(f"\n[report] saved -> {out_csv} " f"(+ _by_domain / _by_data_mode / _by_mode)") return per, per_dm, per_data_mode, per_m
def diagnose_domain(csv_path="finally_mm.csv", domain="Energy"):
"""按 pred_len 拆开看,定位是长horizon崩了还是文本模态有害。"""
import pandas as pd
df = pd.read_csv(csv_path)
df = df[df["data"] == domain]
df = df.drop_duplicates(subset=["data", "pred_len", "mode", "seed"], keep="last")
textprint(f"\n=== {domain}: 逐 pred_len 明细 (跨 seed 均值) ===") g = df.groupby(["pred_len", "mode"])[["mae", "mse"]].mean().reset_index() for pl in sorted(g["pred_len"].unique()): sub = g[g["pred_len"] == pl] m1 = sub[sub["mode"] == "w/o Text"] base_mae = m1["mae"].iloc[0] if len(m1) else float("nan") print(f"\n pred_len={pl} (基线 M1 MAE={base_mae:.4f})") for _, r in sub.iterrows(): gain = (base_mae - r["mae"]) / base_mae * 100 flag = " ← 文本有害!" if ("Text" not in r["mode"] and False) else "" print(f" {r['mode']:<24} MAE={r['mae']:.4f} ({gain:+.1f}%) MSE={r['mse']:.4f}") return g
def main():
ap = argparse.ArgumentParser(
description="iAdapter main (REAL Time-MMD text, optimized)")
ap.add_argument("--data", default="all",
help=f"domain | comma list | 'all' "
f"({', '.join(DOMAINS)}; Health=Health_US)")
ap.add_argument("--data_dir", default="./data")
ap.add_argument("--seq_len", type=int, default=0, help="0=per-domain default")
ap.add_argument("--pred_lens", default="6,8,10,12",
help="逗号分隔的预测长度列表, 如 '6,8,10,12' '12,24,36,48'; "
"留空=域默认单值")
ap.add_argument("--seeds", default="2023,2024,2025,2026,2027")
ap.add_argument("--batch_size", type=int, default=16)
ap.add_argument("--d_model", type=int, default=64)
ap.add_argument("--e_layers", type=int, default=2)
ap.add_argument("--z_loc", type=int, default=2)
ap.add_argument("--text_encoder", default="BERT",
choices=["BERT", "GPT2", "GPT2M", "simple"])
ap.add_argument("--text_dim", type=int, default=64)
ap.add_argument("--llm_layers", type=int, default=6)
ap.add_argument("--text_source", default="both",
choices=["fact", "preds", "both"])
ap.add_argument("--text_lookback", type=int, default=16)
ap.add_argument("--max_clauses", type=int, default=6)
ap.add_argument("--max_chars", type=int, default=1200)
ap.add_argument("--raw_text_topk", type=int, default=6)
ap.add_argument("--raw_lookback_days", type=int, default=60)
ap.add_argument("--keep_history_stats", action="store_true")
ap.add_argument("--ot_only", action="store_true")
ap.add_argument("--no_text_cache", action="store_true",
help="disable frozen-LLM pooled cache [O3]")
ap.add_argument("--his_mode", default="latest",
choices=["ema", "latest", "sum"],
help="his_grad update [O2]; 'sum' reproduces legacy "
"unbounded accumulation")
ap.add_argument("--his_momentum", type=float, default=0.9)
ap.add_argument("--dropout", type=float, default=0.1)
ap.add_argument("--weight_decay", type=float, default=1e-4)
ap.add_argument("--grad_clip", type=float, default=1.0)
ap.add_argument("--patience", type=int, default=3)
ap.add_argument("--warmup_rounds", type=int, default=3)
ap.add_argument("--modes", default="all", help="e.g. m1,m5 or 'all'")
ap.add_argument("--ckpt_dir", default=".")
ap.add_argument("--pretrain_epochs", type=int, default=15)
ap.add_argument("--pretrain_lr", type=float, default=1e-3)
ap.add_argument("--adapter_lr", type=float, default=None)
ap.add_argument("--finetune_lr", type=float, default=None)
ap.add_argument("--online_lr", type=float, default=None)
ap.add_argument("--adp_online_lr", type=float, default=None)
ap.add_argument("--csv", default="finally_mm.csv")
ap.add_argument("--no_figures", action="store_true")
ap.add_argument("--preview", type=int, default=0,
help="N>0: only print N cleaned text samples per domain "
"and exit (no torch needed) [O10]")
a = ap.parse_args()
textdoms = (DOMAINS if a.data.strip().lower() == "all" else [canonical_domain(d) for d in a.data.split(",") if d.strip()]) if a.preview > 0: # 纯文本预览 for d in doms: p = RealTextProvider( d, data_dir=a.data_dir, text_source=a.text_source, text_lookback=a.text_lookback, max_clauses=a.max_clauses, max_chars=a.max_chars, raw_text_topk=a.raw_text_topk, raw_lookback_days=a.raw_lookback_days, keep_history_stats=a.keep_history_stats, ot_only=a.ot_only) p.preview(a.preview) return if not _RUNTIME_OK: raise RuntimeError( f"Training mode needs torch/matplotlib/sklearn/tqdm. " f"Import failed with: {_IMPORT_ERR!r}\n" f"(--preview N works without them.)") pred_lens = ([int(x) for x in a.pred_lens.split(",") if x.strip()] or None) lr_over = dict(adapter_lr=a.adapter_lr, finetune_lr=a.finetune_lr, online_lr=a.online_lr, adp_online_lr=a.adp_online_lr) seeds = [int(s) for s in a.seeds.split(",") if s.strip()] for d, seed in itertools.product(doms, seeds): finetune_mm( data=d, data_dir=a.data_dir, seq_len=a.seq_len or None, pred_lens=pred_lens, seed=seed, batch_size=a.batch_size, d_model=a.d_model, e_layers=a.e_layers, z_loc=a.z_loc, text_encoder=a.text_encoder, text_dim=a.text_dim, llm_layers=a.llm_layers, dropout=a.dropout, weight_decay=a.weight_decay, grad_clip=a.grad_clip, patience=a.patience, warmup_rounds=a.warmup_rounds, his_mode=a.his_mode, his_momentum=a.his_momentum, text_source=a.text_source, text_lookback=a.text_lookback, max_clauses=a.max_clauses, max_chars=a.max_chars, keep_history_stats=a.keep_history_stats, ot_only=a.ot_only, raw_text_topk=a.raw_text_topk, raw_lookback_days=a.raw_lookback_days, use_text_cache=not a.no_text_cache, modes=parse_modes(a.modes), make_figures=not a.no_figures, ckpt_dir=a.ckpt_dir, pretrain_epochs=a.pretrain_epochs, pretrain_lr=a.pretrain_lr, lr_overrides=lr_over, csv_path=a.csv)
if name == "main":
main()
report_averages()
diagnose_domain()这里窗口长度,预测眼界,回溯窗口有什么规律,还是不懂。换其他文本编码器,看看对文本语义理解有何不同
你这段代码里有 3 个“窗口/长度”,它们作用不同:
seq_len:数值输入窗口长度它决定模型每次看多少个历史时间点的数值序列。
例如:
pythonseq_len = 48 pred_len = 6
表示:
text输入:过去 48 个时间点 预测:未来 6 个时间点
如果某个样本从第 100 行开始,那么:
textx = 第100~147行 y = 第148~153行
所以 seq_len 是 历史数值窗口。
你代码里如果命令行没有指定 --seq_len,会走这个逻辑:
pythonbase = seq_len if seq_len else adaptive_seq_len(provider, raw_pred_len) sl, pl = auto_window(provider.n_rows, base, raw_pred_len)
也就是说:
text先根据 pred_len 和数据周期性自动估计 seq_len 再根据数据总行数自动缩短,保证 train/val/test 还有足够样本
adaptive_seq_len() 的规律是:
textseq_len 下界 ≈ 2 × pred_len seq_len 上界 ≈ 6 × pred_len 如果检测到明显周期 P,则尽量覆盖 2 个周期:seq_len ≥ 2P 但最多不超过数据总长度的 50%
所以它不是固定的,而是:
text预测越远,seq_len 越长; 数据有周期,seq_len 会尽量覆盖周期; 数据太短,seq_len 会被 auto_window 自动缩短。
pred_len:预测眼界 / 预测步长 / horizon你说的“预测眼界”就是 pred_len,也就是一次预测未来多少步。
你现在默认是:
python--pred_lens 6,8,10,12
所以程序会依次跑:
text预测未来 6 步 预测未来 8 步 预测未来 10 步 预测未来 12 步
每个 pred_len 会单独训练/加载一个 backbone checkpoint,文件名里也有:
pythoniAdapter_backbone_{domain}_L{sl}_h{pl}_...
其中:
textL = seq_len h = pred_len
所以 h6、h8、h10、h12 是不同预测任务,不能直接混在一起看。一般规律是:
textpred_len 越大,预测越难,MAE/MSE 通常越高; 短期 horizon 更依赖近期波动; 长期 horizon 更依赖趋势、周期、文本背景信息。
text_lookback:文本历史回溯窗口这个是你最容易混淆的地方。
text_lookback 不是数值窗口,而是 取多少行历史文本来辅助当前预测。
关键函数是:
pythondef text_for_row(self, r: int): for rr in range(max(0, r - self.text_lookback + 1), r + 1): ...
也就是说,如果当前数值输入窗口的最后一行是第 r 行,模型会取:
text第 r - text_lookback + 1 行 到 第 r 行
之间的历史文本。
例如:
pythonseq_len = 48 pred_len = 6 text_lookback = 16
某个样本:
text数值输入:第100~147行 预测目标:第148~153行 文本输入:第132~147行对应的文本
注意,它不会取第 148~153 行的文本,所以这是抗泄漏的。
你的代码里文本对齐用的是:
python输入窗口末行全局行号 = offset + window_idx + seq_len - 1
也就是只看输入窗口末尾之前的文本。
可以把它理解成:
textseq_len :看过去多少数值数据 pred_len :预测未来多远 text_lookback :看过去多少文本记录
对应关系是:
text[历史数值窗口 seq_len] → [未来预测窗口 pred_len] ↑ [历史文本窗口 text_lookback]
一个具体例子:
textseq_len = 48 pred_len = 12 text_lookback = 16
表示:
text模型看过去 48 个时间点的数值序列; 同时看过去 16 行文本报道; 然后预测未来 12 个时间点。
你命令行默认写的是:
pythonap.add_argument("--text_lookback", type=int, default=16)
后面又有:
pythonprovider.set_text_lookback( max(text_lookback, math.ceil(pl / max(1, dp_)) + 1) )
这意味着:
texttext_lookback 至少是 16
即使 pred_len=6、8、10、12,后面的自适应公式算出来可能只有 2、3、4,但因为 max(text_lookback, ...),最终还是 16。
所以你现在的文本窗口实际大概率一直是:
texttext_lookback = 16
如果你想让文本窗口随预测眼界变化,建议把默认值改小,例如:
pythonap.add_argument("--text_lookback", type=int, default=2)
或者命令行运行时写:
bashpython iAdapter_main.py --data Traffic --text_lookback 2
这样程序才会根据 pred_len 自动扩大文本回溯范围。
如果是日度数据:
textpred_len = 6 / 8 / 10 / 12 seq_len = 2~6 倍 pred_len,或覆盖明显周期 text_lookback = 2~8 行
如果是周度/月度数据:
texttext_lookback 不宜太大
因为 16 行文本可能已经跨了很长时间,容易引入旧新闻噪声。
比较稳的实验设置可以是:
bash--pred_lens 6,8,10,12 --text_lookback 2 --pred_lens 6,8,10,12 --text_lookback 4 --pred_lens 6,8,10,12 --text_lookback 8 --pred_lens 6,8,10,12 --text_lookback 16
然后看哪一个文本窗口下 M5 相对 M1 收益最大。
你现在支持 4 种:
pythonchoices=["BERT", "GPT2", "GPT2M", "simple"]
它们理论上的区别是:
| 编码器 | 语义特点 | 适合情况 |
|---|---|---|
simple | 从头训练词表和 embedding,没有预训练语义 | 数据量够、领域词很特殊 |
BERT | 双向语义理解,适合事实描述、事件解释 | fact 文本、客观新闻、报告摘要 |
GPT2 | 单向语言模型,偏生成式语境 | preds、outlook、趋势预测类文本 |
GPT2M | 更大的 GPT2,中长期语义表达更强,但更慢更占显存 | 显存充足、文本较复杂 |
但是你当前代码里有一个非常关键的问题:
pythonemb = self.llm.get_input_embeddings()(input_ids) if self.use_fullmodel: emb = self.llm(inputs_embeds=emb).last_hidden_state
而 use_fullmodel 默认是:
pythonuse_fullmodel=False
所以现在的 BERT / GPT2 / GPT2M 实际上 没有真正跑 Transformer 主体,只是用了它们的 token embedding,然后做 mean pooling。
也就是说,现在的差异主要来自:
text不同 tokenizer 不同预训练词向量表 不同 embedding 空间
而不是完整的深层语义理解。
如果你想真正比较:
textBERT 的语义理解 GPT2 的语义理解 GPT2M 的语义理解
你应该加一个命令行参数,比如:
pythonap.add_argument("--use_fullmodel", action="store_true")
然后在创建 LLMTextEncoder 时传进去:
pythonself.text_enc = LLMTextEncoder( llm_tag=text_encoder_type, output_dim=text_dim, llm_layers=llm_layers, dropout=dropout, device=self.device, use_fullmodel=args.use_fullmodel ).to(self.device)
否则你现在说“换文本编码器看语义理解差异”,其实是不充分的。
先不要跑所有域,先选一个域,比如 Traffic 或 Energy。
建议只跑 M1、M2、M5:
textM1:无文本基线 M2:只加文本语义 M5:MoE + 文本梯度,完整方法
命令如下:
bashpython iAdapter_main.py --data Traffic --pred_lens 6,12 --seeds 2023 --modes m1,m2,m5 --text_encoder simple --text_source both --text_lookback 4
bashpython iAdapter_main.py --data Traffic --pred_lens 6,12 --seeds 2023 --modes m1,m2,m5 --text_encoder BERT --text_source both --text_lookback 4
bashpython iAdapter_main.py --data Traffic --pred_lens 6,12 --seeds 2023 --modes m1,m2,m5 --text_encoder GPT2 --text_source both --text_lookback 4
如果显存够,再跑:
bashpython iAdapter_main.py --data Traffic --pred_lens 6,12 --seeds 2023 --modes m1,m2,m5 --text_encoder GPT2M --text_source both --text_lookback 4
如果你加了 --use_fullmodel,再跑:
bashpython iAdapter_main.py --data Traffic --pred_lens 6,12 --seeds 2023 --modes m1,m2,m5 --text_encoder BERT --use_fullmodel --text_source both --text_lookback 4
不要只看 M5 是否最好,要拆开看。
textM1 = w/o Text M2 = w/o ∇e
如果:
textM2 < M1
说明单纯文本语义先验有用。
如果:
textM2 > M1
说明文本可能有噪声,或者编码器理解不到位。
textM4 = MoE Fusion,但没有 text gradient M5 = iAdapter,加入 text gradient
如果:
textM5 < M4
说明 ∂L/∂text_emb 的文本梯度反馈有用。
如果:
textM5 > M4
说明文本梯度可能引入了不稳定扰动。
你可以分别跑:
bash--text_source fact --text_source preds --text_source both
解释方式是:
textfact 好:客观事实报道对预测有帮助 preds 好:前瞻性文本、预测性描述对未来趋势有帮助 both 好:事实 + 预测互补 both 差:两类文本混在一起噪声变大
你的 report_averages() 这里:
pythondf = df.drop_duplicates( subset=["data", "pred_len", "mode", "seed"], keep="last")
它没有把 text_encoder 放进去。
所以你如果依次跑:
textBERT GPT2 simple
同一个:
textdata, pred_len, mode, seed
会被认为是重复实验,只保留最后一次。
这会导致你没法正确比较不同文本编码器。
建议改成:
pythondf = df.drop_duplicates( subset=[ "data", "pred_len", "mode", "seed", "text_encoder", "text_source", "his_mode" ], keep="last" )
然后 groupby 也加上 text_encoder:
pythonper = ( df.groupby(["data", "pred_len", "text_encoder", "text_source", "mode"], sort=False) .apply(_agg) .reset_index() )
否则你跑出来的平均结果会混掉。
你这套代码的规律可以概括成:
textseq_len 决定模型看多长的历史数值; pred_len 决定模型预测多远的未来; text_lookback 决定模型额外看多长的历史文本; 文本只允许对齐到输入窗口末行,不能看到未来目标区间。
而换文本编码器时,当前代码默认只是比较不同模型的词向量 embedding,不是真正比较 BERT/GPT2 的完整语义理解;如果想严谨比较语义能力,需要开启 use_fullmodel=True,并且修改 report_averages(),把 text_encoder 纳入分组统计。