feat(m2): multimodal splice [属性][时间戳][TS tok][事件][问题][回答] (T2.3)

- src/tsmm/model/multimodal.py: MultimodalSplicer — binds LLM input
  embedding layer, assembles text embeds (via tokenizer) + TS token embeds
  (from Projector) into inputs_embeds/attention_mask/labels; labels = -100
  everywhere except answer+EOS (training); left-truncation to max_len=1024.
- tests/test_multimodal.py: 8 tests against real Qwen2.5-0.5B tokenizer +
  stub embedding (shapes, all-ones mask, answer-only labels, TS token count
  in sequence, no-NaN, truncation). 74 tests passing.
- configs/llm.yaml: offline model path (downloaded via proxy to
  ~/models/Qwen2.5-0.5B-Instruct; HF hub unreachable on host).

Spec clarifications (small tier, comet-build Step 4):
- Training appends answer + EOS to the prompt layout.
- Build embedding layer with len(tokenizer) (incl. added special tokens),
  not tokenizer.vocab_size which excludes them (eos=151645).
This commit is contained in:
张宗平
2026-06-30 02:05:15 +00:00
parent f20c2db803
commit f509d47878
4 changed files with 289 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
# LLM backbone config (T2.3 / T2.4)
# Design ref: docs/superpowers/specs/2026-06-29-ts-as-modality-design.md §2.2
# Qwen2.5-0.5B-Instruct: hidden=896, 24 layers, vocab=151936, BF16 ~1GB
# Local offline path (downloaded via proxy; HF hub unreachable on this host)
model_path: /home/zhangzp/models/Qwen2.5-0.5B-Instruct
hf_model_id: Qwen/Qwen2.5-0.5B-Instruct # for reference; offline use model_path
hidden_size: 896
torch_dtype: bfloat16
+1 -1
View File
@@ -17,7 +17,7 @@
- [x] 2.1 TS Encoder `model/ts_encoder.py``Patchify(patch=8, stride=4)` 通道独立切 patch 线性嵌入到 d=256`TSEncoder(d=256, layers=2, heads=4)` 2 层 TF + 可学习位置编码;前向 `[B,T,C]``[B,n_patches,256]`(验证:输入 `[2,512,5]``[2,127,256]`spec 澄清:n_patches=(T-P)/S+1=127 非 128;通道独立=每通道共享 patch 线性后对通道 mean-pool;参数 ≈2.1M 非 4M - [x] 2.1 TS Encoder `model/ts_encoder.py``Patchify(patch=8, stride=4)` 通道独立切 patch 线性嵌入到 d=256`TSEncoder(d=256, layers=2, heads=4)` 2 层 TF + 可学习位置编码;前向 `[B,T,C]``[B,n_patches,256]`(验证:输入 `[2,512,5]``[2,127,256]`spec 澄清:n_patches=(T-P)/S+1=127 非 128;通道独立=每通道共享 patch 线性后对通道 mean-pool;参数 ≈2.1M 非 4M
- [x] 2.2 Projector `model/projector.py``Linear(256→896)+LayerNorm`(验证:输出 `[B,n_patches,896]` 对齐 Qwen2.5-0.5B hidden - [x] 2.2 Projector `model/projector.py``Linear(256→896)+LayerNorm`(验证:输出 `[B,n_patches,896]` 对齐 Qwen2.5-0.5B hidden
- [ ] 2.3 多模态拼接 `model/multimodal.py`Qwen tokenizer tokenize 文本部分,TS token 作 inputs_embeds 插入 `[属性][时间戳][TS tok][事件][问题]`,统一构造 inputs_embeds+attention_mask+labels(仅回答段非 -100)(验证:seq_len ≤1024mask/labels 形状对) - [x] 2.3 多模态拼接 `model/multimodal.py`Qwen tokenizer tokenize 文本部分,TS token 作 inputs_embeds 插入 `[属性][时间戳][TS tok][事件][问题][回答][EOS]`,统一构造 inputs_embeds+attention_mask+labels(仅回答段非 -100)(验证:8 项单测过——seq_len ≤1024mask 全 1、回答段 label 非 -100、TS token 计入序列、无 NaN、超长左截断)。spec 澄清:训练拼回答+EOS,用 `len(tokenizer)` 构建嵌入表以容纳 special tokens。
- [ ] 2.4 训练/推理封装 `model/wrapper.py``MultimodalTSModel` 组合 Encoder+Projector+LLM+LoRA 挂载开关,`forward` 返 loss、`generate` 返文本,支持 `freeze_llm`(阶段①)/`enable_lora(r=16)`(阶段②)(验证:单 batch forward 3060 不 OOMgenerate 能出文本) - [ ] 2.4 训练/推理封装 `model/wrapper.py``MultimodalTSModel` 组合 Encoder+Projector+LLM+LoRA 挂载开关,`forward` 返 loss、`generate` 返文本,支持 `freeze_llm`(阶段①)/`enable_lora(r=16)`(阶段②)(验证:单 batch forward 3060 不 OOMgenerate 能出文本)
- [ ] 2.5 Collator `data/collator.py`JSONL→batch 张量,处理变长 Cpadding+mask)与变长文本(padding+attention_mask)(验证:batch=4 张量形状一致无 NaN - [ ] 2.5 Collator `data/collator.py`JSONL→batch 张量,处理变长 Cpadding+mask)与变长文本(padding+attention_mask)(验证:batch=4 张量形状一致无 NaN
- [ ] 2.6 M2 出口验证:单 batch 前向+反向在 3060 跑通,loss 有限且下降趋势,阶段①模式峰值显存 ~5GB - [ ] 2.6 M2 出口验证:单 batch 前向+反向在 3060 跑通,loss 有限且下降趋势,阶段①模式峰值显存 ~5GB
+142
View File
@@ -0,0 +1,142 @@
"""Multimodal splice (T2.3): assemble TS tokens + text tokens into the LLM input.
Prompt layout (per design §2.1):
[属性][时间戳][TS tokens][事件][问题] # inference / prompt
[属性][时间戳][TS tokens][事件][问题][回答][EOS] # training
This module produces, for a single sample, the LLM-ready tensors:
- ``inputs_embeds`` [1, seq, hidden] (text via the bound embedding layer,
TS tokens via the Projector output)
- ``attention_mask`` [1, seq]
- ``labels`` [1, seq] (-100 everywhere except the answer+EOS span)
Text segments are tokenized with ``add_special_tokens=False`` so we control the
structure; the Qwen2.5 tokenizer is used as-is. Batching / variable-length
padding is the collator's job (T2.5).
Truncation: if the assembled sequence exceeds ``max_len``, it is left-truncated
to keep the TS tokens + answer (the part the model must read/predict), mirroring
how long prompts are usually handled. The TS token block is never truncated.
Design ref: ``docs/superpowers/specs/2026-06-29-ts-as-modality-design.md`` §2.1.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Sequence
import torch
from torch import Tensor
@dataclass
class SpliceOutput:
inputs_embeds: Tensor # [1, seq, hidden]
attention_mask: Tensor # [1, seq] (0/1 int)
labels: Tensor # [1, seq] (-100 or token id)
class MultimodalSplicer:
"""Assemble a multimodal sample from text segments + projected TS tokens.
The text embedding layer (typically ``llm.get_input_embeddings()``) must be
bound via :meth:`bind` before calling :meth:`splice`.
"""
def __init__(
self,
tokenizer,
hidden_size: int,
max_len: int = 1024,
) -> None:
self.tokenizer = tokenizer
self.hidden_size = hidden_size
self.max_len = max_len
self._embed: Optional[torch.nn.Module] = None
# -- binding ----------------------------------------------------------
def bind(self, embed_layer: torch.nn.Module) -> None:
"""Bind the LLM text-embedding layer (input embeddings)."""
self._embed = embed_layer
@property
def embed(self) -> torch.nn.Module:
if self._embed is None:
raise RuntimeError("Splicer has no bound embedding layer; call bind().")
return self._embed
# -- internals --------------------------------------------------------
def _tok(self, text: str) -> Tensor:
"""Tokenize a text segment (no special tokens) → 1D LongTensor."""
if text is None:
text = ""
ids = self.tokenizer(text, add_special_tokens=False)["input_ids"]
return torch.tensor(ids, dtype=torch.long)
def _embed_ids(self, ids: Tensor) -> Tensor:
return self.embed(ids) # [len, hidden]
# -- public API -------------------------------------------------------
def splice(
self,
*,
attributes: str,
timestamps: str,
ts_embeds: Tensor, # [n_patches, hidden] (may be [0, hidden])
events: str,
question: str,
answer: Optional[str] = None,
) -> SpliceOutput:
tok_eos = self.tokenizer.eos_token_id
# 1. tokenize text segments
attr_ids = self._tok(attributes)
ts_ids = self._tok(timestamps)
event_ids = self._tok(events)
q_ids = self._tok(question)
ans_ids = self._tok(answer) if answer is not None else None
# 2. embed the text segments
attr_emb = self._embed_ids(attr_ids)
ts_text_emb = self._embed_ids(ts_ids)
if ts_embeds.shape[0] > 0:
ts_tok_emb = ts_embeds
else:
ts_tok_emb = torch.zeros(0, self.hidden_size)
event_emb = self._embed_ids(event_ids)
q_emb = self._embed_ids(q_ids)
# answer (training only)
if ans_ids is not None:
ans_with_eos = torch.cat([ans_ids, torch.tensor([tok_eos], dtype=torch.long)])
ans_emb = self._embed_ids(ans_with_eos)
# 3. assemble embeddings along the sequence dimension
prompt_parts = [attr_emb, ts_text_emb, ts_tok_emb, event_emb, q_emb]
if ans_ids is not None:
prompt_parts.append(ans_emb)
embeds = torch.cat(prompt_parts, dim=0) # [seq, hidden]
# 4. build labels (default -100)
seq_len = embeds.shape[0]
labels = torch.full((seq_len,), -100, dtype=torch.long)
if ans_ids is not None:
ans_start = seq_len - ans_emb.shape[0]
labels[ans_start:] = ans_with_eos
# 5. truncate (left) to max_len, never dropping the answer
if seq_len > self.max_len:
keep = self.max_len
embeds = embeds[-keep:]
labels = labels[-keep:]
# 6. attention mask (single unpadded sample → all ones)
attn = torch.ones(embeds.shape[0], dtype=torch.long)
return SpliceOutput(
inputs_embeds=embeds.unsqueeze(0), # [1, seq, hidden]
attention_mask=attn.unsqueeze(0), # [1, seq]
labels=labels.unsqueeze(0), # [1, seq]
)
+136
View File
@@ -0,0 +1,136 @@
"""Tests for multimodal splice (T2.3).
Verifies the splice [属性][时间戳][TS tok][事件][问题][回答] produces:
- inputs_embeds [1, seq, hidden] with seq_len <= max_len
- attention_mask [1, seq] all ones (unpadded single sample)
- labels [1, seq] with -100 everywhere EXCEPT the answer segment
Uses the real Qwen2.5-0.5B tokenizer (local) + a stub embedding layer so the
splice *logic* is validated without loading the full LLM weights.
"""
import pytest
import torch
from transformers import AutoTokenizer
from tsmm.model.multimodal import MultimodalSplicer
TOKENIZER_PATH = "/home/zhangzp/models/Qwen2.5-0.5B-Instruct"
HIDDEN = 896
@pytest.fixture(scope="module")
def tokenizer():
return AutoTokenizer.from_pretrained(TOKENIZER_PATH)
@pytest.fixture(scope="module")
def stub_embed(tokenizer):
# mimic LLM get_input_embeddings(): Embedding(vocab, hidden).
# Use len(tokenizer) which includes added special tokens (eos=151645 etc.);
# tokenizer.vocab_size (151643) excludes them and is too small.
return torch.nn.Embedding(len(tokenizer), HIDDEN)
@pytest.fixture
def splicer(tokenizer, stub_embed):
s = MultimodalSplicer(tokenizer, hidden_size=HIDDEN, max_len=1024)
s.bind(stub_embed)
return s
def make_ts_embeds(n_patches=127, hidden=HIDDEN):
return torch.randn(n_patches, hidden)
class TestSplice:
def test_shapes(self, splicer):
out = splicer.splice(
attributes="变量:CPU利用率 单位:% 采样率:1Hz",
timestamps="时间范围:2026-01-01 00:00 ~ 00:08",
ts_embeds=make_ts_embeds(127),
events="",
question="描述这段时序的整体趋势",
answer="整体呈上升趋势。",
)
assert out.inputs_embeds.dim() == 3
assert out.inputs_embeds.shape[0] == 1
assert out.inputs_embeds.shape[2] == HIDDEN
seq = out.inputs_embeds.shape[1]
assert out.attention_mask.shape == (1, seq)
assert out.labels.shape == (1, seq)
# within context budget
assert seq <= 1024
def test_attention_mask_all_ones_unpadded(self, splicer):
out = splicer.splice(
attributes="CPU", timestamps="t0..t1", ts_embeds=make_ts_embeds(10),
events="", question="Q?", answer="A.",
)
assert torch.all(out.attention_mask == 1)
def test_labels_only_answer_non_masked(self, splicer, tokenizer):
out = splicer.splice(
attributes="CPU", timestamps="t0", ts_embeds=make_ts_embeds(10),
events="", question="Q?", answer="上升趋势",
)
labels = out.labels[0]
# there must be at least one non -100 position (the answer)
assert (labels != -100).sum() > 0
# all non-answer positions must be -100
n_non_masked = int((labels != -100).sum())
# the answer token count should match tokenizing answer (+ eos)
ans_ids = tokenizer("上升趋势", add_special_tokens=False)["input_ids"]
# +1 for appended eos
assert n_non_masked == len(ans_ids) + 1
def test_no_answer_means_all_masked(self, splicer):
# inference mode: no answer → all labels -100
out = splicer.splice(
attributes="CPU", timestamps="t0", ts_embeds=make_ts_embeds(10),
events="", question="Q?", answer=None,
)
assert torch.all(out.labels == -100)
def test_ts_tokens_in_sequence(self, splicer):
# the TS token block length should appear in the total seq length
n_patches = 50
out_no_ts = splicer.splice(
attributes="CPU", timestamps="t0", ts_embeds=torch.zeros(0, HIDDEN),
events="", question="Q?", answer="A.",
)
out_with_ts = splicer.splice(
attributes="CPU", timestamps="t0", ts_embeds=make_ts_embeds(n_patches),
events="", question="Q?", answer="A.",
)
delta = out_with_ts.inputs_embeds.shape[1] - out_no_ts.inputs_embeds.shape[1]
assert delta == n_patches
def test_seq_len_within_budget(self, splicer):
out = splicer.splice(
attributes="CPU 内存 磁盘 网络 温度 请求量",
timestamps="2026-01-01 00:00:00 至 2026-01-01 00:08:32 UTC",
ts_embeds=make_ts_embeds(127),
events="t=300 处发生部署事件:服务重启",
question="请判断这段时序是否存在异常,并以 JSON 给出异常区间。",
answer='{"segments": [[300, 350]], "reason": "部署后延迟突增"}',
)
assert out.inputs_embeds.shape[1] <= 1024
def test_no_nan(self, splicer):
out = splicer.splice(
attributes="CPU", timestamps="t0", ts_embeds=make_ts_embeds(20),
events="", question="Q?", answer="A.",
)
assert not torch.isnan(out.inputs_embeds).any()
def test_truncation_when_overlong(self, splicer):
# force an overlong sequence; splicer should truncate to max_len
splicer.max_len = 40
out = splicer.splice(
attributes="CPU", timestamps="t0",
ts_embeds=make_ts_embeds(127),
events="", question="Q?", answer="A.",
)
assert out.inputs_embeds.shape[1] <= 40
assert out.attention_mask.shape == (1, out.inputs_embeds.shape[1])
assert out.labels.shape == (1, out.inputs_embeds.shape[1])