视频加载失败

课程

1483 字
约 5 分钟

实验六:基于预训练模型的实体关系联合抽取

自然语言处理labs/lab/lab06/docs·更新于 2026-09-15

实验六:基于预训练模型的实体关系联合抽取

实验地点:计算机大楼606

实验目的: 理解并实现基于预训练模型的实体关系联合抽取方法,掌握 tagging schema 联合抽取思路。

实验环境(硬件和软件) Windows 11,Python 3.12,PyTorch,HuggingFace Transformers

实验内容:

本实验基于 tagging schema 的联合抽取思路,使用 BERT 预训练模型实现了实体关系联合抽取系统。

核心思想是用单个模型同时完成两个任务:token 级的 BIO 序列标注(实体识别)和句子级的多标签分类(关系预测),两个任务共享 BERT 编码器。

与 pipeline 方法(先识别实体再抽取关系)不同,联合抽取让实体识别和关系分类在训练时相互促进,避免了级联错误传播。

训练完成后,模型能够从输入文本中同时识别出实体和它们之间的关系,通过三元组生成模块输出完整的 SPO 结构化信息。

实验步骤:

1. 数据处理:读取 JSONL 格式的 SPO 数据,使用 BERT tokenizer 分词,并生成 BIO 标签和谓词多标签。

def _process_example(self, data):
        """Convert a single JSONL example to model inputs.

        Follows the reference code's BIO labeling approach:
        1. Tokenize text with BERT tokenizer
        2. Label entity spans with B-I-O tags
        3. Encode predicate labels as multi-hot vector
        """
        text = data['text']
        spo_list = data.get('spo_list', [])

        # Tokenize with BERT
        tokens = self.tokenizer.tokenize(text)
        if len(tokens) > self.max_seq_length - 2:  # Reserve for [CLS] and [SEP]
            tokens = tokens[:self.max_seq_length - 2]

        # Initialize token labels as O
        token_labels = ['O'] * len(tokens)

        # Label entities with BIO tags
        for spo in spo_list:
            for role, entity_text, entity_type in [
                ('subject', spo['subject'], spo['subject_type']),
                ('object', spo['object'], spo['object_type'])
            ]:
                entity_tokens = self.tokenizer.tokenize(entity_text)
                # Find entity tokens in text tokens
                start_idx = self._find_sublist(tokens, entity_tokens)
                if start_idx is not None:
                    token_labels[start_idx] = "B-" + entity_type
                    for j in range(1, len(entity_tokens)):
                        token_labels[start_idx + j] = "I-" + entity_type

        # Mark WordPiece continuation tokens
        for idx, token in enumerate(tokens):
            if token.startswith("##"):
                token_labels[idx] = "[##WordPiece]"

        # Build input IDs with [CLS] and [SEP]
        final_tokens = ['[CLS]'] + tokens + ['[SEP]']
        final_labels = ['[CLS]'] + token_labels + ['[SEP]']

        input_ids = self.tokenizer.convert_tokens_to_ids(final_tokens)
        attention_mask = [1] * len(input_ids)

        # Token label IDs
        token_label_ids = [self.token_label_map.get(l, self.token_label_map['O']) for l in final_labels]

        # Pad
        padding_length = self.max_seq_length - len(input_ids)
        input_ids += [0] * padding_length
        attention_mask += [0] * padding_length
        token_label_ids += [self.token_label_map['[Padding]']] * padding_length

        # Predicate multi-hot encoding
        predicate_ids = [0] * len(self.predicate_label_list)
        for spo in spo_list:
            pred = spo['predicate']
            if pred in self.predicate_label_map:
                predicate_ids[self.predicate_label_map[pred]] = 1

        return {
            'input_ids': torch.tensor(input_ids, dtype=torch.long),
            'attention_mask': torch.tensor(attention_mask, dtype=torch.long),
            'token_label_ids': torch.tensor(token_label_ids, dtype=torch.long),
            'predicate_ids': torch.tensor(predicate_ids, dtype=torch.float),
            'text': text,
            'tokens': tokens,
            'spo_list': spo_list,

2. 模型定义:BERT 编码器 + 双任务输出头(token label head 和 predicate head)。

class JointExtractionModel(nn.Module):
    """Joint model for entity recognition and relation extraction.

    Two output heads sharing a BERT encoder:
    1. Token label head: sequence labeling for entity BIO tags
    2. Predicate head: multi-label classification for relation types
    """

    def __init__(self, bert_model_name, num_token_labels, num_predicates,
                 dropout=0.1):
        super(JointExtractionModel, self).__init__()

        self.bert = BertModel.from_pretrained(bert_model_name)
        hidden_size = self.bert.config.hidden_size

        self.dropout = nn.Dropout(dropout)

        # Token label head: sequence labeling (BIO tags)
        self.token_label_classifier = nn.Linear(hidden_size, num_token_labels)

        # Predicate head: multi-label classification (relation types)
        self.predicate_classifier = nn.Linear(hidden_size, num_predicates)

        # Loss functions
        self.token_label_criterion = nn.CrossEntropyLoss(ignore_index=0)  # ignore [Padding]
        self.predicate_criterion = nn.BCEWithLogitsLoss()

    def forward(self, input_ids, attention_mask, token_label_ids=None,
                predicate_ids=None):
        """
        Args:
            input_ids: [batch_size, seq_length]
            attention_mask: [batch_size, seq_length]
            token_label_ids: [batch_size, seq_length] (for training)
            predicate_ids: [batch_size, num_predicates] (for training)

        Returns:
            dict with 'token_label_logits', 'predicate_logits', and optionally 'loss'
        """
        outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)

        sequence_output = outputs.last_hidden_state   # [batch, seq_len, hidden]
        pooled_output = outputs.pooler_output          # [batch, hidden]

        sequence_output = self.dropout(sequence_output)
        pooled_output = self.dropout(pooled_output)

        # Token label prediction
        token_label_logits = self.token_label_classifier(sequence_output)
        # [batch, seq_len, num_token_labels]

        # Predicate prediction
        predicate_logits = self.predicate_classifier(pooled_output)
        # [batch, num_predicates]

        result = {
            'token_label_logits': token_label_logits,
            'predicate_logits': predicate_logits,
        }

        # Compute loss if labels provided
        if token_label_ids is not None and predicate_ids is not None:
            # Token label loss
            active_loss = attention_mask.view(-1) == 1
            active_logits = token_label_logits.view(-1, token_label_logits.size(-1))[active_loss]
            active_labels = token_label_ids.view(-1)[active_loss]
            token_label_loss = self.token_label_criterion(active_logits, active_labels)

            # Predicate loss
            predicate_loss = self.predicate_criterion(predicate_logits, predicate_ids)

            # Combined loss
            total_loss = token_label_loss + predicate_loss
            result['loss'] = total_loss
            result['token_label_loss'] = token_label_loss.item()
            result['predicate_loss'] = predicate_loss.item()

        return result

