Technical Walkthrough — 21 slides

OmniFeedback AI

Enterprise feedback ingestion, escalation triage, and a GenAI resolution copilot — built with the overfitting fix made visible, live, instead of just claimed.

Python 3.11 PyTorch BiLSTM HuggingFace Transformers Streamlit SQLite Star Schema

Live demo →   ·   GitHub repository →

The Hook

One model looked perfect.
That was the problem.

V1 · Naive generator — K-Means silhouette 1.000 Memorized 9 unique training sentences. Meaningless.
V2 · Hardened generator — K-Means silhouette 0.328 212 unique sentences, real ambiguity. Honest.

Same pipeline. Same code. The only difference is the training data — and the app lets you flip between them and watch every downstream metric change, live.

Problem & Business Context

Feedback arrives everywhere. Crises hide in the noise.

Support tickets, app reviews, tweets, call notes — thousands of records a day, across channels that don't talk to each other. A billing outage or crash spike can sit unnoticed for hours.

Crisis Escalation

Detect urgency spikes in real time instead of discovering an outage from angry tweets.

Roadmap Prioritization

Cluster unstructured feedback into operational themes without hand-labeling.

Resolution Drafting

Cut manual ticket-handling time with a structured, reviewable AI-drafted reply.

Architecture

Six stages, one warehouse at the center

Raw feedback (CSV)
→
Regex TextIngestor
→
SQLite Star Schema (ACID)
TF-IDF + K-Means
Kneedle elbow k-selection
PyTorch BiLSTM
urgency regression, 0–1
BERT NER + BART
entities + summarization
↓ all feed into ↓
GenAI Resolution Copilot
→
Streamlit Dashboard (V1/V2/V3 toggle on every page)
Data Ingestion & Warehouse

A real star schema, not a flat CSV

Regex cleaning strips HTML, URLs, mentions, and repeated characters before anything touches the database. Every batch insert runs inside one transaction:

BEGIN → validate → INSERT × N → COMMIT
any constraint violation → full ROLLBACK, verified by a dedicated pytest case
Fact_Feedback
feedback_id, user_id→Dim_User, channel_id→Dim_Channel, aspect_id→Dim_Product, urgency_score CHECK(0–1)
Dim_User
user_id
Dim_Channel
channel_id, channel_name UNIQUE
Dim_Product
aspect_id, aspect_category UNIQUE
Classical ML

Choosing K without cheating

Naively maximizing silhouette picks whichever k is largest on templated text — it never plateaus. Replaced argmax(silhouette) with the Kneedle max-distance-from-chord elbow method.

Selected k (V2 Hardened)9
Silhouette at k=90.328
Baseline classifiers (macro-F1)
Logistic Regression0.914
Random Forest0.912
Deep Learning

A BiLSTM that's told when to stop

Embedding (dim 128)
Bidirectional LSTM (64 units/direction)
Dropout (p=0.3)
Linear + Sigmoid → urgency ∈ [0,1]
  • Fixed-seed train / val / test split, done once before training
  • Early stopping on validation MSE (patience = 4)
  • Adam optimizer, L2 weight decay 1e-5
  • Metrics reported on the held-out test set, never validation
Test MSE (V2)0.016
Stopped at epoch6
The Headline Feature

V1 (naive) vs. V2 (hardened) — side by side

MetricV1 NaiveV2 Hardened
Unique training sentences9212
K-Means silhouette (best k)1.0000.328
Baseline classifier macro-F11.0000.914
BiLSTM test macro-F11.0000.821
BiLSTM test MSE0.01370.0162
V1's perfect scores are the red flag, not the win — it memorized 9 sentences. Every viewer can verify this themselves on the app's Model Comparison page.
V3 · A Second Bug, Found Live

A friend testing the app found what 27 passing tests couldn't

Same sentence, scored MEDIUM under V2
↓ switch model version, nothing else ↓
Correctly scored HIGH / CRITICAL under V3

