【多模态大模型面经】 BERT 专题面经

2025-12-16 13:59:25
文章摘要
本文主要介绍了BERT的基本原理以及常见的面试问题

✍ 本专题假设读者已经具备一定的深度学习与 Transformer 基础,目标是帮助读者系统地复习 BERT 模型的核心设计思想与常见面试问法。本专题来源于本人在面试 NLP / LLM / 多模态预训练相关岗位时的真实问题与个人总结,本章的重点是为什么GPT的【MASK】设计会导致数据泄露?为什么BERT在取代【MASK】保留原词的时候就不会导致数据泄露?等比较深入的问题


一、BERT 基本架构

BERT 全称为 Bidirectional Encoder Representations from Transformers,由 Google AI 在 2018 年提出,奠定了后续预训练语言模型(PLM)发展的基石。

📚️ 论文地址:arxiv地址

BERT与Transformer不同,和GPT一样沿用了Transformer的基本架构, 不同的是,BERT是基于 Transformer Encoder 堆叠的模型,舍弃了 Transformer 的 Decoder,仅保留 Encoder 部分, 是一个堆叠了 $L$ 层 Encoder 的纯 Transformer 编码器模型。作者提出了两个大小的BERT模型:

模型 层数 (L) 隐层维度 (H) 注意力头数 (A) 参数量 备注
BERT Base 12 768 12 ~110M 与GPT大小相同
BERT Large 24 1024 16 ~340M 更高表达力,训练更慢

图片描述 面试官很喜欢问大模型架构之间的区别,例如:

🧠 1. Transformer、GPT、BERT 架构分别是什么,为什么要这么用?

代表模型 方向 任务类型 优势 劣势
BERT, RoBERTa Encoder-only 理解(分类、匹配、QA) 双向上下文、语义表达强 无法生成、mask训练慢
GPT serious Decoder-only 生成(文本生成、对话) 自回归生成自然、训练目标简单 单向建模、理解弱
Transformer, T5, BART Encoder–Decoder 理解+生成(摘要、翻译) 二者兼顾,可用于指令模型 训练/推理复杂度高

BERT对于初学者来说,好像也是一个生成任务,但实际上,BERT是在做一个完形填空的任务。

🧩 举个例子:

输入:我爱 MASK 学习

→ BERT 能预测“机器”

输入:我爱学习

→ BERT 无法生成接下来的“因为我有热情”,因为它没有解码器。

因此,BERT 的 MLM 任务确实有“生成”行为,但它并不是自回归意义上的生成模型。 它预测被mask的token,而不是像GPT一样输入一个句子,依次生成一个完整的句子。

🧠 2. 介绍一下BERT的训练过程

BERT 的训练分为两个阶段:

  1. 预训练(Pre-training):在大规模无监督语料上进行语言建模任务学习。
  2. 下游微调(Fine-tuning):在下游任务(分类、问答、序列标注等)上,用任务特定的输入格式(加上 [CLS][SEP] 标志),再加上一个小的输出层。

图片描述


二、BERT: Pre-training

在面试过程中,只要你的简历上涉及到了BERT,BERT一定会问你一个问题:

🧠 3. 预训练任务有哪几个任务

任务 缩写 作用 举例
Masked Language Model MLM 学习双向上下文 猜被 MASK 的词
Next Sentence Prediction NSP 学习句子间关系 判断 B 是不是 A 的下一句

图片描述


2.1 Masked Language Model (MLM)

传统的语言模型(例如 GPT)是条件概率建模,

$$P(x_1, x_2, ..., x_n) = \prod_{t=1}^{n} P(x_t | x_1, ..., x_{t-1})$$

也就是逐词预测下一个 token。这乍一看好像也可以实现双向,假设我们想让模型学:

$$P(x_t | x_1, ..., x_{t-1}, x_{t+1}, ..., x_n)$$

也就是同时看到左右上下文。但问题是:如果模型的多层 self-attention 可以访问所有位置的信息,通过其他 token 的上下文聚合,模型仍然可能在深层“绕回来”获取自己的信息, “间接地”访问到自己的真实词。

🧩 举个例子:

输入: I love NLP, 想预测 token: "love"

Layer 1: "love" attends to "I" and "NLP"

Layer 2: "NLP" attends back to "love"

这样“love”通过“间接路径”又拿到了自己的 embedding,

因此,BERT 引入了 Masked Language Modeling (MLM) 预训练目标,让模型能够同时看到 左右上下文。在预训练时,BERT会随机遮蔽输入序列中 15% 的 token. (其中有80%被替换为MASK, 10% 替换为随机词, 10% 保留原词)任务是预测被遮蔽的词:

