题目重点:语言模型vocabulary head不能直接充当二分类输出。
题意与假设
这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。
- 训练使用上述causal hidden states做sequence classification,而非改成双向encoder;右padding mask至少一个有效token。mean pooling接D×C classifier head+bias。
- 代码示范前向,不假装包含fine-tune训练器或真实分类数据。参数冻结/联合训练、分类标签和类不平衡由任务合同决定。
解题路线
- 取FFN后的hidden B×T×D,不取B×T×Vocab logits当embedding。mask加权mean除有效长度,padding必须排除,输出B×C,再按分类交叉熵训练。
- 保留causal attention时早位置只看前缀,mean包含各前缀表示;last-valid pooling只用完整前缀末位。如果改双向encoder,所有有效token可互看,需要另写padding attention mask,不能称只是换head且模型完全相同。
- 右padding未来位置不会影响前面causal hidden,但mean仍需mask。训练代码拒绝0长度及非右padding,明确没有任意左paddingposition处理;不能只传pooling mask就宣称内部padding attention已完备。
- 从验证开始:mask掉的suffix token改变不应影响分类结果;同一真实序列不同右padding长度结果一致;按标签分组查混淆矩阵、校准和length distribution,避免分类器学到padding或截断伪信号。
- freeze backbone只训head成本较低但表示适配有限,joint fine-tune需要较小学习率和验证早停。数据划分按来源/时间/重复组隔离,不能依靠大模型预训练名气跳过泄漏检查。
手动走一遍
两条句子长度2/4都padding到4,mean分别除2/4,不全除4;否则短句hidden整体缩小一半。替换masked suffix的token ID后classification logits不变,作为明确的padding行为验证。二分类输出B×2接CE,或B×1接BCEWithLogits;不要对两个logits分别sigmoid再当互斥概率。
Python 参考实现
下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。
代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。
import numpy as np
def layer_norm(x):
return (x-x.mean(axis=-1,keepdims=True))/np.sqrt(x.var(axis=-1,keepdims=True)+1e-5)
def gelu(x):return .5*x*(1+np.tanh(np.sqrt(2/np.pi)*(x+.044715*x**3)))
def initialize(vocab,width,heads,max_length,seed=0):
if min(vocab,width,heads,max_length)<1 or width%heads:raise ValueError('dimensions/heads')
rng=np.random.default_rng(seed);scale=width**-.5
return dict(token=rng.normal(size=(vocab,width))*scale,position=rng.normal(size=(max_length,width))*scale,
q=rng.normal(size=(width,width))*scale,k=rng.normal(size=(width,width))*scale,
v=rng.normal(size=(width,width))*scale,o=rng.normal(size=(width,width))*scale,
ff1=rng.normal(size=(width,4*width))*scale,ff2=rng.normal(size=(4*width,width))*scale,heads=heads)
def forward(tokens,p,cache=None):
ids=np.asarray(tokens)
if ids.ndim!=2 or ids.shape[1]<1 or not np.issubdtype(ids.dtype,np.integer):raise ValueError('B,T integer tokens')
if np.any(ids<0) or np.any(ids>=len(p['token'])):raise ValueError('token ID')
b,t=ids.shape;d=p['token'].shape[1];h=p['heads'];dh=d//h
offset=0 if cache is None else cache['k'].shape[2]
if offset+t>len(p['position']):raise ValueError('position length')
x=p['token'][ids]+p['position'][offset:offset+t];normalized=layer_norm(x)
split=lambda a:a.reshape(b,t,h,dh).transpose(0,2,1,3)
q=split(normalized@p['q']);k=split(normalized@p['k']);v=split(normalized@p['v'])
if cache is not None:
if cache['k'].shape!=(b,h,offset,dh) or cache['v'].shape!=cache['k'].shape:raise ValueError('cache shape')
k=np.concatenate([cache['k'],k],axis=2);v=np.concatenate([cache['v'],v],axis=2)
scores=q@k.swapaxes(-1,-2)/np.sqrt(dh)
permitted=np.arange(k.shape[2])[None,:]<=offset+np.arange(t)[:,None]
scores=np.where(permitted[None,None,:,:],scores,-np.inf)
shifted=scores-scores.max(axis=-1,keepdims=True);weights=np.exp(shifted);weights/=weights.sum(axis=-1,keepdims=True)
attended=(weights@v).transpose(0,2,1,3).reshape(b,t,d)
hidden=x+attended@p['o'];hidden=hidden+gelu(layer_norm(hidden)@p['ff1'])@p['ff2']
logits=layer_norm(hidden)@p['token'].T
return logits,hidden,dict(k=k,v=v)
def classify(tokens,p,pooling_mask,head,bias):
# Encoder-like use is a different contract; this classifier retains causal hidden states.
_,hidden,_=forward(tokens,p);mask=np.asarray(pooling_mask,dtype=bool)
if mask.shape!=hidden.shape[:2] or np.any(mask.sum(axis=1)==0):raise ValueError('nonempty pooling mask')
# Mask must be right padding; masked suffix cannot influence earlier causal states.
if np.any(mask[:,1:] & ~mask[:,:-1]):raise ValueError('right padding')
pooled=(hidden*mask[:,:,None]).sum(axis=1)/mask.sum(axis=1,keepdims=True)
return pooled@head+bias
复杂度
forward仍O(BT²D+BTD²),pool O(BTD)、head O(BDC);训练反向和优化器状态另计。代码复用vocabulary logits虽classifier不使用,生产可移除该投影节省O(BTD·Vocab)。
易错点与追问
容易失分的边界
- 直接对vocabulary softmax取前两类当分类器。
- pool全padding行产生除零却静默输出NaN。
- 把causal改成bidirectional但复用旧KV cache解释,模型语义已变化。
继续深挖
- last-valid、CLS和mean pooling如何用任务与验证选择?
- 多标签分类loss/mask和评估阈值与单标签有什么不同?