V2 scored an obviously-critical complaint ("crashes every time I check out, I've switched to a competitor") almost identically to an obviously-positive one. Tests check code correctness — not whether a model actually generalizes to language it never saw in training.

Root Cause, Measured

196 words. That's V2's entire vocabulary.

Out-of-vocabulary rate: template-matching text vs. real-world phrasing.

V2's BiLSTM learns its own embedding table from scratch — its vocabulary is exactly 196 words, because the synthetic training templates are narrow. Tokenizing genuinely natural sentences showed 33–63% of words mapping to <UNK>. With over half a sentence invisible, the model has almost nothing to work with — output collapses toward the training mean regardless of true severity.

The Fix

Don't learn English from scratch — fine-tune what already knows it

Same lesson from an unrelated image-classification project: fine-tuning a slice of a pretrained model beat training a CNN from scratch by a wide margin. Applied here: fine-tune a pretrained DistilBERT instead of training a BiLSTM's vocabulary from 4,000 rows.

Subword tokenization means no closed-vocabulary problem at all — any English word decomposes into known pieces.
L0 🔒
L1 🔒
L2 🔒
L3 🔒
L4 🔓
L5 🔓
+ Regression Head
21.4%
of 66.4M parameters trainable
The Proof

13× larger separation. Zero additional training data.

Same synthetic test set — MSEV2: 0.0162 · V3: 0.0160
V2 critical−low separation (real sentences)0.022
V3 critical−low separation (same sentences)0.298

The standard test-set metrics look nearly identical — that's expected, since that test set is drawn from the same narrow templates both models trained on. The real difference only shows up on text neither model saw verbatim, which is exactly the point.

Shipping It

The checkpoint was too big for GitHub

265MB saved (full model state dict) — exceeds GitHub's 100MB hard limit
↓
56MB — only the fine-tuned subset (last 2 layers + head); frozen backbone re-downloaded from HuggingFace at load time

~79% of that 265MB was the frozen, unmodified DistilBERT backbone — identical to what AutoModel.from_pretrained() already fetches fresh. Rather than reach for Git LFS, saved only the parameters that actually changed and reconstructed the rest at load time.

Verified byte-for-byte identical predictions before and after the change — a storage fix, not a retrain.
Honest Boundary

Severe ≠ CRITICAL — and that's worth saying plainly

Tested ~20 real sentences directly against V3 to find what actually crosses the 0.8 CRITICAL threshold. Two patterns reliably do:

0.860"deleted all data... fix immediately... unacceptable"
0.808"unauthorized access... password changed... financial risk"
A genuinely severe complaint outside those two patterns — e.g. "production down, losing revenue every minute" — tops out around 0.69–0.79 (HIGH), not CRITICAL. The threshold is calibrated to specific training patterns, not a general "how bad does this sound" scale. Expected behavior for a model trained on a particular distribution — not a bug — but worth stating plainly rather than letting a viewer assume otherwise.
Transformer NLP

Entity extraction & executive summarization

BERT NER — dslim/bert-base-NER (433MB, swapped down from bert-large for memory)

"The iPhone 15 crashed after the iOS 17.2 update, and Apple support in California hasn't responded."

iPhone 15 · MISC · .99 iOS 17.2 · MISC · .94 Apple · ORG · .99 California · LOC · .99
Why MISC? CoNLL-2003 (the training scheme behind this model, and every small CoNLL-trained NER model) has only 4 tags — PER/ORG/LOC/MISC — with no PRODUCT tag, so product/software names fall into the catch-all by design. Checked OntoNotes alternatives with a PRODUCT tag: the small ones weren't reliably available, the reliable one is ~1.7GB. Not worth the memory regression — documented as a known scheme limitation instead. See PROGRESS.md Milestone 16.
DistilBART — sshleifer/distilbart-cnn-12-6 (fallback) · Claude (synthesis)

