humanize-text-model / humanize.py
Lynote's picture
Update default model id to Lynote namespace
7f7899a verified
Raw
History Blame Contribute Delete
8.9 kB
"""humanize-text-model inference wrapper (bilingual: English + Chinese).
Loads the Lynote bilingual humanizer — two small seq2seq checkpoints in one
repo (``en/`` T5-small, ``zh/`` Chinese T5-small) — and routes each input to
the right language model. URLs, numbers, paths, code and quoted strings are
protected with tokenizer-friendly placeholders before generation and restored
afterwards.
Usage:
from humanize import Humanizer
h = Humanizer() # loads "Lynote/humanize-text-model" from the Hub
print(h("It is important to note that this robust solution serves as a testament to our commitment."))
"""
import argparse
import os
import re
import sys
from typing import List, Optional
import torch
from transformers import BertTokenizer, T5ForConditionalGeneration, T5Tokenizer
DEFAULT_MODEL = "Lynote/humanize-text-model"
MAX_CHARS = 4_000
MAX_TOKENS = 256
DEVICE = "mps" if torch.backends.mps.is_available() else (
"cuda" if torch.cuda.is_available() else "cpu"
)
PROTECTED_PATTERN = re.compile(
r"(`[^`]+`|https?://[^\s\u4e00-\u9fff,。!?;:、]+|(?:[A-Za-z]:)?(?:[/\\][\w.\-]+)+|"
r"\b\d+(?:\.\d+)?(?:%|[A-Za-z]+)?\b|\"[^\"]*\"|'[^']*')"
)
CJK_RATIO = re.compile(r"[\u4e00-\u9fff]")
def protect(text: str, language: Optional[str] = None):
"""Replace protected spans with language-aware placeholders:
EN ``PROTECTED_n``, ZH ``【保护n】`` (both tokenizer-friendly)."""
protected = []
zh = language == "zh" or (language is None and is_chinese(text))
def replace(match):
token = f"【保护{len(protected)}】" if zh else f"PROTECTED_{len(protected)}"
protected.append(match.group(0))
return token
return PROTECTED_PATTERN.sub(replace, text), protected
def restore(text: str, protected: List[str]) -> str:
"""Restore placeholders (either format, tolerant of inserted spaces)."""
for index, value in enumerate(protected):
text = re.sub(rf"PROTECTED_{index}\b", lambda m: value, text)
text = re.sub(
rf"【\s*保\s*护\s*{index}\s*】", lambda m: value, text
)
return text
def is_chinese(text: str, threshold: float = 0.05) -> bool:
total = len(re.sub(r"\s+", "", text))
if total == 0:
return False
return len(CJK_RATIO.findall(text)) / total > threshold
class Humanizer:
def __init__(
self,
model_id: str = DEFAULT_MODEL,
device: Optional[str] = None,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
hf_token: Optional[str] = None,
lazy: bool = True,
):
"""model_id is a repo (or local dir) containing ``en/`` and ``zh/``
subdirectories with separate checkpoints. With ``lazy=True`` (default)
a language model is only loaded when first needed."""
self.model_id = model_id
self.device = torch.device(device or DEVICE)
self._models = {}
self._tokenizers = {}
self._cache_dir = cache_dir
self._local_only = local_files_only
self._token = hf_token
if not lazy:
self._get("en")
self._get("zh")
def _load(self, language: str, model: T5ForConditionalGeneration, tokenizer: T5Tokenizer):
self._models[language] = model
self._tokenizers[language] = tokenizer
def _get(self, language: str):
if language not in self._models:
model = T5ForConditionalGeneration.from_pretrained(
self.model_id, subfolder=language,
cache_dir=self._cache_dir,
local_files_only=self._local_only, token=self._token,
)
tokenizer_cls = BertTokenizer if language == "zh" else T5Tokenizer
tokenizer = tokenizer_cls.from_pretrained(
self.model_id, subfolder=language,
cache_dir=self._cache_dir,
local_files_only=self._local_only, token=self._token,
)
model.to(self.device).eval()
self._load(language, model, tokenizer)
return self._models[language], self._tokenizers[language]
def humanize(self, text: str, num_beams: int = 3, do_sample: bool = False) -> str:
text = (text or "").strip()
if not text:
raise ValueError("Input text is empty.")
if len(text) > MAX_CHARS:
raise ValueError(f"Input exceeds {MAX_CHARS:,} characters.")
return self.humanize_batch([text], num_beams=num_beams, do_sample=do_sample)[0]
def humanize_batch(
self,
texts: List[str],
num_beams: int = 3,
do_sample: bool = False,
batch_size: int = 8,
) -> List[str]:
"""Humanize a list of texts. URLs, numbers, paths, code and quotes are
protected per-text with placeholders and restored after generation."""
masked = []
protected_all = []
for t in texts:
masked_text, protected = protect(t)
masked.append(masked_text)
protected_all.append(protected)
outputs: List[str] = []
grouped: dict = {}
for i, t in enumerate(masked):
grouped.setdefault("zh" if is_chinese(t) else "en", []).append(i)
for language, indices in grouped.items():
model, tokenizer = self._get(language)
for start in range(0, len(indices), batch_size):
chunk_idx = indices[start : start + batch_size]
enc = tokenizer(
[masked[i] for i in chunk_idx],
max_length=MAX_TOKENS,
truncation=True,
padding=True,
return_tensors="pt",
)
if "token_type_ids" in enc:
enc.pop("token_type_ids")
enc = enc.to(self.device)
with torch.no_grad():
gen_kwargs = dict(
num_beams=num_beams,
do_sample=do_sample,
early_stopping=True,
)
if language == "zh":
# Small Chinese T5 degenerates on long sequences: cap
# length and block exact 4-gram repetition (EN does not
# need these).
gen_kwargs["max_length"] = 40
gen_kwargs["no_repeat_ngram_size"] = 4
else:
gen_kwargs["max_length"] = MAX_TOKENS
gen = model.generate(**enc, **gen_kwargs)
decoded = tokenizer.batch_decode(gen, skip_special_tokens=True)
for j, text in zip(chunk_idx, decoded):
outputs.append((j, text))
outputs.sort(key=lambda x: x[0])
return [
restore(self._polish(out), protected)
for out, protected in zip([o[1] for o in outputs], protected_all)
]
@staticmethod
def _polish(text: str) -> str:
text = re.sub(r"^\s*(?:extra\d+\s*[,,。\s]*)+", "", text)
text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff,。;:!?])", "", text)
text = re.sub(r"(?<=[,。;:!?])\s+(?=[\u4e00-\u9fff])", "", text)
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"\s+([,.;:!?,。;:!?])", r"\1", text)
text = re.sub(r"[,;]\s*\.", ".", text)
text = re.sub(r"\.\s*[,;]", ".", text)
text = re.sub(r"[,,]\s*(?=[。!?.!?])", "", text)
text = re.sub(r"\.\s+([a-z])", lambda m: ". " + m.group(1).upper(), text)
return text.strip(" ,")
def main():
parser = argparse.ArgumentParser(description="Bilingual text humanizer (T5-small EN + Chinese T5-small)")
parser.add_argument("--text", help="text to humanize")
parser.add_argument("--input", help="path to a text file")
parser.add_argument("--output", help="write result to a file")
parser.add_argument("--model", default=DEFAULT_MODEL, help="model id or local dir")
parser.add_argument("--beams", type=int, default=3)
parser.add_argument("--list-models", action="store_true", help="print model ids and exit")
args = parser.parse_args()
if args.list_models:
print(DEFAULT_MODEL)
return
if args.text and args.input:
parser.error("provide only one of --text / --input")
source = args.text or (open(args.input, encoding="utf-8").read() if args.input else None)
if not source:
parser.error("provide --text or --input")
humanizer = Humanizer(model_id=args.model)
result = humanizer.humanize(source, num_beams=args.beams)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(result)
print(f"written to {args.output}")
else:
print(result)
if __name__ == "__main__":
main()