Files
ts-as-modality/tests/test_multimodal.py
T
张宗平 f509d47878 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).
2026-06-30 02:05:15 +00:00

137 lines
5.3 KiB
Python

"""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])