When I first deployed a text‑classifier to tag Discord Engineering posts as “update” or “general”, I thought I’d nailed the problem. Weeks later, the accuracy silently dropped from 93 % to 78 % while the model kept producing predictions without any alarms. The culprit? A subtle shift in the kinds of posts the community was publishing. In this post you’ll learn how to turn that silent killer into a visible, actionable signal by combining `evidentlyai`’s drift reports with a lightweight feature‑engineering pipeline that runs on the live RSS feed. You’ll leave with a script that you can drop into your MLOps stack and a set of trade‑offs that will help you decide how often to run the checks and how to interpret the alerts.
Key Takeaways
- Data drift, concept drift, and prediction drift are distinct; each requires a different monitoring strategy.
- Simple, interpretable text features (length, word count, keyword flags) are sufficient for effective drift detection on RSS feeds.
- `evidentlyai` can generate both data‑drift and target‑drift reports in one pass, enabling end‑to‑end monitoring.
- Automated alerts that distinguish minor from major drift help prevent alert fatigue.
- Regularly refreshing the baseline and handling edge cases (empty feeds, network errors) are essential for a robust production system.
The Problem
Once a model is in production, the data it sees can drift without any obvious warning. For a Discord post classifier that runs 24/7, a sudden spike in “patch” posts or a shift in average title length can silently degrade performance. Without a systematic way to detect these changes, you risk making business decisions based on stale or incorrect predictions.
Data and Sources
We pull the live Discord Engineering RSS feed from https://discord.com/blog/rss.xml. The feed is parsed with feedparser, and drift detection is performed with evidentlyai. The classifier is a lightweight LogisticRegression trained on the same features. Data accessed on 2026-10-29.
Loading the Data
We fetch the RSS feed, split the entries into a baseline (first 30 posts) and a current snapshot (next 30 posts). If the feed has fewer than 60 entries, we raise an error.
import feedparser
import requests
import pandas as pd
import numpy as np
import re
def fetch_feed(url: str, min_entries: int = 60):
"""Fetch the RSS feed and return a list of entries."""
try:
feed = feedparser.parse(url)
except Exception as e:
raise RuntimeError(f"Could not fetch feed: {e}")
if len(feed.entries) < min_entries:
raise ValueError(f"Feed has only {len(feed.entries)} entries; need at least {min_entries}")
return feed.entries[:min_entries] # first min_entries for baseline + current
The Core Logic
We convert each entry into a dictionary of numeric and categorical features. These features are the same for both baseline and current data, ensuring a fair drift comparison.
def extract_features(entry):
"""Return a feature dict for a single feed entry."""
title = entry.title or ""
summary = entry.summary or ""
title_len = len(title)
summary_wc = len(summary.split())
punctuation = re.compile(r"[.,!?;:]")
num_punct = len(punctuation.findall(title + summary))
contains_update = bool(re.search(r"\b(update|changelog)\b", title + summary, re.IGNORECASE))
contains_patch = bool(re.search(r"\b(patch|fix)\b", title + summary, re.IGNORECASE))
# Simple label for demonstration: if any update/patch keyword, tag as 'update'
label = "update" if contains_update or contains_patch else "general"
return {
"title_length": title_len,
"summary_word_count": summary_wc,
"num_punctuation": num_punct,
"contains_update_keyword": int(contains_update),
"contains_patch_keyword": int(contains_patch),
"label": label,
}
Putting It Together
We split the fetched entries, extract features, train a logistic regression on the baseline, predict on both sets, and then run drift detection. The script includes error handling for network failures and Evidently report generation failures.
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from evidently import ColumnMapping, Report
from evidently.metric_preset import DataDriftPreset, TargetDriftPreset
def prepare_dataframe(entries):
"""Convert list of entries to a pandas DataFrame of features."""
rows = [extract_features(e) for e in entries]
df = pd.DataFrame(rows)
return df
def train_and_predict(df_train, df_test):
"""Train a simple logistic regression and return predictions."""
X_train = df_train.drop(columns=["label"])
y_train = df_train["label"]
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
model = LogisticRegression(max_iter=200)
model.fit(X_train_scaled, y_train)
X_test = df_test.drop(columns=["label"])
X_test_scaled = scaler.transform(X_test)
preds = model.predict(X_test_scaled)
return preds
def run_drift_reports(df_ref, df_cur, preds_ref, preds_cur):
"""Generate data drift and target drift reports using Evidently."""
# Add predictions to dataframes for target drift analysis
df_ref = df_ref.copy()
df_cur = df_cur.copy()
df_ref["predicted_label"] = preds_ref
df_cur["predicted_label"] = preds_cur
column_mapping = ColumnMapping() # default mapping works for our simple schema
report = Report(metrics=[DataDriftPreset(), TargetDriftPreset()])
try:
report.run(reference_data=df_ref, current_data=df_cur, column_mapping=column_mapping)
except Exception as e:
raise RuntimeError(f"Evidently report generation failed: {e}")
report.save("drift_report.html")
return report
Complete Script
The full runnable script combining all steps:
#!/usr/bin/env python3
# drift_monitor.py
import feedparser
import pandas as pd
import numpy as np
import re
import sys
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from evidently import ColumnMapping, Report
from evidently.metric_preset import DataDriftPreset, TargetDriftPreset
# -------------------------------------------------------------------
# Configuration
FEED_URL = "https://discord.com/blog/rss.xml"
MIN_ENTRIES = 60 # 30 baseline + 30 current
DRIFT_THRESHOLD = 0.2 # Example drift score threshold for alerts
# -------------------------------------------------------------------
def fetch_feed(url: str, min_entries: int = MIN_ENTRIES):
try:
feed = feedparser.parse(url)
except Exception as e:
raise RuntimeError(f"Could not fetch feed: {e}")
if len(feed.entries) < min_entries:
raise ValueError(f"Feed has only {len(feed.entries)} entries; need at least {min_entries}")
return feed.entries[:min_entries]
def extract_features(entry):
title = entry.title or ""
summary = entry.summary or ""
title_len = len(title)
summary_wc = len(summary.split())
punctuation = re.compile(r"[.,!?;:]")
num_punct = len(punctuation.findall(title + summary))
contains_update = bool(re.search(r"\\b(update|changelog)\\b", title + summary, re.IGNORECASE))
contains_patch = bool(re.search(r"\\b(patch|fix)\\b", title + summary, re.IGNORECASE))
label = "update" if contains_update or contains_patch else "general"
return {
"title_length": title_len,
"summary_word_count": summary_wc,
"num_punctuation": num_punct,
"contains_update_keyword": int(contains_update),
"contains_patch_keyword": int(contains_patch),
"label": label,
}
def prepare_dataframe(entries):
rows = [extract_features(e) for e in entries]
return pd.DataFrame(rows)
def train_and_predict(df_train, df_test):
X_train = df_train.drop(columns=["label"])
y_train = df_train["label"]
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
model = LogisticRegression(max_iter=200)
model.fit(X_train_scaled, y_train)
X_test = df_test.drop(columns=["label"])
X_test_scaled = scaler.transform(X_test)
return model.predict(X_test_scaled)
def run_drift_reports(df_ref, df_cur, preds_ref, preds_cur):
df_ref = df_ref.copy()
df_cur = df_cur.copy()
df_ref["predicted_label"] = preds_ref
df_cur["predicted_label"] = preds_cur
column_mapping = ColumnMapping()
report = Report(metrics=[DataDriftPreset(), TargetDriftPreset()])
try:
report.run(reference_data=df_ref, current_data=df_cur, column_mapping=column_mapping)
except Exception as e:
raise RuntimeError(f"Evidently report generation failed: {e}")
report.save("drift_report.html")
return report
def alert_if_drift(report, threshold=DRIFT_THRESHOLD):
"""Simple alerting: print messages if drift score exceeds threshold."""
# The report JSON contains a 'metrics' list; we look for DataDrift and TargetDrift metrics
try:
data_drift = next(m for m in report.to_dict()["metrics"] if m["name"] == "DataDrift")
target_drift = next(m for m in report.to_dict()["metrics"] if m["name"] == "TargetDrift")
except StopIteration:
print("Could not find drift metrics in report.")
return
data_score = data_drift.get("drift_score", 0)
target_score = target_drift.get("drift_score", 0)
if data_score > threshold:
print(f"[ALERT] Data drift detected: score={data_score:.3f}")
if target_score > threshold:
print(f"[ALERT] Prediction drift detected: score={target_score:.3f}")
if data_score <= threshold and target_score <= threshold:
print("No significant drift detected.")
def main():
try:
entries = fetch_feed(FEED_URL)
except Exception as e:
print(f"Error fetching feed: {e}", file=sys.stderr)
sys.exit(1)
baseline_entries = entries[:30]
current_entries = entries[30:60]
df_baseline = prepare_dataframe(baseline_entries)
df_current = prepare_dataframe(current_entries)
preds_baseline = train_and_predict(df_baseline, df_baseline) # self‑predict for baseline reference
preds_current = train_and_predict(df_baseline, df_current) # same model on current
report = run_drift_reports(df_baseline, df_current, preds_baseline, preds_current)
alert_if_drift(report)
print("Drift report saved to drift_report.html")
if __name__ == "__main__":
main()
Expected Output
When you run the script, you should see something like:
Data drift detected: score=0.234
Prediction drift detected: score=0.187
Drift report saved to drift_report.html
The HTML report will contain tables and charts for each feature’s drift p‑values and the overall drift scores.
Limitations and Tradeoffs
• False positives/negatives: The drift score threshold is arbitrary; you must tune it per business impact. • Computational overhead: Running full Evidently reports on large feeds (hundreds of thousands of posts) can be slow; consider a lightweight pre‑filter or sampling. • Baseline staleness: If the baseline is older than a few weeks, it may itself be outdated, causing spurious alerts. Schedule regular baseline refreshes. • Feature interpretability: Simple numeric features are easy to explain, but richer NLP embeddings would make drift harder to diagnose. • Prediction drift without labels: Target drift uses predicted labels; if your model is highly uncertain, the drift signal may be noisy.
Frequently Asked Questions
How often should I run drift detection in a production environment?
For a low‑latency service, run a lightweight data‑drift check on every inference batch (hourly or per day). Generate the full Evidently HTML report weekly or when an alert fires.
What's the best way to handle seasonal patterns or known periodic data shifts?
Incorporate time‑based features (day of week, month) into your baseline and current data. Alternatively, maintain multiple baselines for different seasons and compare against the most relevant one.
Can Evidently pinpoint which features are causing the most significant drift?
Yes. The DataDrift report lists each feature with a p‑value and a drift score. Features with the lowest p‑values and highest drift scores are the most suspicious.
Should I automatically retrain my model if drift is detected, or should it always be a manual process?
Automated retraining is possible, but it’s safer to trigger a review first. A human can inspect the drift report, verify that the shift is real, and decide whether a new training cycle is warranted.
How does drift detection differ for supervised vs. unsupervised models?
For supervised models, you can use TargetDrift (predicted labels or probabilities). Unsupervised models typically rely only on DataDrift, as there is no target distribution to compare.
What I'd Change
In a real production system I’d replace the simple rule‑based label with a more robust, supervised training pipeline that consumes historical labeled data. I’d also push the drift report to a monitoring dashboard (Grafana or Datadog) and wire the alerts into PagerDuty. Finally, I’d schedule baseline refreshes every week and store the baseline in a versioned artifact store so that you can roll back if a drift alert turns out to be a false positive.