From f20c2db803becf810b0960beaa2a8aa8ba1d54bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=97=E5=B9=B3?= Date: Tue, 30 Jun 2026 00:17:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(m2):=20Projector=20Linear(256=E2=86=92896)?= =?UTF-8?q?+LayerNorm=20(T2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/tsmm/model/projector.py: Projector (Linear + LayerNorm). - tests/test_projector.py: 5 tests (shape, Qwen hidden alignment, LayerNorm present, gradient flow, no-NaN). - 66 tests passing. --- openspec/changes/ts-as-modality/tasks.md | 2 +- src/tsmm/model/projector.py | 22 +++++++++++++++ tests/test_projector.py | 35 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/tsmm/model/projector.py create mode 100644 tests/test_projector.py diff --git a/openspec/changes/ts-as-modality/tasks.md b/openspec/changes/ts-as-modality/tasks.md index d725d04..f6bb633 100644 --- a/openspec/changes/ts-as-modality/tasks.md +++ b/openspec/changes/ts-as-modality/tasks.md @@ -16,7 +16,7 @@ ## 2. M2 · 模型可跑通 - [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) -- [ ] 2.2 Projector `model/projector.py`:`Linear(256→896)+LayerNorm`(验证:输出 `[B,128,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 ≤1024,mask/labels 形状对) - [ ] 2.4 训练/推理封装 `model/wrapper.py`:`MultimodalTSModel` 组合 Encoder+Projector+LLM+LoRA 挂载开关,`forward` 返 loss、`generate` 返文本,支持 `freeze_llm`(阶段①)/`enable_lora(r=16)`(阶段②)(验证:单 batch forward 3060 不 OOM;generate 能出文本) - [ ] 2.5 Collator `data/collator.py`:JSONL→batch 张量,处理变长 C(padding+mask)与变长文本(padding+attention_mask)(验证:batch=4 张量形状一致无 NaN) diff --git a/src/tsmm/model/projector.py b/src/tsmm/model/projector.py new file mode 100644 index 0000000..eed9692 --- /dev/null +++ b/src/tsmm/model/projector.py @@ -0,0 +1,22 @@ +"""Projector (T2.2): maps TS tokens from encoder dim to LLM hidden dim. + +``Linear(d → h)`` + ``LayerNorm``. Output feeds as soft tokens into the LLM +``inputs_embeds`` (see ``model/multimodal.py``, T2.3). + +Design ref: §2.2 — Projector = Linear(256→896) + LayerNorm. +""" +from __future__ import annotations + +import torch +from torch import nn + + +class Projector(nn.Module): + def __init__(self, in_dim: int = 256, out_dim: int = 896) -> None: + super().__init__() + self.linear = nn.Linear(in_dim, out_dim) + self.norm = nn.LayerNorm(out_dim) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + # x: [..., in_dim] → [..., out_dim] + return self.norm(self.linear(x)) diff --git a/tests/test_projector.py b/tests/test_projector.py new file mode 100644 index 0000000..b4b7b03 --- /dev/null +++ b/tests/test_projector.py @@ -0,0 +1,35 @@ +"""Tests for Projector (T2.2): TS token d → LLM hidden.""" +import torch + +from tsmm.model.projector import Projector + + +class TestProjector: + def test_output_shape(self): + proj = Projector(in_dim=256, out_dim=896) + x = torch.randn(2, 127, 256) + out = proj(x) + assert out.shape == (2, 127, 896) + + def test_aligns_qwen_hidden(self): + # Qwen2.5-0.5B hidden = 896 + proj = Projector(in_dim=256, out_dim=896) + out = proj(torch.randn(1, 127, 256)) + assert out.shape[-1] == 896 + + def test_has_layernorm(self): + proj = Projector(in_dim=256, out_dim=896) + assert any(isinstance(m, torch.nn.LayerNorm) for m in proj.modules()) + + def test_gradient_flows(self): + proj = Projector(in_dim=256, out_dim=896) + x = torch.randn(1, 31, 256) + out = proj(x) + out.sum().backward() + assert proj.linear.weight.grad is not None + assert proj.linear.weight.grad.abs().sum() > 0 + + def test_no_nan(self): + proj = Projector(in_dim=256, out_dim=896) + out = proj(torch.randn(4, 128, 256)) + assert not torch.isnan(out).any()