Input: "I love MASK learning." Output: "I love deep learning."

模型通过上下文(左右两侧)推测被 Mask 的词,因此学习到双向语义信息

🧠 4. 为什么只有80%被替换为MASK,而不是全部

下游任务的输入从来没有 [MASK]。但如果预训练时几乎所有预测目标都依赖 [MASK] 特征,模型就会学会“见到 [MASK] 才认真预测”,而当 fine-tune 阶段没有 [MASK] 时,它的表现会退化。这样做是为了防止模型过度依赖 [MASK] 符号,增强鲁棒性。


学到这里,可能有些读者就有些疑惑了,为什么这里就可以保留原词了呢?保留原词不就又导致数据泄露了吗?如果你有此疑问,可以继续看下去。

BERT 的整体过程是这样的:

  1. 先随机选出 15% 的 token → 这些位置是“潜在的预测目标”。
  2. loss 只计算在这 15% 的位置上。

模型虽然“输入里看到 love”,但它并不知道 “love” 是要被预测的位置。所以它的“泄露”是表层信息流的可见,而非训练信号(loss 反传)层面的泄露。


2.2 Next Sentence Prediction(NSP )

NSP任务是判断 B 是不是 A 的下一句:

输入A 输入B 标签
I went to the store. I bought some milk. IsNext
I went to the store. Penguins live in Antarctica. NotNext

其中50% 的样本中,B 是 A 的真实下一句;50% 是随机句子;BERT 输入由两句话拼接而成,用 [SEP] 分隔:

[CLS] Sentence A [SEP] Sentence B [SEP]

模型通过 [CLS] 向量预测是否为“下一句”。

图片描述

🧠 5. NSP 的作用是什么?为什么后来的模型(如 RoBERTa)去掉了 NSP?

NSP 想让模型不仅理解句内词之间的关系, 还要理解句与句之间的语义连续性(discourse-level coherence)。

后续实验(尤其是 RoBERTa 和 ALBERT)发现 NSP 的问题主要有两点:

任务过于简单 :随机拼句 vs. 连续句 这个二分类太容易。模型可以仅靠表面统计特征(比如主题词、长度、标点)来判断,而非真正理解上下文。

难以泛化到真实句间关系任务:下游任务(如 QA、NLI)要求模型理解 逻辑推理(entailment、contradiction、causality),但 NSP 学到的只是“句子 A 和句子 B 是否相邻”


🧠 6. 后续模型是如何替代 NSP 的?

模型 NSP 是否保留 替代机制
RoBERTa ❌ 去掉 NSP 用更长连续文本训练(512 tokens),依赖 MLM 自行捕获句间依存
ALBERT ✅ 改进 引入 SOP(Sentence Order Prediction):判断两句是否调换顺序,更关注语义连贯性
ELECTRA ❌ 去掉 NSP 改为 RTD(Replaced Token Detection),更细粒度的预训练信号

三、BERT 手撕代码模块

Self-Attention & Encoder

class BertSelfAttention(nn.Module):
    def __init__(self, hidden_dim, num_heads, dropout=0.1):
        super().__init__()
        assert hidden_dim % num_heads == 0
        self.num_heads = num_heads
        self.head_dim = hidden_dim // num_heads
    self.query = nn.Linear(hidden_dim, hidden_dim)
    self.key = nn.Linear(hidden_dim, hidden_dim)
    self.value = nn.Linear(hidden_dim, hidden_dim)

    self.dropout = nn.Dropout(dropout)

def _transpose_for_scores(self, x):
    """
    [B, L, H] -> [B, h, L, d]
    """
    B, L, H = x.size()
    x = x.view(B, L, self.num_heads, self.head_dim)
    return x.permute(0, 2, 1, 3)  # [B, h, L, d]

def forward(self, hidden_states, attention_mask=None):
    """
    hidden_states: [B, L, H]
    attention_mask: [B, 1, 1, L]  (1 保留, 0 mask)
    """
    Q = self._transpose_for_scores(self.query(hidden_states))
    K = self._transpose_for_scores(self.key(hidden_states))
    V = self._transpose_for_scores(self.value(hidden_states))

    # [B, h, L, L]
    scores = torch.matmul(Q, K.transpose(-1, -2)) / (self.head_dim ** 0.5)

    if attention_mask is not None:
        # 将 mask 为 0 的位置置为 -inf,softmax 后概率为 0
        scores = scores.masked_fill(attention_mask == 0, float("-inf"))

    attn_probs = F.softmax(scores, dim=-1)
    attn_probs = self.dropout(attn_probs)

    # [B, h, L, d]
    context = torch.matmul(attn_probs, V)
    # -> [B, L, H]
    context = context.permute(0, 2, 1, 3).contiguous()
    B, L, h, d = context.size()
    context = context.view(B, L, h * d)
    return context, attn_probs

