Base LLM | 从 NLP 到 LLM 的算法全栈教程 第七天

命名实体识别概要

数据预处理

首先看数据结构,针对label提取所有的实体类型并且基于BMES做标签映射,将映射表保存。

python 复制代码
import json
import os


def save_json(data, file_path):
    """
    将数据以格式化的 JSON 形式保存到文件
    """
    os.makedirs(os.path.dirname(file_path), exist_ok=True)
    with open(file_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=4)


def collect_entity_types_from_file(file_path):
    """
    从单个数据文件中提取所有唯一的实体类型
    """
    types = set()
    with open(file_path, 'r', encoding='utf-8') as f:
        all_data = json.load(f)
        for data in all_data:
            # 遍历实体列表,提取 'type' 字段
            for entity in data['entities']:
                types.add(entity['type'])
    return types


def generate_tag_map(data_files, output_file):
    """
    从数据文件构建 BMES 标签映射并保存
    """
    # 1. 从所有文件中收集实体类型
    all_entity_types = set()
    for file_path in data_files:
        all_entity_types.update(collect_entity_types_from_file(file_path))

    # 2. 排序以保证映射一致性
    sorted_types = sorted(list(all_entity_types))
    print(f"发现的实体类型: {sorted_types}")

    # 3. 构建 BMES 标签映射
    tag_to_id = {'O': 0}  # 'O' 代表非实体
    for entity_type in sorted_types:
        for prefix in ['B', 'M', 'E', 'S']:
            tag_name = f"{prefix}-{entity_type}"
            tag_to_id[tag_name] = len(tag_to_id)

    print(f"\n已生成 {len(tag_to_id)} 个标签映射。")

    # 4. 保存映射文件
    save_json(tag_to_id, output_file)
    print(f"标签映射已保存至: {output_file}")


if __name__ == '__main__':
    # 定义输入的数据文件和期望的输出路径
    train_file = './data/CMeEE-V2_train.json'
    dev_file = './data/CMeEE-V2_dev.json'
    output_path = './data/categories.json'

    generate_tag_map(data_files=[train_file, dev_file], output_file=output_path)

针对文本:规范化文本(统一全角半角),统计词频,删掉小于最小词频的单词,添加pad和unk,注意排序使词汇表可复现。

python 复制代码
import json
import os
from collections import Counter


def save_json(data, file_path):
    """
    将数据以易于阅读的格式保存为 JSON 文件
    """
    os.makedirs(os.path.dirname(file_path), exist_ok=True)
    with open(file_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=4)


def normalize_text(text):
    """
    规范化文本
    """
    full_width = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&'()*+,-./:;<=>?@[\]^_`{|}~""
    half_width = r"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&'" + r'()*+,-./:;<=>?@[\]^_`{|}~".'
    mapping = str.maketrans(full_width, half_width)
    return text.translate(mapping)


def create_char_vocab(data_files, output_file, min_freq=1):
    """
    从数据文件创建字符级词汇表
    """
    char_counts = Counter()
    for file_path in data_files:
        with open(file_path, 'r', encoding='utf-8') as f:
            all_data = json.load(f)
            for data in all_data:
                text = normalize_text(data['text'])
                char_counts.update(list(text))

    # 过滤低频词
    frequent_chars = [char for char, count in char_counts.items() if count >= min_freq]
    
    # 保证每次生成结果一致
    frequent_chars.sort()

    # 添加特殊标记
    special_tokens = ["<PAD>", "<UNK>"]
    final_vocab_list = special_tokens + frequent_chars
    
    print(f"词汇表大小 (min_freq={min_freq}): {len(final_vocab_list)}")

    # 保存词汇表
    save_json(final_vocab_list, output_file)
    print(f"词汇表已保存至: {output_file}")


if __name__ == '__main__':
    train_file = './data/CMeEE-V2_train.json'
    dev_file = './data/CMeEE-V2_dev.json'
    output_path = './data/vocabulary.json'

    # 设置字符最低频率,1表示包含所有出现过的字符
    create_char_vocab(data_files=[train_file, dev_file], output_file=output_path, min_freq=1)

构建vocabulary类,初始化生成token和id的映射,并且又__len__方法和token_id转化方法。

构建dataset,初始化传入文本,标签映射表,vocab类,实现__len__方法和__getitem__方法,其中__getitem__方法将输入的data转成token_id和标签id映射标识。返回token_ids和label_ids。

构建dataloader,首先构建collate_fn完成序列的pad和mask序列的构建,然后基于dataset生成dataloader。

python 复制代码
import json
import torch
from torch.utils.data import Dataset, DataLoader
from torch.nn.utils.rnn import pad_sequence


def normalize_text(text):
    """
    规范化文本,例如将全角字符转换为半角字符。
    """
    full_width = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&'()*+,-./:;<=>?@[\]^_`{|}~""
    half_width = r"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&'" + r'()*+,-./:;<=>?@[\]^_`{|}~".'
    mapping = str.maketrans(full_width, half_width)
    return text.translate(mapping)


