text_scorer.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """
  2. text_scorer.py - 基于语言模型的文本完整性评分模块
  3. 使用中文 GPT-2 模型(ONNX 格式)计算文本的自然度评分,
  4. 用于判断:
  5. 1. 两个文本片段是否应该拼接(跨页内容延续检测)
  6. 2. 文本中的换行符是否需要去除
  7. """
  8. import logging
  9. import os
  10. from typing import Optional
  11. import numpy as np
  12. import onnxruntime as ort
  13. from tokenizers import Tokenizer
  14. logger = logging.getLogger(__name__)
  15. # 模型路径
  16. MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
  17. "models", "gpt2-chinese-cluecorpussmall-onnx")
  18. MODEL_PATH = os.path.join(MODEL_DIR, "model.onnx")
  19. TOKENIZER_PATH = os.path.join(MODEL_DIR, "tokenizer.json")
  20. # 特殊 token ID(BERT WordPiece)
  21. CLS_ID = 101
  22. SEP_ID = 102
  23. PAD_ID = 0
  24. # 最大序列长度
  25. MAX_LEN = 64
  26. class TextScorer:
  27. """使用中文 GPT-2 计算文本自然度评分的单例封装。"""
  28. _instance = None
  29. def __new__(cls):
  30. if cls._instance is None:
  31. cls._instance = super().__new__(cls)
  32. cls._instance._initialized = False
  33. return cls._instance
  34. def __init__(self):
  35. if self._initialized:
  36. return
  37. self._initialized = True
  38. self._session = None
  39. self._tokenizer = None
  40. def _ensure_loaded(self):
  41. if self._session is not None:
  42. return
  43. if not os.path.exists(MODEL_PATH):
  44. raise FileNotFoundError(
  45. f"模型文件不存在: {MODEL_PATH}\n"
  46. f"请确认 models/gpt2-chinese-cluecorpussmall-onnx/ 已下载"
  47. )
  48. self._session = ort.InferenceSession(MODEL_PATH)
  49. self._tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
  50. logger.info(f"文本评分模型已加载: {MODEL_PATH}")
  51. def score(self, text: str) -> float:
  52. """计算文本的自然度评分(0~1),越高越自然完整。
  53. 基于困惑度(Perplexity)转换:score = 1 / (1 + PPL/10)
  54. """
  55. self._ensure_loaded()
  56. if not text or not text.strip():
  57. return 1.0
  58. tokens = self._tokenizer.encode(text.strip())
  59. ids = tokens.ids
  60. if len(ids) > MAX_LEN - 2:
  61. ids = ids[:MAX_LEN - 2]
  62. input_ids = [CLS_ID] + ids + [SEP_ID]
  63. seq_len = len(input_ids)
  64. padding = [PAD_ID] * (MAX_LEN - seq_len)
  65. input_ids_padded = input_ids + padding
  66. attn_mask = [1] * seq_len + [0] * (MAX_LEN - seq_len)
  67. inputs = {
  68. 'input_ids': np.array([input_ids_padded], dtype=np.int64),
  69. 'attention_mask': np.array([attn_mask], dtype=np.int64),
  70. }
  71. logits = self._session.run(None, inputs)[0]
  72. # 计算每个位置的交叉熵损失(预测下一个 token)
  73. losses = []
  74. for i in range(seq_len - 1):
  75. pred = logits[0, i, :]
  76. true_id = input_ids[i + 1]
  77. # softmax + cross-entropy
  78. pred_max = np.max(pred)
  79. exp_pred = np.exp(pred - pred_max)
  80. probs = exp_pred / np.sum(exp_pred)
  81. loss = -np.log(max(probs[true_id], 1e-10))
  82. losses.append(loss)
  83. avg_loss = np.mean(losses) if losses else 0
  84. ppl = np.exp(avg_loss)
  85. return float(1.0 / (1.0 + ppl / 10.0))
  86. def is_continuation(self, text1: str, text2: str,
  87. threshold: float = 1.5) -> bool:
  88. """判断 text2 是否是 text1 的内容延续。
  89. 比较 text1、text2 和拼接后文本的评分。
  90. 如果拼接后评分显著高于各自评分,说明是延续关系。
  91. Args:
  92. text1: 前半段文本
  93. text2: 后半段文本
  94. threshold: 拼接提升倍率阈值(默认 1.5 倍)
  95. Returns:
  96. bool: 是否构成内容延续
  97. """
  98. if not text1 or not text2:
  99. return False
  100. if len(text1.strip()) < 2 or len(text2.strip()) < 2:
  101. return False
  102. s1 = self.score(text1)
  103. s2 = self.score(text2)
  104. sc = self.score(text1.strip() + text2.strip())
  105. # 拼接后评分 > 各自最大评分的 threshold 倍
  106. best_individual = max(s1, s2)
  107. if best_individual <= 0:
  108. return False
  109. improvement = sc / best_individual
  110. # 同时检查:拼接后不低于单独评分,且明显提升
  111. return sc > best_individual and improvement >= threshold
  112. def clean_newlines(self, text: str,
  113. threshold: float = 1.1) -> str:
  114. """去除文本中对自然度有负面影响的换行符。
  115. 比较原文和去除 \\n 后的评分,若去除后评分提升超过 threshold,
  116. 则返回去除换行符的版本。
  117. Args:
  118. text: 原始文本
  119. threshold: 评分提升阈值(默认 1.1 倍)
  120. Returns:
  121. str: 清理后的文本
  122. """
  123. if not text or '\n' not in text:
  124. return text
  125. cleaned = text.replace('\n', '')
  126. if cleaned == text:
  127. return text
  128. s_orig = self.score(text)
  129. s_clean = self.score(cleaned)
  130. if s_clean > s_orig * threshold:
  131. logger.debug(f"去除换行符: '{text[:30]}' -> '{cleaned[:30]}'"
  132. f" (评分 {s_orig:.4f} -> {s_clean:.4f})")
  133. return cleaned
  134. return text
  135. # 全局单例
  136. _scorer = None
  137. def get_scorer() -> TextScorer:
  138. """获取全局 TextScorer 单例。"""
  139. global _scorer
  140. if _scorer is None:
  141. _scorer = TextScorer()
  142. return _scorer