class BertSelfOutput(nn.Module):
def init(self, hidden_dim, dropout=0.1):
super().init()
self.dense = nn.Linear(hidden_dim, hidden_dim)
self.layer_norm = nn.LayerNorm(hidden_dim)
self.dropout = nn.Dropout(dropout)

def forward(self, hidden_states, input_tensor):
    """
    hidden_states: self-attention 输出 [B, L, H]
    input_tensor: 残差输入 [B, L, H]
    """
    x = self.dense(hidden_states)
    x = self.dropout(x)
    return self.layer_norm(x + input_tensor)

class BertIntermediate(nn.Module):
def init(self, hidden_dim, intermediate_dim, activation="gelu"):
super().init()
self.dense = nn.Linear(hidden_dim, intermediate_dim)
if activation == "relu":
self.act_fn = F.relu
else: # 默认 GELU
self.act_fn = F.gelu

def forward(self, x):
    return self.act_fn(self.dense(x))

class BertOutput(nn.Module):
def init(self, hidden_dim, intermediate_dim, dropout=0.1):
super().init()
self.dense = nn.Linear(intermediate_dim, hidden_dim)
self.layer_norm = nn.LayerNorm(hidden_dim)
self.dropout = nn.Dropout(dropout)

def forward(self, hidden_states, input_tensor):
    x = self.dense(hidden_states)
    x = self.dropout(x)
    return self.layer_norm(x + input_tensor)

class BertLayer(nn.Module):
"""
一个完整的 BERT Encoder 层:
Self-Attention -> Add & Norm -> FFN -> Add & Norm
"""
def init(self, hidden_dim, num_heads, intermediate_dim, dropout=0.1):
super().init()
self.attention = BertSelfAttention(hidden_dim, num_heads, dropout)
self.attention_output = BertSelfOutput(hidden_dim, dropout)
self.intermediate = BertIntermediate(hidden_dim, intermediate_dim)
self.output = BertOutput(hidden_dim, intermediate_dim, dropout)

def forward(self, hidden_states, attention_mask=None):
    # Self-Attention
    attn_output, _ = self.attention(hidden_states, attention_mask)
    hidden_states = self.attention_output(attn_output, hidden_states)

    # FFN
    intermediate_output = self.intermediate(hidden_states)
    layer_output = self.output(intermediate_output, hidden_states)
    return layer_output

Encoder + Pooler + BertModel

class BertEncoder(nn.Module):
    def __init__(self, num_layers, hidden_dim, num_heads, intermediate_dim, dropout=0.1):
        super().__init__()
        self.layers = nn.ModuleList([
            BertLayer(hidden_dim, num_heads, intermediate_dim, dropout)
            for _ in range(num_layers)
        ])
def forward(self, hidden_states, attention_mask=None):
    # 简化版:只返回最后一层的输出
    for layer in self.layers:
        hidden_states = layer(hidden_states, attention_mask)
    return hidden_states

class BertPooler(nn.Module):
"""
Pooler:取 [CLS] 位置的向量,过一层全连接 + tanh
"""
def init(self, hidden_dim):
super().init()
self.dense = nn.Linear(hidden_dim, hidden_dim)

def forward(self, hidden_states):
    # 假设 input_ids 的第一个 token 是 [CLS]
    cls_token = hidden_states[:, 0]  # [B, H]
    pooled = torch.tanh(self.dense(cls_token))
    return pooled

class BertModel(nn.Module):
"""
一个最小可用的 BERT 模型:
Embedding -> Encoder(L 层) -> Pooler
"""
def init(
self,
vocab_size,
hidden_dim=768,
num_layers=12,
num_heads=12,
intermediate_dim=3072,
max_len=512,
segment_size=2,
dropout=0.1,
):
super().init()
self.embeddings = BertEmbedding(
vocab_size=vocab_size,
hidden_dim=hidden_dim,
max_len=max_len,
segment_size=segment_size,
)
self.encoder = BertEncoder(
num_layers=num_layers,
hidden_dim=hidden_dim,
num_heads=num_heads,
intermediate_dim=intermediate_dim,
dropout=dropout,
)
self.pooler = BertPooler(hidden_dim)