class Vocabulary:
    """
    负责管理词汇表和 token 到 id 的映射。
    """
    def __init__(self, vocab_path):
        with open(vocab_path, 'r', encoding='utf-8') as f:
            self.tokens = json.load(f)
        self.token_to_id = {token: i for i, token in enumerate(self.tokens)}
        self.pad_id = self.token_to_id['<PAD>']
        self.unk_id = self.token_to_id['<UNK>']

    def __len__(self):
        return len(self.tokens)

    def convert_tokens_to_ids(self, tokens):
        return [self.token_to_id.get(token, self.unk_id) for token in tokens]


class NerDataset(Dataset):
    """
    处理 NER 数据,并将其转换为适用于 PyTorch 模型的格式。
    """
    def __init__(self, data_path, vocab: Vocabulary, tag_map: dict):
        self.vocab = vocab
        self.tag_to_id = tag_map
        with open(data_path, 'r', encoding='utf-8') as f:
            self.records = json.load(f)

    def __len__(self):
        return len(self.records)

    def __getitem__(self, idx):
        record = self.records[idx]
        text = normalize_text(record['text'])
        tokens = list(text)
        
        # 将文本 tokens 转换为 ids
        token_ids = self.vocab.convert_tokens_to_ids(tokens)

        # 初始化标签序列为 'O'
        tags = ['O'] * len(tokens)
        for entity in record.get('entities', []):
            entity_type = entity['type']
            start = entity['start_idx']
            end = entity['end_idx']

            if end >= len(tokens): continue

            if start == end:
                tags[start] = f'S-{entity_type}'
            else:
                tags[start] = f'B-{entity_type}'
                tags[end] = f'E-{entity_type}'
                for i in range(start + 1, end):
                    tags[i] = f'M-{entity_type}'
        
        # 将标签转换为 ids
        label_ids = [self.tag_to_id.get(tag, self.tag_to_id['O']) for tag in tags]

        return {
            "token_ids": torch.tensor(token_ids, dtype=torch.long),
            "label_ids": torch.tensor(label_ids, dtype=torch.long)
        }


def create_ner_dataloader(data_path, vocab, tag_map, batch_size, shuffle=False):
    """
    创建 NER 任务的 DataLoader。
    """
    dataset = NerDataset(data_path, vocab, tag_map)
    
    def collate_batch(batch):
        token_ids_list = [item['token_ids'] for item in batch]
        label_ids_list = [item['label_ids'] for item in batch]

        padded_token_ids = pad_sequence(token_ids_list, batch_first=True, padding_value=vocab.pad_id)
        padded_label_ids = pad_sequence(label_ids_list, batch_first=True, padding_value=-100)  # -100 用于在计算损失时忽略填充部分

        attention_mask = (padded_token_ids != vocab.pad_id).long()

        return {
            "token_ids": padded_token_ids,
            "label_ids": padded_label_ids,
            "attention_mask": attention_mask
        }

    return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, collate_fn=collate_batch)


if __name__ == '__main__':
    # 文件路径
    train_file = './data/CMeEE-V2_train.json'
    vocab_file = './data/vocabulary.json'
    categories_file = './data/categories.json'

    # 1. 加载词汇表和标签映射
    vocabulary = Vocabulary(vocab_path=vocab_file)
    with open(categories_file, 'r', encoding='utf-8') as f:
        tag_map = json.load(f)
    print("词汇表和标签映射加载完成。")

    # 2. 创建 DataLoader
    train_loader = create_ner_dataloader(
        data_path=train_file,
        vocab=vocabulary,
        tag_map=tag_map,
        batch_size=4,
        shuffle=True
    )
    print("DataLoader 创建完成。")

    # 3. 验证一个批次的数据
    print("\n--- 验证一个批次的数据 ---")
    batch = next(iter(train_loader))
    
    print(f"  Token IDs (shape): {batch['token_ids'].shape}")
    print(f"  Label IDs (shape): {batch['label_ids'].shape}")
    print(f"  Attention Mask (shape): {batch['attention_mask'].shape}")
    print(f"  Token IDs (sample): {batch['token_ids'][0][:20]}...")
    print(f"  Label IDs (sample): {batch['label_ids'][0][:20]}...")
    print(f"  Attention Mask (sample): {batch['attention_mask'][0][:20]}...")

nn.ModuleList vs nn.Sequential

在 PyTorch 中,nn.ModuleListnn.Sequential 都是用来容纳多个子模块的容器,但它们的设计思想和使用场景不同:

  • nn.Sequential:像一个自动化的流水线,数据会自动按顺序流过每一层。适用于简单的线性堆叠,但无法实现层间的复杂交互。
  • **nn.ModuleList**:更像一个普通的 Python 列表 ,只负责存储模块,而不会自动执行它们。你需要在 forward 方法中手动编写循环来调用每一层,所以可以在层与层之间加入自定义逻辑(如残差连接)。

model构建

loss_fn = nn.CrossEntropyLoss(ignore_index=-100, reduction='none')

reduction='none':正常损失返回的是一个值,设置该参数使损失返回每个标签上的损失,构成损失矩阵。

ignore_index 生效 :可以看到 label_ids 中值为 -100 的填充位置,其对应的损失值为 0。如果用此方法,在单向模型中可以不传mask序列,但在双向模型中不行。