3. 三元组生成:从预测的 BIO 标签提取实体,与预测的关系类型匹配生成 SPO 三元组。

def extract_entities(tokens, token_labels, token_label_list):
    """Extract entities from BIO-tagged token sequence.

    Args:
        tokens: list of token strings
        token_labels: list of predicted label IDs (integers)
        token_label_list: mapping from ID to label string

    Returns:
        list of entity dicts with text, type, start, end
    """
    entities = []
    current_entity = None
    o_label = 'O'

    for i, (token, label_id) in enumerate(zip(tokens, token_labels)):
        if i >= len(tokens):
            break
        label = token_label_list[label_id] if label_id < len(token_label_list) else o_label

        # Skip special tokens
        if label in SPECIAL_TOKENS or label == '[##WordPiece]':
            continue

        if label.startswith("B-"):
            # Save previous entity
            if current_entity is not None:
                entities.append(current_entity)
            entity_type = label[2:]
            current_entity = {
                'text': token.replace('##', ''),
                'type': entity_type,
                'start': i,
                'end': i,
            }
        elif label.startswith("I-") and current_entity is not None:
            entity_type = label[2:]
            if entity_type == current_entity['type']:
                current_entity['text'] += token.replace('##', '')
                current_entity['end'] = i
            else:
                entities.append(current_entity)
                current_entity = None
        else:
            if current_entity is not None:
                entities.append(current_entity)
                current_entity = None

    if current_entity is not None:
        entities.append(current_entity)

    return entities
  • (1)联合抽取相比 pipeline 方法有什么优势?

Pipeline 方法先做实体识别再做关系分类,两个模型独立训练,实体识别的错误会级联传播到关系抽取阶段。

联合抽取让实体识别和关系分类共享编码器,在训练时相互促进。例如,关系标签的学习可以帮助模型更好地区分不同语境下的实体边界。

  • (2)为什么谓词预测使用多标签分类而非多分类?

一个句子中可能同时存在多种关系类型(如’李雷在华为工作,毕业于北京大学’同时含 works_for 和 educated_at),因此需要对每个谓词独立做二分类判断。

模型使用 Sigmoid + BCEWithLogitsLoss,对每个谓词输出 0/1 预测,而非 Softmax + CrossEntropy 的互斥多分类。

实验数据记录:

1. 训练数据 25 条,验证数据 8 条,JSONL 格式。

2. 实体类型 3 种:PER(人名)、ORG(机构)、LOC(地点)。

3. 关系类型 6 种:works_for、educated_at、lives_in、located_in、visits、founder_of。

4. 模型使用 bert-base-chinese 作为编码器,max_seq_length=128。

5. 训练参数:epochs=10,batch_size=8,learning_rate=2e-5,AdamW + linear warmup。

问题讨论:

问题:联合抽取模型在小规模数据上的效果受限。

现象描述:当前训练数据仅 25 条,模型学到的实体和关系模式有限,对新样本的泛化能力不足。

原因分析:BERT 预训练模型虽然具有强大的语言理解能力,但微调阶段仍需要足够的任务数据来学习特定的标注模式。

解决方法:扩充训练数据规模,增加更多实体和关系类型,使用更大的预训练模型(如 RoBERTa)或引入数据增强策略。

Profile Image of the Author
Sonder
好想要技术
这是公告标题
这只是一个公告
分类
标签
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录