"""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)