torch.nn.utils.rnn.pack_padded_sequence:这个函数的主要作用是接收一个 填充后input 张量,以及一个记录了 真实长度lengths 列表。它会返回一个 PackedSequence 对象,可以把它想象成一个"压缩"后的数据包,其中所有的填充位都被暂时移除了。RNN 模块在接收到这个特殊对象后,其内部就能正确、高效地处理变长序列。

当然,有"打包"就有"解包"。与之对应的 pad_packed_sequence 函数会负责将 RNN 计算完成后的 PackedSequence 对象再"解压"还原成带有填充的、规整的 Tensor。

python 复制代码
import torch
import torch.nn as nn
import torch.nn.utils.rnn as rnn

class BiGRUNerNetWork(nn.Module):
    def __init__(self, vocab_size, hidden_size, num_tags, num_gru_layers=1):
        super().__init__()
        # 1. Token Embedding 层
        self.embedding = nn.Embedding(vocab_size, hidden_size)

        # 2. 使用 ModuleList 构建多层双向 GRU
        self.gru_layers = nn.ModuleList()
        for _ in range(num_gru_layers):
            self.gru_layers.append(
                nn.GRU(
                    input_size=hidden_size,
                    hidden_size=hidden_size,
                    num_layers=1,
                    batch_first=True,
                    bidirectional=True  # 开启双向
                )
            )

        # 3. 特征融合层
        self.fc = nn.Linear(hidden_size * 2, hidden_size)

        # 4. 分类决策层 (Classifier)
        self.classifier = nn.Linear(hidden_size, num_tags)

    def forward(self, token_ids, attention_mask):
        # 1. 计算真实长度
        lengths = attention_mask.sum(dim=1).cpu()

        # 2. 获取词向量
        embedded_text = self.embedding(token_ids)

        # 3. 打包序列
        current_packed_input = rnn.pack_padded_sequence(
            embedded_text, lengths, batch_first=True, enforce_sorted=False
        )

        # 4. 循环通过 GRU 层
        for gru_layer in self.gru_layers:
            # GRU 输出 (packed)
            packed_output, _ = gru_layer(current_packed_input)

            # 解包以进行后续操作,并指定 total_length
            output, _ = rnn.pad_packed_sequence(
                packed_output, batch_first=True, total_length=token_ids.shape[1]
            )

            # 特征融合
            features = self.fc(output)

            # 残差连接
            # 同样需要解包上一层的输入
            input_padded, _ = rnn.pad_packed_sequence(
                current_packed_input, batch_first=True, total_length=token_ids.shape[1]
            )
            current_input = features + input_padded

            # 重新打包作为下一层的输入
            current_packed_input = rnn.pack_padded_sequence(
                current_input, lengths, batch_first=True, enforce_sorted=False
            )

        # 5. 解包最终输出用于分类
        final_output, _ = rnn.pad_packed_sequence(
            current_packed_input, batch_first=True, total_length=token_ids.shape[1]
        )

        # 6. 分类
        logits = self.classifier(final_output)

        return logits


if __name__ == '__main__':

    token_ids = torch.tensor([
        [210,   18,  871, 147,   0,   0,   0,   0],
        [922, 2962,  842, 210,  18, 871, 147,   0]
    ], dtype=torch.int64)

    # attention_mask 标记哪些是真实 token (1) 哪些是填充 (0)
    attention_mask = torch.tensor([
        [1, 1, 1, 1, 0, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 1, 0]
    ], dtype=torch.int64)

    label_ids = torch.tensor([
        [0, 0, 0, 0, -100, -100, -100, -100],
        [0, 0, 0, 0,    0,    0,    0, -100]
    ], dtype=torch.int64)

    # 实例化模型
    model = BiGRUNerNetWork(
        vocab_size=10000,
        hidden_size=128,
        num_tags=37,
        num_gru_layers=2
    )

    # 3. 执行前向传播
    logits = model(token_ids=token_ids, attention_mask=attention_mask)

    # 4. 构造损失函数
    loss_fn = nn.CrossEntropyLoss(ignore_index=-100, reduction='none')

    # 5. 计算损失
    # CrossEntropyLoss 要求类别维度在前,所以需要交换最后两个维度
    # [batch, seq_len, num_tags] -> [batch, num_tags, seq_len]
    permuted_logits = torch.permute(logits, dims=(0, 2, 1))
    loss = loss_fn(permuted_logits, label_ids)

    # 6. 打印结果
    print(f"Logits shape: {logits.shape}")
    print(f"Loss shape: {loss.shape}")
    print("\n每个 Token 的损失:")
    print(loss)

组件构建

Trainer

在开始编写 Trainer 类之前,先在 src/ 目录下创建一个 trainer 文件夹,并在其中新建一个 trainer.py 文件,用于存放 Trainer 类的定义。

Trainer 只负责"训练" : Trainer 类的核心职责是执行标准的训练和评估循环。

init:初始化model: PyTorch 模型。 optimizer: 优化器。 loss_fn: 损失函数。 train_loader: 训练数据加载器。 dev_loader: 验证数据加载器。 eval_metric_fn: 评估函数。 output_dir: 模型输出目录。 device: 训练设备。

fit(self, epochs):训练的主入口

_train_one_epoch(self):封装一个epoch训练流程