5 distinct crisis reports (crash, billing, shipping, outage, latency) condensed to one brief in ~5s (cached weights).

BART-family models are span-compression models trained on news prose — on short, keyword-dense tickets they read as near-verbatim extraction, not real synthesis. Added a pluggable Claude backend with a synthesis-oriented prompt for genuine cross-ticket theming, honestly labeled per-summary which backend produced it. See PROGRESS.md Milestone 15.
GenAI Copilot

Chain-of-Thought, structured JSON, zero required API keys

{
  "thought_process": "...",
  "root_cause": "...",
  "risk_level": "CRITICAL|HIGH|MEDIUM|LOW",
  "recommended_action": "...",
  "customer_reply_draft": "..."
}

Few-shot examples + Chain-of-Thought system prompt. Backend is pluggable:

No API key → deterministic local rules (free, always works)
↕ auto-switches
ANTHROPIC_API_KEY set → Claude drafts the response
Data Insights I

Confusion matrix & threshold sensitivity

Macro-F1 @ 0.5 threshold0.826
Best threshold found (sweep)0.48 → F1 0.829
Pred vs. true urgency correlation0.869

0.3-pt gain from tuning means 0.5 was already close to optimal — no hidden accuracy left on the table.

Data Insights II

Most errors are boundary cases, not failures

False negatives

Almost entirely the deliberately-ambiguous "neutral" template class (urgency 0.35–0.6) — the model lands on the wrong side of a boundary the dataset was built to contain, not a comprehension failure.

An honest limitation

Channel vs. urgency independence: χ²=4.18, p=0.840. The synthetic generator assigns channel independent of content — a real gap, documented and fixed in the roadmap, not hidden.

QA & Business Value

Verified end to end, caveats stated up front

FindingSeverityStatus
Naive generator made the pipeline overfit-proneHighFixed
Docker healthcheck silently broken (missing curl)MediumFixed
Cloud deploy Python-version mismatch blocked launchHighFixed
Channel/aspect carry no signal in synthetic dataMediumOpen
Playground & Copilot don't share pasted textLowOpen
Enhancement Roadmap

What's next, ranked by cost vs. leverage

Free, do first

Correlate channel/aspect with template category; validate on real Yelp/Sentiment140 data; share state across pages.

Cheap paid APIs

OpenAI embeddings for semantic clustering; Pinecone for similar-ticket RAG; Claude API already wired in.

Research bets

Fine-tuned DistilBERT vs. BiLSTM comparison; active learning on boundary cases; drift monitoring.

Production Incidents

Free-tier hosting found two real limits, not two hypothetical ones

IncidentRoot causeFix
"Gone over its resource limits" (crash)4 transformer models loaded at once (bert-large-NER 1.33GB + bart-large-cnn 1.63GB + DistilBERT V3) ≈ 2.96GB, measured via the HuggingFace file-metadata API, not guessedFixed — swapped to bert-base-NER + distilbart-cnn-12-6, cutting the combined footprint to 1.66GB, verified output quality held at both toy and production batch scale
"Your app has been throttled" (CPU)Streamlit Community Cloud's free-tier CPU cap under sustained transformer inference load, with no GPUDocumented, not code-fixed — expected behavior at this hosting tier; the real fix is a paid inference endpoint (see Enhancement Roadmap)
Both diagnosed from real Streamlit Cloud logs and screenshots, not assumption — the memory fix was verified against measured model file sizes before any code changed. See PROGRESS.md Milestone 14.
Closing

Built, tested, deployed — every real bug found in production, fixed or honestly documented

31 passing tests · V1→V2→V3 model evolution, live and toggleable · live Streamlit deployment · Dockerfile verified · full build log in PROGRESS.md · data insights, product review, and roadmap in /docs.

scikit-learnSciPyPlotly pytestDocker

Live demo →   ·   GitHub →

Next: blog series, research paper, Twitter/LinkedIn threads.