ai-human-text-detector / src /features.py
mdarahmanxAI's picture
Deploy AI vs human detector with LLM explanations
d433145 verified
Raw
History Blame Contribute Delete
3.8 kB
"""Handcrafted linguistic features.
These capture writing style rather than topic: how long sentences are and how
much that varies, vocabulary richness, punctuation habits, and readability.
They are computed on the original text (before whitespace normalization) so
casing, digits, and punctuation are preserved.
Used by both the training notebook and the Streamlit app.
"""
from __future__ import annotations
import re
import numpy as np
import textstat
import nltk
from nltk.tokenize import sent_tokenize
from nltk.corpus import stopwords
def _ensure_nltk_data():
"""Download the corpora we rely on if they are not already present.
Keeps the app working on a fresh server (e.g. a cloud deploy) where NLTK
data has not been installed. No-op once the data is on disk.
"""
for resource in ("punkt", "punkt_tab", "stopwords", "wordnet"):
try:
nltk.data.find(f"corpora/{resource}")
except LookupError:
try:
nltk.data.find(f"tokenizers/{resource}")
except LookupError:
nltk.download(resource, quiet=True)
_ensure_nltk_data()
_STOPWORDS = set(stopwords.words("english"))
_WORD = re.compile(r"[A-Za-z']+")
# Order matters: this is the column order of the feature matrix.
FEATURE_NAMES = [
"word_count",
"avg_word_length",
"avg_sentence_length",
"sentence_length_std", # burstiness: humans vary sentence length more
"type_token_ratio", # vocabulary richness
"hapax_ratio", # share of words used exactly once
"stopword_ratio",
"uppercase_ratio",
"digit_ratio",
"comma_rate", # the *_rate features are per 100 words
"semicolon_rate",
"quote_rate",
"exclamation_rate",
"question_rate",
"flesch_reading_ease",
"flesch_kincaid_grade",
]
def linguistic_features(text: str) -> dict[str, float]:
"""Return the named linguistic features for one passage."""
text = str(text)
words = _WORD.findall(text.lower())
n_words = len(words)
letters = [c for c in text if c.isalpha()]
if n_words == 0:
return {name: 0.0 for name in FEATURE_NAMES}
sentences = sent_tokenize(text) or [text]
sent_word_counts = [len(_WORD.findall(s)) for s in sentences]
unique = set(words)
counts: dict[str, int] = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
hapax = sum(1 for c in counts.values() if c == 1)
per_100 = 100.0 / n_words
feats = {
"word_count": float(n_words),
"avg_word_length": float(np.mean([len(w) for w in words])),
"avg_sentence_length": float(np.mean(sent_word_counts)),
"sentence_length_std": float(np.std(sent_word_counts)),
"type_token_ratio": len(unique) / n_words,
"hapax_ratio": hapax / n_words,
"stopword_ratio": sum(1 for w in words if w in _STOPWORDS) / n_words,
"uppercase_ratio": (sum(1 for c in letters if c.isupper()) / len(letters)) if letters else 0.0,
"digit_ratio": sum(1 for c in text if c.isdigit()) / len(text) if text else 0.0,
"comma_rate": text.count(",") * per_100,
"semicolon_rate": text.count(";") * per_100,
"quote_rate": (text.count('"') + text.count("'")) * per_100,
"exclamation_rate": text.count("!") * per_100,
"question_rate": text.count("?") * per_100,
"flesch_reading_ease": float(textstat.flesch_reading_ease(text)),
"flesch_kincaid_grade": float(textstat.flesch_kincaid_grade(text)),
}
return feats
def feature_matrix(texts) -> np.ndarray:
"""Stack linguistic features for many passages into a 2-D array."""
rows = [linguistic_features(t) for t in texts]
return np.array([[r[name] for name in FEATURE_NAMES] for r in rows], dtype=float)