_train_step(self, batch):封装一个训练步骤的逻辑(前向、损失、反向)

_evaluate(self):封装评估逻辑。

_evaluation_step(self, batch):封装一个评估步骤的逻辑(前向、损失)。

_save_checkpoint(self, is_best=False):封装模型保存逻辑。

在我们当前的"组件式组装"设计中,虽然 Trainer 不直接接收整个 config 对象(以保持解耦),但 config 依然是所有"零件"的参数来源。

python 复制代码
import torch
from tqdm import tqdm
import os
from dataclasses import asdict

class Trainer:
    def __init__(self, model, optimizer, loss_fn, train_loader, dev_loader=None, 
                 eval_metric_fn=None, output_dir=None, device='cpu'):
        self.model = model.to(device)
        self.optimizer = optimizer
        self.loss_fn = loss_fn
        self.train_loader = train_loader
        self.dev_loader = dev_loader
        self.eval_metric_fn = eval_metric_fn
        self.output_dir = output_dir
        self.device = torch.device(device)
        
        if self.output_dir:
            os.makedirs(self.output_dir, exist_ok=True)

    def fit(self, epochs):
        best_metric = float('inf')  # 初始化一个无穷大的 best_metric,用于后续比较
        
        for epoch in range(1, epochs + 1):
            # 1. 执行一个周期的训练
            train_loss = self._train_one_epoch()
            print(f"Epoch {epoch} - Training Loss: {train_loss:.4f}")

            # 2. 执行评估
            metrics = self._evaluate()
            if metrics:
                print(f"Epoch {epoch} - Validation Metrics: {metrics}")
                current_metric = metrics.get('loss')  # 默认监控验证集 loss
                
                # 3. 如果当前 metric 优于历史最优,则保存最佳模型
                if current_metric < best_metric:
                    best_metric = current_metric
                    if self.output_dir:
                        self._save_checkpoint(is_best=True)
                        print(f"New best model saved with validation loss: {best_metric:.4f}")

            # 4. 每个 epoch 结束后,保存最新的模型状态
            if self.output_dir:
                self._save_checkpoint(is_best=False)

    def _train_one_epoch(self):
        """执行一个完整的训练周期。"""
        self.model.train()  # 设置为训练模式
        total_loss = 0
        
        # 使用 tqdm 显示进度条
        for batch in tqdm(self.train_loader, desc=f"Training Epoch"):
            outputs = self._train_step(batch)
            total_loss += outputs['loss'].item()  # 累加 loss
        
        return total_loss / len(self.train_loader)  # 返回平均 loss

    def _train_step(self, batch):
        """执行单个训练步骤(前向、损失、反向)。"""
        # 1. 将数据移动到指定设备
        batch = {k: v.to(self.device) for k, v in batch.items() if isinstance(v, torch.Tensor)}

        # 2. 模型前向传播
        logits = self.model(token_ids=batch['token_ids'], attention_mask=batch['attention_mask'])
        
        # 3. 计算损失
        # CrossEntropyLoss 要求 logits 的形状为 [B, C, L],label_ids 的形状为 [B, L]
        loss = self.loss_fn(logits.permute(0, 2, 1), batch['label_ids'])
            
        # 4. 反向传播与参数更新
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
        
        return {'loss': loss, 'logits': logits}

    def _evaluate(self):
        """在验证集上执行评估。"""
        if self.dev_loader is None:
            return None

        self.model.eval()  # 设置为评估模式
        total_loss = 0
        all_logits = []
        all_labels = []
        all_attention_mask = []

        with torch.no_grad():  # 禁用梯度计算
            for batch in tqdm(self.dev_loader, desc="Evaluating"):
                outputs = self._evaluation_step(batch)
                
                total_loss += outputs['loss'].item()
                # 收集所有批次的 logits 和 labels,用于后续评估
                all_logits.append(outputs['logits'].cpu())
                all_labels.append(batch['label_ids'].cpu())
                all_attention_mask.append(batch['attention_mask'].cpu())
        
        metrics = {}
        # 如果提供了评估函数,则调用它来计算指标
        if self.eval_metric_fn:
            metrics = self.eval_metric_fn(all_logits, all_labels, all_attention_mask)
        
        # 计算并记录平均 loss
        metrics['loss'] = total_loss / len(self.dev_loader)
        return metrics

    def _evaluation_step(self, batch):
        """执行单个评估步骤(前向、损失)。"""
        # 1. 将数据移动到指定设备
        batch = {k: v.to(self.device) for k, v in batch.items() if isinstance(v, torch.Tensor)}
        
        # 2. 模型前向传播
        logits = self.model(token_ids=batch['token_ids'], attention_mask=batch['attention_mask'])
        
        # 3. 计算损失
        loss = self.loss_fn(logits.permute(0, 2, 1), batch['label_ids'])
        
        return {'loss': loss, 'logits': logits}

    def _save_checkpoint(self, is_best):
        """保存模型检查点。"""
        state = {'model_state_dict': self.model.state_dict()}
        if is_best:
            # 保存最佳模型
            torch.save(state, os.path.join(self.output_dir, 'best_model.pth'))
        # 保存最新模型
        torch.save(state, os.path.join(self.output_dir, 'last_model.pth'))