def forward(self, input_ids, token_type_ids, attention_mask=None):
    """
    input_ids: [B, L]
    token_type_ids: [B, L]  (segment id / sentence A/B)
    attention_mask: [B, L]  (1 表示真实 token, 0 表示 padding)
    """
    # [B, L, H]
    hidden_states = self.embeddings(input_ids, token_type_ids)

    if attention_mask is not None:
        # [B, 1, 1, L],方便广播到 [B, h, L, L]
        attention_mask = attention_mask[:, None, None, :]

    # Encoder
    sequence_output = self.encoder(hidden_states, attention_mask)
    # Pooler
    pooled_output = self.pooler(sequence_output)
    return sequence_output, pooled_output

BertForPreTraining

class MaskedLanguageModel(nn.Module):
    """
    对被 mask 的位置做 token 分类:
    hidden_states: [B, L, H]
    masked_positions: [B, M]  (每个样本 M 个 mask 位置,不足用 -1 填充)
    """
    def __init__(self, vocab_size, hidden_dim):
        super().__init__()
        self.transform = nn.Linear(hidden_dim, hidden_dim)
        self.layer_norm = nn.LayerNorm(hidden_dim)
        self.decoder = nn.Linear(hidden_dim, vocab_size, bias=False)
def forward(self, hidden_states, masked_positions):
    B, L, H = hidden_states.size()

    # masked_positions: [B, M],其中 -1 表示“无效”
    # 构造 batch 维索引
    batch_idx = torch.arange(B, device=hidden_states.device).unsqueeze(1)  # [B, 1]
    # 为避免 -1 索引越界,先 clamp 到 [0, L-1],后面通过 label mask 掩掉
    pos = masked_positions.clamp(min=0)

    # [B, M, H]
    masked_hidden = hidden_states[batch_idx, pos]

    x = F.gelu(self.transform(masked_hidden))
    x = self.layer_norm(x)
    logits = self.decoder(x)  # [B, M, vocab_size]
    return logits

class NextSentencePrediction(nn.Module):
def init(self, hidden_dim):
super().init()
self.linear = nn.Linear(hidden_dim, 2)

def forward(self, cls_vector):
    # cls_vector: [B, H]
    return self.linear(cls_vector)  # [B, 2]

class BertForPreTraining(nn.Module):
"""
BERT 预训练模型:BertModel + MLM + NSP
"""
def init(self, vocab_size, hidden_dim=768, **kwargs):
super().init()
self.bert = BertModel(
vocab_size=vocab_size,
hidden_dim=hidden_dim,
**kwargs,
)
self.mlm_head = MaskedLanguageModel(vocab_size, hidden_dim)
self.nsp_head = NextSentencePrediction(hidden_dim)

def forward(
    self,
    input_ids,
    token_type_ids,
    attention_mask,
    masked_positions,
    masked_lm_labels=None,      # [B, M],无效位置用 -100
    next_sentence_labels=None,   # [B]
):
    sequence_output, pooled_output = self.bert(
        input_ids=input_ids,
        token_type_ids=token_type_ids,
        attention_mask=attention_mask,
    )

    # MLM 预测
    prediction_scores = self.mlm_head(sequence_output, masked_positions)
    # NSP 预测
    seq_relationship_score = self.nsp_head(pooled_output)

    outputs = (prediction_scores, seq_relationship_score)

    # 如传入 label,则计算 loss
    if masked_lm_labels is not None and next_sentence_labels is not None:
        # MLM loss
        mlm_loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
        # [B * M, vocab_size] vs [B * M]
        mlm_loss = mlm_loss_fct(
            prediction_scores.view(-1, prediction_scores.size(-1)),
            masked_lm_labels.view(-1),
        )

        # NSP loss
        nsp_loss_fct = nn.CrossEntropyLoss()
        nsp_loss = nsp_loss_fct(
            seq_relationship_score.view(-1, 2),
            next_sentence_labels.view(-1),
        )

        total_loss = mlm_loss + nsp_loss
        outputs = (total_loss, mlm_loss, nsp_loss) + outputs

    return outputs  # (loss, mlm_loss, nsp_loss, prediction_scores, seq_relationship_score)

Mask 采样函数:实现 15% + 80/10/10 规则

