""" perplexity.py — token-predictability engine. The core AI-vs-human signal: LLMs generate text auto-regressively by picking high-probability next tokens, so machine-written text tends to be *more predictable* — lower perplexity, higher fraction of tokens the model would itself have ranked at the top. Human text is "burstier" and picks lower-probability words more often. This module loads a small causal LM per language (GPT-2 for English, KoGPT2 for Korean) and computes, for a piece of text: - mean perplexity (exp of mean negative log-likelihood) - mean / std of token log-prob (predictability + its variability) - top-k rank hit rate (fraction of tokens in model's top-k) - per-token surprisal (for span/paragraph highlighting) Long inputs are processed with a sliding window so we stay within the model's context length and CPU memory budget. """ from __future__ import annotations import os import math from dataclasses import dataclass, field from functools import lru_cache from typing import List, Optional import torch os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") # Only force offline mode when BOTH language-model dirs are present locally # (e.g. a bundled offline install). Otherwise leave the hub reachable so the # weights can be downloaded on first run (e.g. on Hugging Face Spaces). _gpt2_dir = os.environ.get("AIDETECT_GPT2_DIR", "models/gpt2") _kogpt2_dir = os.environ.get("AIDETECT_KOGPT2_DIR", "models/kogpt2") if os.path.isdir(_gpt2_dir) and os.path.isdir(_kogpt2_dir): os.environ.setdefault("HF_HUB_OFFLINE", "1") os.environ.setdefault("TRANSFORMERS_OFFLINE", "1") from transformers import ( # noqa: E402 AutoModelForCausalLM, AutoTokenizer, PreTrainedTokenizerFast, ) from transformers.utils import logging as _hf_logging # noqa: E402 _hf_logging.set_verbosity_error() # silence benign load-report / buffer notes # ---------------------------------------------------------------------------- # Model locations. Point these at local dirs (assembled during setup) or HF ids. # ---------------------------------------------------------------------------- _DEFAULT_MODEL_DIRS = { "en": os.environ.get("AIDETECT_GPT2_DIR", "models/gpt2"), "ko": os.environ.get("AIDETECT_KOGPT2_DIR", "models/kogpt2"), } # Fallback HF hub ids if the local dir is absent. _HUB_IDS = {"en": "gpt2", "ko": "skt/kogpt2-base-v2"} # KoGPT2 ships only tokenizer.json; special tokens must be supplied explicitly. _KOGPT2_SPECIAL = dict( bos_token="", eos_token="", unk_token="", pad_token="", mask_token="", ) torch.set_num_threads(max(1, os.cpu_count() or 1)) @dataclass class PPLResult: """Perplexity / predictability summary for one text.""" lang: str n_tokens: int perplexity: float # exp(mean NLL) — lower = more AI-like mean_logprob: float # mean token log-prob (nats); higher = more predictable std_logprob: float # variability of surprisal ~ "burstiness" proxy topk_hit_rate: float # fraction of tokens in model's top-k prediction median_logprob: float token_surprisals: List[float] = field(default_factory=list) # per-token NLL (nats) token_strings: List[str] = field(default_factory=list) def as_features(self) -> dict: return { "ppl": self.perplexity, "log_ppl": math.log(self.perplexity + 1e-9), "mean_logprob": self.mean_logprob, "std_logprob": self.std_logprob, "median_logprob": self.median_logprob, "topk_hit_rate": self.topk_hit_rate, } class _LM: """A loaded causal LM + tokenizer for one language.""" def __init__(self, lang: str): self.lang = lang path = _DEFAULT_MODEL_DIRS.get(lang) src = path if (path and os.path.isdir(path)) else _HUB_IDS[lang] self.model = AutoModelForCausalLM.from_pretrained(src) self.model.eval() if lang == "ko": tok_file = os.path.join(src, "tokenizer.json") if os.path.isfile(tok_file): self.tok = PreTrainedTokenizerFast( tokenizer_file=tok_file, **_KOGPT2_SPECIAL ) else: # hub fallback self.tok = AutoTokenizer.from_pretrained(src, **_KOGPT2_SPECIAL) else: self.tok = AutoTokenizer.from_pretrained(src) self.max_ctx = getattr(self.model.config, "n_positions", 1024) @lru_cache(maxsize=4) def get_lm(lang: str) -> _LM: """Load (and cache) the LM for a language. 'en' or 'ko'.""" if lang not in _HUB_IDS: lang = "en" return _LM(lang) @torch.no_grad() def score_text( text: str, lang: str = "en", topk: int = 10, stride: Optional[int] = None, want_tokens: bool = True, ) -> PPLResult: """ Compute perplexity / predictability statistics for `text`. Uses a sliding window (`stride`) so text longer than the model context is scored without truncation; overlapping context tokens are masked from the loss so each token is scored exactly once with full left context. """ lm = get_lm(lang) tok, model = lm.tok, lm.model max_len = min(lm.max_ctx, 1024) if stride is None: stride = max_len // 2 enc = tok(text, return_tensors="pt") input_ids = enc.input_ids n_all = input_ids.shape[1] if n_all < 2: return PPLResult(lang, 0, float("inf"), 0.0, 0.0, 0.0, 0.0, [], []) nll_sum = 0.0 logprobs: List[float] = [] surprisals: List[float] = [] topk_hits = 0 counted = 0 prev_end = 0 for begin in range(0, n_all, stride): end = min(begin + max_len, n_all) ids = input_ids[:, begin:end] # number of *new* target tokens in this window (avoid double counting) trg_len = end - prev_end logits = model(ids).logits # (1, L, V) # predict position t from t-1: align logits[:-1] with labels[1:] shift_logits = logits[0, :-1, :] shift_labels = ids[0, 1:] logp = torch.log_softmax(shift_logits, dim=-1) tok_logp = logp[torch.arange(shift_labels.shape[0]), shift_labels] # (L-1,) # only keep the last (trg_len-1) newly-scored tokens in this window if trg_len > 1: keep_from = shift_labels.shape[0] - (trg_len - 1) else: keep_from = shift_labels.shape[0] keep_from = max(0, keep_from) sel_logp = tok_logp[keep_from:] sel_labels = shift_labels[keep_from:] sel_logits = shift_logits[keep_from:] # top-k membership if sel_logits.shape[0] > 0: topk_ids = sel_logits.topk(topk, dim=-1).indices hits = (topk_ids == sel_labels.unsqueeze(1)).any(dim=1) topk_hits += int(hits.sum()) nll = -sel_logp nll_sum += float(nll.sum()) counted += sel_logp.shape[0] logprobs.extend(sel_logp.tolist()) surprisals.extend(nll.tolist()) prev_end = end if end == n_all: break if counted == 0: return PPLResult(lang, 0, float("inf"), 0.0, 0.0, 0.0, 0.0, [], []) mean_nll = nll_sum / counted ppl = math.exp(min(mean_nll, 50)) lp_tensor = torch.tensor(logprobs) result = PPLResult( lang=lang, n_tokens=counted, perplexity=ppl, mean_logprob=float(lp_tensor.mean()), std_logprob=float(lp_tensor.std(unbiased=False)) if counted > 1 else 0.0, median_logprob=float(lp_tensor.median()), topk_hit_rate=topk_hits / counted, ) if want_tokens: all_toks = tok.convert_ids_to_tokens(input_ids[0].tolist()) result.token_surprisals = surprisals result.token_strings = all_toks[1:] # labels are input_ids[1:] return result if __name__ == "__main__": import sys txt = sys.argv[1] if len(sys.argv) > 1 else \ "The results demonstrate a statistically significant improvement." lang = sys.argv[2] if len(sys.argv) > 2 else "en" r = score_text(txt, lang) print(f"lang={r.lang} n={r.n_tokens} ppl={r.perplexity:.1f} " f"mean_lp={r.mean_logprob:.3f} std_lp={r.std_logprob:.3f} " f"topk={r.topk_hit_rate:.3f}")