配置类config参数
python 复制代码
# src/configs/configs.py
import torch
from dataclasses import dataclass, field

@dataclass
class NerConfig:
    # --- 路径参数 ---
    data_dir: str = "data"
    train_file: str = "CMeEE-V2_train.json"
    dev_file: str = "CMeEE-V2_dev.json"
    vocab_file: str = "vocabulary.json"
    tags_file: str = "categories.json"
    output_dir: str = "output"

    # --- 训练参数 ---
    batch_size: int = 32
    epochs: int = 20
    learning_rate: float = 1e-3
    device: str = field(default_factory=lambda: 'cuda' if torch.cuda.is_available() else 'cpu')
    
    # --- 模型参数 ---
    hidden_size: int = 256
    num_gru_layers: int = 2

@dataclass 是 Python 3.7 引入的装饰器,可以简化类的编写。对于 TrainerConfig 这样的配置类,它会自动生成构造函数 (__init__),无需再手动编写冗长的参数赋值代码。同时,它还会生成一个友好的打印格式 (__repr__),这意味着 print(config) 会清晰地展示所有参数和值,便于调试。

模型组件

第一步:创建模型目录

src/ 目录下创建一个新的文件夹 models

第二步:定义模型基类

在构建具体的模型之前,可以先在 src/models/ 目录下创建一个 base.py 文件来定义一个 模型基类 。这个基类使用 Python 的 abc 模块(Abstract Base Classes)来规定所有 NER 模型都必须遵循的一个统一接口。

这样做的好处是:

  • 强制接口统一 :所有模型都必须实现一个 forward 方法,且接收相同的参数(token_ids, attention_mask)。这保证了 Trainer 可以与任何我们未来创建的新模型(如 BERT-NER, LSTM-NER)无缝协作,无需修改 Trainer 的代码。
  • 提高可读性与可维护性:代码的结构更清晰,别人接手项目时,只需查看基类就能明白模型部分的接口规范。
python 复制代码
# src/models/base.py
import torch.nn as nn
from abc import ABC, abstractmethod

class BaseNerNetwork(nn.Module, ABC):
    @abstractmethod
    def forward(self, token_ids, attention_mask):
        """
        定义所有 NER 模型都必须遵循的前向传播接口。
        
        Args:
            token_ids (torch.Tensor): [batch_size, seq_len]
            attention_mask (torch.Tensor): [batch_size, seq_len]

        Returns:
            torch.Tensor: Logits, [batch_size, seq_len, num_tags]
        """
        raise NotImplementedError
python 复制代码
# src/models/ner_model.py
import torch.nn as nn
import torch.nn.utils.rnn as rnn
from .base import BaseNerNetwork # 导入基类

class BiGRUNerNetWork(BaseNerNetwork): # 继承自 BaseNerNetwork
    # ... (省略具体实现,与前文一致) ...
数据加载组件

定义 NerDataset 类,DataLoader

分词器组件

构建分词器基类,在换其他分词器时保障后续接口可以使用。

python 复制代码
# src/tokenizer/base.py
from abc import ABC, abstractmethod

class BaseTokenizer(ABC):
    @abstractmethod
    def text_to_tokens(self, text: str) -> list[str]:
        """将文本分割成 token 列表。"""
        raise NotImplementedError

    @abstractmethod
    def tokens_to_ids(self, tokens: list[str]) -> list[int]:
        """将 token 列表转换为 ID 列表。"""
        raise NotImplementedError

    def encode(self, text: str) -> list[int]:
        """将文本直接编码为 ID 列表的便捷方法。"""
        tokens = self.text_to_tokens(text)
        return self.tokens_to_ids(tokens)

    @abstractmethod
    def get_pad_id(self) -> int:
        """获取填充 token 的 ID。"""
        raise NotImplementedError
python 复制代码
# src/tokenizer/char_tokenizer.py
from .vocabulary import Vocabulary
from .base import BaseTokenizer

def normalize_text(text):
    # ... (省略 normalize_text 函数实现) ...

class CharTokenizer(BaseTokenizer):
    def __init__(self, vocab: Vocabulary):
        self.vocab = vocab

    def text_to_tokens(self, text: str):
        normalized_text = normalize_text(text)
        return list(normalized_text)

    def tokens_to_ids(self, tokens: list[str]):
        return self.vocab.convert_tokens_to_ids(tokens)
    
    def get_pad_id(self) -> int:
        return self.vocab.pad_id
评估组件

def _trans_entity2tuple(label_ids, id2tag):将标签ID序列转换为实体元组列表(严格 BMES 解码)。仅在遇到 E- 或 S- 时落盘;遇到新的 B- 或 O 不闭合未完成片段。

当前 calculate_entity_level_metrics 的实现,在面对 Trainer 的批量评估场景时,会遇到两个问题:

  1. 处理填充 :在一个批次中,不同长度的句子会被填充到相同长度。这些填充位(Padding)不应参与评估。我们需要利用 attention_mask 机制,来过滤掉所有因填充而产生的无效 Token,确保评估只在有效的序列片段上进行。

  2. 追踪样本来源 :当处理一个批次的多个样本时,必须能区分每个实体到底来自哪个样本。例如,批次中的第一个样本和第二个样本可能在相同的位置 (0, 2) 都有一个 'dis' 类型的实体。如果在解码时不加以区分,这两个独立的实体在存入 set 时会被误判为同一个。为了准确区分来自同一批次中不同样本的实体,设计了一种方案:为每个解码出的实体附加其所在样本的唯一ID(即批次内索引 i)。确保每个实体都由一个唯一的 (样本ID, 实体类型, 起始位置, 结束位置) 四元组来标识,从根本上解决实体归属混淆的问题。

