| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- """
- text_scorer.py - 基于语言模型的文本完整性评分模块
- 使用中文 GPT-2 模型(ONNX 格式)计算文本的自然度评分,
- 用于判断:
- 1. 两个文本片段是否应该拼接(跨页内容延续检测)
- 2. 文本中的换行符是否需要去除
- """
- import logging
- import os
- from typing import Optional
- import numpy as np
- import onnxruntime as ort
- from tokenizers import Tokenizer
- logger = logging.getLogger(__name__)
- # 模型路径
- MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
- "models", "gpt2-chinese-cluecorpussmall-onnx")
- MODEL_PATH = os.path.join(MODEL_DIR, "model.onnx")
- TOKENIZER_PATH = os.path.join(MODEL_DIR, "tokenizer.json")
- # 特殊 token ID(BERT WordPiece)
- CLS_ID = 101
- SEP_ID = 102
- PAD_ID = 0
- # 最大序列长度
- MAX_LEN = 64
- class TextScorer:
- """使用中文 GPT-2 计算文本自然度评分的单例封装。"""
- _instance = None
- def __new__(cls):
- if cls._instance is None:
- cls._instance = super().__new__(cls)
- cls._instance._initialized = False
- return cls._instance
- def __init__(self):
- if self._initialized:
- return
- self._initialized = True
- self._session = None
- self._tokenizer = None
- def _ensure_loaded(self):
- if self._session is not None:
- return
- if not os.path.exists(MODEL_PATH):
- raise FileNotFoundError(
- f"模型文件不存在: {MODEL_PATH}\n"
- f"请确认 models/gpt2-chinese-cluecorpussmall-onnx/ 已下载"
- )
- self._session = ort.InferenceSession(MODEL_PATH)
- self._tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
- logger.info(f"文本评分模型已加载: {MODEL_PATH}")
- def score(self, text: str) -> float:
- """计算文本的自然度评分(0~1),越高越自然完整。
- 基于困惑度(Perplexity)转换:score = 1 / (1 + PPL/10)
- """
- self._ensure_loaded()
- if not text or not text.strip():
- return 1.0
- tokens = self._tokenizer.encode(text.strip())
- ids = tokens.ids
- if len(ids) > MAX_LEN - 2:
- ids = ids[:MAX_LEN - 2]
- input_ids = [CLS_ID] + ids + [SEP_ID]
- seq_len = len(input_ids)
- padding = [PAD_ID] * (MAX_LEN - seq_len)
- input_ids_padded = input_ids + padding
- attn_mask = [1] * seq_len + [0] * (MAX_LEN - seq_len)
- inputs = {
- 'input_ids': np.array([input_ids_padded], dtype=np.int64),
- 'attention_mask': np.array([attn_mask], dtype=np.int64),
- }
- logits = self._session.run(None, inputs)[0]
- # 计算每个位置的交叉熵损失(预测下一个 token)
- losses = []
- for i in range(seq_len - 1):
- pred = logits[0, i, :]
- true_id = input_ids[i + 1]
- # softmax + cross-entropy
- pred_max = np.max(pred)
- exp_pred = np.exp(pred - pred_max)
- probs = exp_pred / np.sum(exp_pred)
- loss = -np.log(max(probs[true_id], 1e-10))
- losses.append(loss)
- avg_loss = np.mean(losses) if losses else 0
- ppl = np.exp(avg_loss)
- return float(1.0 / (1.0 + ppl / 10.0))
- def is_continuation(self, text1: str, text2: str,
- threshold: float = 1.5) -> bool:
- """判断 text2 是否是 text1 的内容延续。
- 比较 text1、text2 和拼接后文本的评分。
- 如果拼接后评分显著高于各自评分,说明是延续关系。
- Args:
- text1: 前半段文本
- text2: 后半段文本
- threshold: 拼接提升倍率阈值(默认 1.5 倍)
- Returns:
- bool: 是否构成内容延续
- """
- if not text1 or not text2:
- return False
- if len(text1.strip()) < 2 or len(text2.strip()) < 2:
- return False
- s1 = self.score(text1)
- s2 = self.score(text2)
- sc = self.score(text1.strip() + text2.strip())
- # 拼接后评分 > 各自最大评分的 threshold 倍
- best_individual = max(s1, s2)
- if best_individual <= 0:
- return False
- improvement = sc / best_individual
- # 同时检查:拼接后不低于单独评分,且明显提升
- return sc > best_individual and improvement >= threshold
- def clean_newlines(self, text: str,
- threshold: float = 1.1) -> str:
- """去除文本中对自然度有负面影响的换行符。
- 比较原文和去除 \\n 后的评分,若去除后评分提升超过 threshold,
- 则返回去除换行符的版本。
- Args:
- text: 原始文本
- threshold: 评分提升阈值(默认 1.1 倍)
- Returns:
- str: 清理后的文本
- """
- if not text or '\n' not in text:
- return text
- cleaned = text.replace('\n', '')
- if cleaned == text:
- return text
- s_orig = self.score(text)
- s_clean = self.score(cleaned)
- if s_clean > s_orig * threshold:
- logger.debug(f"去除换行符: '{text[:30]}' -> '{cleaned[:30]}'"
- f" (评分 {s_orig:.4f} -> {s_clean:.4f})")
- return cleaned
- return text
- # 全局单例
- _scorer = None
- def get_scorer() -> TextScorer:
- """获取全局 TextScorer 单例。"""
- global _scorer
- if _scorer is None:
- _scorer = TextScorer()
- return _scorer
|