AI Detector
Trained a custom Logistic Regression classifier from scratch on ~300k samples with 29 handcrafted NLP features to detect AI-generated text zero ML framework dependencies at inference.

~300k Dataset Construction and Resumable Feature Extraction
Training a robust binary classifier on AI-generated vs. human-written text requires a large, balanced dataset small datasets overfit to specific generators or writing styles. Extracting 29 NLP features across 300,000 texts is computationally expensive and can take hours on a single GPU. If the process crashes at row 150,000, restarting from zero wastes half a day of GPU time.
Assembled a ~299,000-row dataset (150,000 AI-generated, 149,128 human-written) split across three CSV files. Built a resumable feature extraction pipeline in train.py: it writes features to a CSV incrementally (flushing after every 100 rows), reads back the existing output file on restart to determine the start index, and resumes extraction from where it left off. The 29 features are used to train the model on a 95/5 stratified split with a fixed random seed (42) for reproducibility.
if os.path.exists(WORKING_FEATURES_CSV):
done_df = pd.read_csv(WORKING_FEATURES_CSV)
start_index = len(done_df)
print(f"Resuming from row {start_index}...")
mode = 'a' if start_index > 0 else 'w'
with open(WORKING_FEATURES_CSV, mode) as f:
if start_index == 0:
header = ["label"] + [f"f_{i}" for i in range(29)]
f.write(",".join(header) + "\n")
for i in range(start_index, total_rows):
feats = features(df["text"].iloc[i])
row_data = [str(df["label"].iloc[i])] + [str(x) for x in feats]
f.write(",".join(row_data) + "\n")
f.flush()RECORD_END_RE = re.compile(r',([01])(?=\r?\n|$)')
def robust_load_csv(path, encoding="utf-8"):
with open(path, "r", encoding=encoding, errors="replace") as f:
raw = f.read()
records = []
pos = 0
for m in RECORD_END_RE.finditer(body):
chunk = body[pos:m.end()]
pos = m.end()
label = chunk[-1]
text_part = chunk[:-2].strip()
if text_part.startswith('"') and text_part.endswith('"'):
text_part = text_part[1:-1].replace('""', '"')
records.append((text_part.strip(), int(label)))
return pd.DataFrame(records, columns=["text", "label"])A resumable pipeline was necessary because the full extraction run takes 6-8 hours on a Kaggle GPU. Kaggle kernels have a 12-hour time limit, and connectivity interruptions are common. The incremental-write design means every 100 rows are persisted to disk, so the maximum wasted work on a crash is 100 rows, not 150,000. The robust_load_csv function handles malformed rows (quoted text containing commas, empty records, encoding errors) with a regex-based parser that finds record boundaries by matching the trailing label character ([01]) at the end of each CSV line, rather than relying on a naive split-by-comma that would break on quoted text containing commas.
299,128 rows extracted and persisted without data loss across multiple Kaggle sessions. The 95/5 split gives ~284,000 training samples and ~15,000 test samples large enough to detect overfitting and validate generalization. Balanced class weights (w_neg ≈ 1.00, w_pos ≈ 1.00 after normalization) prevent the model from defaulting to the majority class. Fixed random seed ensures reproducible splits across training runs.
29-Feature NLP Engineering Pipeline
Most "AI detectors" are thin wrappers around third-party APIs, giving users no visibility into what signals are being used or how the classification works. Building an interpretable classifier requires extracting meaningful features from raw text signals that capture the structural, lexical, and discourse-level patterns that distinguish AI-generated text from human writing, without relying on a pre-trained language model at inference time.
Designed a 29-feature extraction pipeline in Python covering four feature families: (1) lexical diversity Shannon entropy of the word frequency distribution, MATTR (Moving Average Type-Token Ratio over 20-word windows), lexical density, and top-word frequency ratio; (2) syntactic rule-based POS ratios for verbs, nouns, adjectives, and adverbs plus a passive voice ratio computed by detecting auxiliary + past-participle patterns; (3) structural average sentence length (words and characters), sentence length standard deviation, coefficient of variation, comma ratio, total punctuation ratio, and Yule's K for vocabulary richness; (4) discourse bigram and trigram repetition ratios, top-trigram concentration, stopword ratio, Flesch Reading Ease readability score, and a structural parallelism ratio that fingerprints sentences by their opening word and length bucket. A separate cosine-similarity coherence feature measures mean pairwise cosine similarity between adjacent sentence bag-of-words vectors, capturing the even tone and repetitive phrasing typical of AI text.
def features(text):
sentences = _split_sentences(text)
words = _words(text)
words_lower = [w.lower() for w in words]
f_shannon_entropy = float(-sum(p * math.log2(p)
for p in [c/len(words) for c in Counter(words_lower).values()]))
f_lexical_density = _safe_div(
sum(1 for w in words_lower if w not in STOPWORDS), len(words))
f_yules_k = _yules_k(words)
f_mattr = _mattr(words)
return np.array([
f_shannon_entropy, f_lexical_density,
f_top_word_freq_ratio, f_yules_k, f_mattr,
...
], dtype=float)Lexical Diversity: Shannon Entropy, Lexical Density, MATTR, Top Word Freq Ratio
Syntactic: Verb/Noun/Adj/Adv Ratios, Passive Voice Ratio, Conjunction Ratio
Structural: Avg Sent Length, StdDev, CV, Comma/Punct Ratio, Yule's K
Discourse: Bigram/Trigram Repetition, Coherence (cosine), Parallelism, FleschHandcrafted features were chosen over transformer embeddings for three reasons: interpretability (each feature maps to a known linguistic property, so false positives can be diagnosed), portability (29 floats require no GPU, no model weights, no runtime dependency the same feature extractor runs in Python during training and TypeScript at inference), and speed (feature extraction for a 25,000-character text completes in under 50ms). The tradeoff is that handcrafted features cannot capture deep semantic patterns that a transformer would the system relies on structural and distributional signals rather than meaning.
29 features that are fully self-documenting, portable to any language, and compute in O(n) over the input text. Each feature directly corresponds to a known linguistic property of AI-generated text, making the model's decisions auditable the system does not "ask another AI" to detect AI.
From-Scratch Logistic Regression (No ML Framework)
The classifier needs to produce a portable model file that can run anywhere no scikit-learn, no TensorFlow.js, no ONNX runtime. The model needs z-score normalization (mean/std computed during training), a weighted sigmoid, and class-weighted binary cross-entropy loss, all implemented from scratch. The inference path must be a simple dot product plus sigmoid, with no graph construction, no session management, and no external model server.
Implemented a complete LogisticRegressionScratch class in Python with z-score scaler, balanced class weight resolution, L2 regularization, and gradient descent trained for 3,000 epochs at lr=0.1 with l2_lambda=0.05 on a 95/5 train/test split. The export function serializes weights (w), bias (b), training mean (mean), and training std (std) into a single JSON file. The inference code mirrors the training exactly: (x - mean) / std, then dot product with w, then bias addition, then sigmoid with clipping at ±500 to prevent overflow.
model = LogisticRegressionScratch(n_features=29)
model.fit(
X_train, y_train,
epochs=3000, lr=0.1,
l2_lambda=0.05,
class_weight="balanced",
)
model.export_weights("model.json")export function predictProba(features: number[], model: ModelWeights): number {
let z = model.b;
for (let i = 0; i < features.length; i++) {
let std = model.std[i];
if (std === 0) std = 1.0;
const scaledFeature = (features[i] - model.mean[i]) / std;
z += model.w[i] * scaledFeature;
}
return sigmoid(z);
}A from-scratch implementation was necessary because scikit-learn's LogisticRegression uses a different regularization convention (C parameter vs. lambda), a different solver (LBFGS vs. vanilla gradient descent), and different default class weight calculations using the Python model directly would not produce identical results. The z-score normalization parameters must be serialized alongside the weights so that inference applies the exact same transformation as the training pipeline. Exporting a raw weight vector plus normalization stats is the simplest possible inference code path: 29 multiplications, 29 additions, one sigmoid no branching, no loops with conditionals, no memory allocation beyond the feature array.
| Approach | Why rejected |
|---|---|
| From-scratch Logistic Regression | Zero dependencies. Inference is a dot product + sigmoid. Serialization is 4 JSON arrays. No ML runtime needed. |
| scikit-learn model export (joblib/pickle) | Requires Python pickle deserialization. Not portable to non-Python environments. |
| TensorFlow.js / ONNX Runtime | Adds ~2-5MB runtime dependency. Increases complexity. Overkill for a linear model with 29 inputs. |
Inference is a pure arithmetic function: 29 multiplications, 29 additions, one bias addition, one sigmoid call. No external library, no model server, no cold-start penalty. The model.json file is under 5KB. The TypeScript predictProba function is 15 lines of code with zero branches beyond the sigmoid clipping.
Zero-Dependency TypeScript Feature Port (Python → Inference Parity)
The Python feature extraction pipeline uses string.punctuation (a stdlib constant), numpy for mean/std, sklearn for CountVectorizer and cosine similarity, and the wordfreq library for Zipf frequency tables. None of these exist in a lightweight TypeScript runtime. A naive port would miss subtle differences in sentence splitting, syllable counting, or Yule's K rounding that would cause the inference features to disagree with the training features, silently degrading accuracy.
Built a zero-dependency TypeScript feature extractor (~250 lines) that mirrors the Python pipeline exactly: the same regex for sentence splitting (lookbehind for [.!?] followed by whitespace + uppercase), the same vowel-counting syllable counter with trailing-e adjustment, the same Yule's K formula using raw frequency counts, the same MATTR sliding-window computation. Cosine similarity between adjacent sentences was reimplemented as a manual dot-product-over-norms function using a sparse vocabulary index, replacing sklearn's CountVectorizer. The sentenceSimilarityFeatures function builds a vocab index from all sentences, constructs dense count vectors, and computes pairwise cosine similarity matching the sklearn output for identical inputs. Feature output was verified to match between Python and TypeScript for the same input text.
function sentenceSimilarityFeatures(sentences: string[]): [number, number] {
if (sentences.length < 2) return [0, 0];
const vocabIndex = new Map<string, number>();
for (const s of sentences) {
for (const w of words(s)) {
const wl = w.toLowerCase();
if (!vocabIndex.has(wl)) vocabIndex.set(wl, vocabIndex.size);
}
}
const dense: number[][] = sentences.map((s) => {
const vec = new Array(vocabIndex.size).fill(0);
for (const t of words(s).map((w) => w.toLowerCase())) {
const idx = vocabIndex.get(t);
if (idx !== undefined) vec[idx] += 1;
}
return vec;
});
const sims: number[] = [];
for (let i = 0; i < dense.length - 1; i++) {
sims.push(cosine(dense[i], dense[i + 1]));
}
return [mean(sims), std(sims)];
}def _sentence_similarity_features(sentences):
if len(sentences) < 2:
return 0.0, 0.0
vec = CountVectorizer().fit_transform(sentences)
sims = []
arr = vec.toarray()
for i in range(len(sentences) - 1):
a, b = arr[i:i+1], arr[i+1:i+2]
if a.sum() == 0 or b.sum() == 0:
sims.append(0.0)
else:
sims.append(float(cosine_similarity(a, b)[0, 0]))
return _mean(sims), _std(sims)An alternative was to ship the Python feature code via Pyodide (Python-in-WASM), but this adds ~10MB to the deployment bundle and adds 500ms+ to cold start unacceptable for a latency-sensitive API. The manual TypeScript port was tedious but produces identical results with zero runtime overhead. The cosine similarity function avoids constructing a full dense matrix (which would be O(n² × vocab_size) in memory) by computing similarity between consecutive sentence pairs only, which is O(n × vocab_size) sufficient because AI text typically shows high similarity between adjacent sentences rather than distant ones.
The TypeScript feature extractor runs with zero npm dependencies. All 29 features are computed in a single pass over the text (with the exception of the sentence-pair cosine similarity pass). Feature values match the Python training pipeline exactly for the same input, eliminating train-serve skew. The extractor compiles to a single file with no dynamic imports.
Key Technical Achievements
~300k Balanced Training Set
Assembled and cleaned a ~299,000-row dataset (150k AI-generated, 149k human-written) with resumable incremental feature extraction that survived multiple Kaggle session interruptions without data loss.
29-Feature Interpretable Pipeline
Every classification signal is a named, auditable NLP feature Shannon entropy, Yule's K, MATTR, Flesch readability, POS ratios, n-gram repetition, cosine coherence not a black-box embedding. The model does not "ask another AI" to detect AI.
Train-Serve Feature Parity
A zero-dependency TypeScript feature extractor mirrors the Python training pipeline exactly same regex, same syllable counter, same Yule's K formula verified to produce identical outputs, eliminating the silent accuracy degradation of train-serve skew.
From-Scratch LR Model
Logistic Regression implemented from scratch with balanced class weights and L2 regularization: inference is 29 multiplications, 29 additions, one bias, one sigmoid. No ML runtime. Model weights serialize to a 5KB JSON file.
Resumable Kaggle Pipeline
Incremental-write feature extraction that flushes every 100 rows and resumes from the last checkpoint, surviving Kaggle's 12-hour kernel limits and connectivity interruptions across ~300k rows.
Balanced Class Weights
Implemented balanced class weight resolution in the from-scratch Logistic Regression to handle the near-equal 150k/149k class split, ensuring neither class dominates during training.
Zero-Framework Inference
The entire inference pipeline feature extraction, z-score normalization, dot product, sigmoid runs in TypeScript with zero npm dependencies. Model weights are a 5KB JSON file loadable anywhere.