python 复制代码
# src/metrics/entity_metrics.py
import torch

def _trans_entity2tuple(label_ids, id2tag):
    """
    将标签ID序列转换为实体元组列表(严格 BMES 解码)。
    仅在遇到 E- 或 S- 时落盘;遇到新的 B- 或 O 不闭合未完成片段。
    """
    entities = []
    current_entity = None

    for i, label_id in enumerate(label_ids):
        # 将标签ID映射为字符串标签,未知则视作 'O'
        tag = id2tag.get(label_id.item(), 'O')

        if tag.startswith('B-'):
            # 开启新片段:记录类型与起始位置;end 暂定为 i+1
            current_entity = (tag[2:], i, i + 1)
        elif tag.startswith('M-'):
            # 仅当已存在片段,且类型一致时续接(扩展 end)
            if current_entity and current_entity[0] == tag[2:]:
                current_entity = (current_entity[0], current_entity[1], i + 1)
            else:
                # 类型不一致或不存在片段:丢弃未完成片段
                current_entity = None
        elif tag.startswith('E-'):
            # 仅当已存在片段且类型一致时闭合并落盘
            if current_entity and current_entity[0] == tag[2:]:
                current_entity = (current_entity[0], current_entity[1], i + 1)
                entities.append(current_entity)
            # 无论是否匹配,E- 都视为一次片段结束
            current_entity = None
        elif tag.startswith('S-'):
            # 单字实体:直接落盘(start=i, end=i+1)
            entities.append((tag[2:], i, i + 1))
            current_entity = None
        else:  # 'O'
            # 非实体位置:严格模式不闭合未完成片段,直接丢弃
            current_entity = None

    # 返回集合去重
    return set(entities)


def calculate_entity_level_metrics(all_pred_ids, all_label_ids, all_masks, id2tag):
    """
    计算实体级别的精确率、召回率和 F1 分数。
    """
    true_entities = set()
    pred_entities = set()
    sample_idx = 0

    # 按批次遍历,同时保持 preds/labels/masks 对齐
    for preds_batch, labels_batch, masks_batch in zip(all_pred_ids, all_label_ids, all_masks):
        B = labels_batch.shape[0]  # 当前批次样本数
        for b in range(B):
            # 对单个样本应用布尔掩码,去除 padding 位置
            row_mask = masks_batch[b].bool()
            row_labels = labels_batch[b][row_mask]
            row_preds = preds_batch[b][row_mask]

            # 严格 BMES 解码为实体集合
            te = _trans_entity2tuple(row_labels, id2tag)
            pe = _trans_entity2tuple(row_preds, id2tag)

            # 为每个实体附加 (sample_idx,) 前缀,确保不同样本的相同实体不冲突
            true_entities.update({(sample_idx,) + e for e in te})
            pred_entities.update({(sample_idx,) + e for e in pe})
            sample_idx += 1

    num_correct = len(true_entities.intersection(pred_entities))
    num_true = len(true_entities)
    num_pred = len(pred_entities)

    precision = num_correct / num_pred if num_pred > 0 else 0.0
    recall = num_correct / num_true if num_true > 0 else 0.0
    f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0
    return {"precision": precision, "recall": recall, "f1": f1}

模型推理与优化

解码预测序列:

模型的前向传播最终输出的是一个 logits 张量,形状为 [batch_size, seq_len, num_tags]。经过 argmax 操作后,会得到一个标签 ID 序列,例如 [0, 9, 10, 11, 0, ...]

这个序列本身并不直观。为了进行实体级评估,或者将预测结果呈现给用户,必须实现一个 解码 (Decode) 函数,将这个数字序列转换成一个包含具体实体信息的列表,例如:[{"text": "高血压", "type": "dis", "start": 3, "end": 6}]。这个解码过程的核心,就是根据 BMES 标注体系的规则,从标签序列中解析出实体的边界和类型。

解码策略:

当前采用的是一种 "严格"模式 。任何不符合规范的序列(例如只有 B- 没有 E- 的实体)都会被直接放弃。这是最常见的做法,因为它能保证输出实体的规范性。

在某些特定的业务场景下,也可以采用更 "宽松"的策略 。例如,如果模型预测出一个 B-M-O 的序列,可以选择将 B-M 这部分作为一个实体输出,而不是完全丢弃它。这种策略的选择,取决于具体应用对"召回率"和"精确率"的不同侧重,需要根据实际需求来决定。

NerPredictor 核心流程

__init__ 方法的目标是加载并准备好所有推理所需的组件。

加载配置 : 从模型目录加载 config.json,获取模型超参数和相关文件路径

加载词汇表和标签映射 : 根据配置文件中的路径,加载 vocabulary.jsontags.json,并构建 id2tag 映射。