def create_masked_lm_labels(
    input_ids,
    pad_token_id,
    cls_token_id,
    sep_token_id,
    mask_token_id,
    vocab_size,
    mlm_probability=0.15,
    max_masks_per_seq=20,
):
    """
    根据 BERT 规则构造 MLM 输入和标签:
    - 15% token 作为预测目标
      - 80% -> [MASK]
      - 10% -> 随机词
      - 10% -> 保留原词
    返回:
        masked_input_ids: [B, L]
        masked_lm_labels: [B, M],非 mask 位置填 -100
        masked_positions: [B, M],不足部分填 -1
    """
    device = input_ids.device
    B, L = input_ids.size()
# 1. 初始化
masked_input_ids = input_ids.clone()
masked_lm_labels = torch.full(
    (B, max_masks_per_seq), -100, dtype=torch.long, device=device
)
masked_positions = torch.full(
    (B, max_masks_per_seq), -1, dtype=torch.long, device=device
)

# 预先生成随机数
prob = torch.rand_like(input_ids.float())

# 构建不能被 mask 的位置(特殊符号和 padding)
special_mask = (
    (input_ids == pad_token_id)
    | (input_ids == cls_token_id)
    | (input_ids == sep_token_id)
)

# 选出真正被选为 mask 候选的 token
mask_candidate = (prob < mlm_probability) & (~special_mask)

for b in range(B):
    candidate_indices = torch.nonzero(mask_candidate[b], as_tuple=False).view(-1)
    # 限制最多 mask 的个数
    if len(candidate_indices) > max_masks_per_seq:
        chosen = candidate_indices[torch.randperm(len(candidate_indices))[:max_masks_per_seq]]
    else:
        chosen = candidate_indices

    if len(chosen) == 0:
        continue

    # 记录这些位置的 label
    masked_lm_labels[b, : len(chosen)] = input_ids[b, chosen]
    masked_positions[b, : len(chosen)] = chosen

    # 80% -> [MASK]
    num_mask = len(chosen)
    num_mask_mask = int(num_mask * 0.8)
    num_mask_random = int(num_mask * 0.1)
    # 剩余 10% 保留原词

    perm = torch.randperm(num_mask)
    mask_idx = chosen[perm[:num_mask_mask]]
    random_idx = chosen[perm[num_mask_mask : num_mask_mask + num_mask_random]]
    # keep_idx = chosen[perm[num_mask_mask + num_mask_random:]]

    # 替换为 [MASK]
    masked_input_ids[b, mask_idx] = mask_token_id

    # 替换为随机词
    if len(random_idx) > 0:
        random_words = torch.randint(
            low=0, high=vocab_size, size=(len(random_idx),), device=device
        )
        masked_input_ids[b, random_idx] = random_words

    # 剩下的 10% 保留原词,不需要改 masked_input_ids

return masked_input_ids, masked_lm_labels, masked_positions

预训练

def pretrain_step(
    model: BertForPreTraining,
    batch,
    optimizer,
    pad_token_id,
    cls_token_id,
    sep_token_id,
    mask_token_id,
    vocab_size,
    device="cuda",
):
    model.train()
    input_ids = batch["input_ids"].to(device)           # [B, L]
    token_type_ids = batch["token_type_ids"].to(device) # [B, L]
    attention_mask = batch["attention_mask"].to(device) # [B, L]
    next_sentence_labels = batch["next_sentence_labels"].to(device)  # [B]
# 构造 MLM 目标
masked_input_ids, masked_lm_labels, masked_positions = create_masked_lm_labels(
    input_ids=input_ids,
    pad_token_id=pad_token_id,
    cls_token_id=cls_token_id,
    sep_token_id=sep_token_id,
    mask_token_id=mask_token_id,
    vocab_size=vocab_size,
)

masked_input_ids = masked_input_ids.to(device)
masked_lm_labels = masked_lm_labels.to(device)
masked_positions = masked_positions.to(device)

outputs = model(
    input_ids=masked_input_ids,
    token_type_ids=token_type_ids,
    attention_mask=attention_mask,
    masked_positions=masked_positions,
    masked_lm_labels=masked_lm_labels,
    next_sentence_labels=next_sentence_labels,
)

total_loss, mlm_loss, nsp_loss = outputs[:3]

optimizer.zero_grad()
total_loss.backward()
optimizer.step()

return {
    "loss": total_loss.item(),
    "mlm_loss": mlm_loss.item(),
    "nsp_loss": nsp_loss.item(),
}

声明:该内容由作者自行发布,观点内容仅供参考,不代表平台立场;如有侵权,请联系平台删除。
标签:
技术栈