加载分词器 : 初始化 CharTokenizer。

初始化模型并加载权重:

  • 根据配置实例化 BiGRUNerNetWork 模型。
  • 从模型目录加载 best_model.pth 模型权重。这里需要使用 map_location=self.device 来确保模型可以被加载到指定的设备上(无论是 CPU 还是 GPU)。
  • 调用 model.to(self.device) 将模型移至指定设备。
  • 调用 model.eval() 将模型切换到评估模式,关闭 Dropout 和 BatchNorm 等只在训练时使用的层,确保预测结果的确定性。
python 复制代码
# code/C8/06_predict.py
import torch
import json
import os
import argparse
from src.models.ner_model import BiGRUNerNetWork
from src.tokenizer.vocabulary import Vocabulary
from src.tokenizer.char_tokenizer import CharTokenizer
from src.utils.file_io import load_json

class NerPredictor:
    def __init__(self, model_dir, device='cpu'):
        self.device = torch.device(device)
        
        # --- 1. 加载配置文件以获取模型参数 ---
        config_path = os.path.join(model_dir, 'config.json')
        self.config = load_json(config_path)

        # --- 2. 加载词汇表和标签映射 ---
        vocab_path = os.path.join(self.config["data_dir"], self.config["vocab_file"])
        tags_path = os.path.join(self.config["data_dir"], self.config["tags_file"])

        self.vocab = Vocabulary.load_from_file(vocab_path)
        self.tokenizer = CharTokenizer(self.vocab)
        tag_map = load_json(tags_path)
        self.id2tag = {v: k for k, v in tag_map.items()}

        # --- 3. 初始化模型并加载权重 ---
        self.model = BiGRUNerNetWork(
            vocab_size=len(self.vocab),
            hidden_size=self.config["hidden_size"],
            num_tags=len(tag_map),
            num_gru_layers=self.config["num_gru_layers"]
        )
        model_path = os.path.join(model_dir, 'best_model.pth')
        self.model.load_state_dict(torch.load(model_path, map_location=self.device)['model_state_dict'])
        self.model.to(self.device)
        self.model.eval()

    def predict(self, text):
        tokens = self.tokenizer.text_to_tokens(text)
        token_ids = self.tokenizer.tokens_to_ids(tokens)
        
        # --- 预处理 ---
        token_ids_tensor = torch.tensor([token_ids], dtype=torch.long).to(self.device)
        attention_mask = torch.ones_like(token_ids_tensor)

        # --- 模型预测 ---
        with torch.no_grad():
            logits = self.model(token_ids_tensor, attention_mask)
        
        # --- 后处理 ---
        predictions = torch.argmax(logits, dim=-1).squeeze(0)
        tags = [self.id2tag[id_.item()] for id_ in predictions]

        return self._extract_entities(tokens, tags)

    def _extract_entities(self, tokens, tags):
        entities = []
        current_entity = None
        for i, tag in enumerate(tags):
            if tag.startswith('B-'):
                if current_entity:
                    pass
                current_entity = {"text": tokens[i], "type": tag[2:], "start": i}
            elif tag.startswith('M-'):
                if current_entity and current_entity["type"] == tag[2:]:
                    current_entity["text"] += tokens[i]
                else:
                    current_entity = None
            elif tag.startswith('E-'):
                if current_entity and current_entity["type"] == tag[2:]:
                    current_entity["text"] += tokens[i]
                    current_entity["end"] = i + 1
                    entities.append(current_entity)
                current_entity = None
            elif tag.startswith('S-'):
                current_entity = None
                entities.append({"text": tokens[i], "type": tag[2:], "start": i, "end": i + 1})
            else: # 'O' 标签
                current_entity = None
        
        return entities

def main():
    parser = argparse.ArgumentParser(description="NER Prediction")
    parser.add_argument("--model_dir", type=str, required=True, help="Directory of the saved model and config.")
    parser.add_argument("--text", type=str, required=True, help="Text to predict.")
    args = parser.parse_args()

    predictor = NerPredictor(model_dir=args.model_dir)
    entities = predictor.predict(args.text)
    print(f"Text: {args.text}")
    print(f"Entities: {json.dumps(entities, ensure_ascii=False, indent=2)}")

if __name__ == "__main__":
    main()

自定义损失函数

加权交叉熵损失

最简单的方法就是"加权"。给数量稀少的实体标签(B, M, E, S)一个更高的权重,给数量庞大的非实体标签(O)一个较低的权重。例如,我们可以设置实体损失的权重为 10,非实体损失的权重为 1。这样,模型在反向传播时,如果弄错了一个实体 Token,会受到比弄错一个非实体 Token 大 10 倍的"惩罚",从而迫使模型更加关注对实体的识别。

硬负样本挖掘

另一种思路是"采样"。在大量的非实体样本中,大部分是模型可以轻易正确预测的"简单样本",它们对损失的贡献很小,反复学习意义不大。真正有价值的是那些模型容易搞错的"硬负样本",例如一个模型倾向于预测为实体的非实体 Token。

硬负样本挖掘的做法是:在计算非实体部分的损失时,不计算所有非实体 Token 的平均损失,而是只选择其中损失值最大(Top-K)的一部分进行计算和反向传播。这样就相当于从海量的"多数派"中,筛选出了最有价值的"疑难样本"进行学习,提升了训练的效率和效果。

python 复制代码
# code/C8/src/loss/ner_loss.py

import torch
import torch.nn as nn

class NerLoss(nn.Module):
    """
    自定义 NER 损失函数,集成两种策略来对抗数据不均衡问题:
    1. 加权交叉熵
    2. 硬负样本挖掘
    """
    def __init__(self, loss_type='cross_entropy', entity_weight=10.0, hard_negative_ratio=0.5, ignore_index=-100):
        super().__init__()
        # --- 参数定义 ---
        self.loss_type = loss_type                # 损失类型: 'cross_entropy', 'weighted_ce', 'hard_negative_mining'
        self.entity_weight = entity_weight        # 实体损失的权重
        self.hard_negative_ratio = hard_negative_ratio  # 硬负样本与正样本的比例
        
        # 基础损失函数,设置为 'none' 模式以获取每个 token 的单独损失
        self.base_loss_fn = nn.CrossEntropyLoss(reduction='none', ignore_index=ignore_index)

    def forward(self, logits, labels):
        """
        根据初始化时选择的 loss_type 计算损失。
        """
        if self.loss_type == 'weighted_ce':
            return self._weighted_cross_entropy(logits, labels)
        elif self.loss_type == 'hard_negative_mining':
            return self._hard_negative_mining(logits, labels)
        else: 
            # 默认使用 PyTorch 原生的交叉熵损失
            return self.base_loss_fn(logits, labels).mean()

    def _weighted_cross_entropy(self, logits, labels):
        """
        加权交叉熵损失的实现。
        """
        # 计算每个 token 的基础损失, shape: [batch_size, seq_len]
        loss_per_token = self.base_loss_fn(logits, labels)

        # 创建掩码来区分实体和非实体 token
        entity_mask = (labels > 0).float()      # 实体 (B, M, E, S)
        non_entity_mask = (labels == 0).float() # 非实体 (O)

        # 分别计算实体和非实体部分的平均损失
        entity_loss = torch.sum(loss_per_token * entity_mask) / (torch.sum(entity_mask) + 1e-8)
        non_entity_loss = torch.sum(loss_per_token * non_entity_mask) / (torch.sum(non_entity_mask) + 1e-8)

        # 根据预设权重,组合两部分损失
        total_loss = self.entity_weight * entity_loss + 1.0 * non_entity_loss
        return total_loss, entity_loss.detach(), non_entity_loss.detach()

    def _hard_negative_mining(self, logits, labels):
        """
        硬负样本挖掘损失的实现。
        """
        # 计算每个 token 的基础损失
        loss_per_token = self.base_loss_fn(logits, labels)

        # 实体部分的损失计算与加权交叉熵方法相同
        entity_mask = (labels > 0).float()
        entity_loss = torch.sum(loss_per_token * entity_mask) / (torch.sum(entity_mask) + 1e-8)

        # 筛选出所有非实体 token 的损失
        non_entity_mask = (labels == 0).float()
        non_entity_loss = loss_per_token * non_entity_mask

        # 确定要挖掘的硬负样本数量
        num_entities = torch.sum(entity_mask).item()
        num_hard_negatives = int(num_entities * self.hard_negative_ratio)

        # 如果当前批次没有实体,则按固定比例选择负样本,避免数量为0
        if num_hard_negatives == 0:
            num_non_entities = torch.sum(non_entity_mask).item()
            num_hard_negatives = int(num_non_entities * 0.1)

        # 从非实体损失中选出最大的 top-k 个作为硬负样本
        topk_losses, _ = torch.topk(non_entity_loss.view(-1), k=num_hard_negatives)
        
        # 计算硬负样本的平均损失
        hard_negative_loss = torch.mean(topk_losses)

        # 结合实体损失和硬负样本损失
        total_loss = self.entity_weight * entity_loss + 1.0 * hard_negative_loss

        return total_loss, entity_loss.detach(), hard_negative_loss.detach()
相关推荐
缘友一世2 小时前
当LLM Agent遇上真实渗透测试:从失败分类到难度感知规划的系统性突破
人工智能·渗透测试
躺柒2 小时前
读2025世界前沿技术发展报告32极地考察与开发
人工智能·北极·海洋工程·极地探索·海洋技术
2301_764441332 小时前
Dify工作流中实现查询优化(QO):将查询复杂度分类法与QOL框架融入工作流
人工智能·语言模型·自然语言处理·命令模式
oG99bh7CK2 小时前
高光谱成像基础(六)滤波匹配 MF
人工智能·算法·目标跟踪
永霖光电_UVLED2 小时前
生物技术公司 BiomX 进军国防市场,计划收购 DFSL
人工智能·架构·汽车
龙文浩_2 小时前
AI深度学习/PyTorch/反向传播与梯度下降
人工智能·pytorch·深度学习
独隅2 小时前
Keras 全面介绍:从入门到实践
人工智能·深度学习·keras
工业机器视觉设计和实现2 小时前
自己的初心,在bpnet基础上自研cnn
人工智能·神经网络·cnn
cyyt2 小时前
深度学习周报(3.30~4.5)
人工智能·深度学习