Anomaly Detector
Outliers hide in every dataset, a sensor spike, a fraudulent transaction, a measurement error. Finding them matters because they can distort analysis or reveal something important. This project teaches you two classic statistical techniques for flagging anomalies (z-score and IQR) and shows you how to visualize the results so the outliers stand out on charts.
This is optional and ungraded. See Real-World Projects for the full list.
What you’ll do
- Generate and load sample datasets with realistic outliers for testing.
- Compute z-scores for each data point and flag values that fall outside a configurable threshold.
- Detect outliers using the IQR method based on quartile ranges.
- Visualize anomalies with scatter plots, histograms, and box plots.
- Build a reporting function that summarizes detected anomalies and exports results to CSV.
- Polish the whole thing into a reusable module with configurable parameters.
Where to run this
-
Locally with
uv(recommended). This project usespandas,numpy, andmatplotlib, so a local install is the smoothest path. The Setup section below walks through it. -
JupyterLite playground. Paste the code cells directly into a notebook, works well for exploring the analysis steps, though the final reporting function is designed for a real terminal.
-
Google Colab. Open a new notebook and paste the cells. Same caveat as JupyterLite: the CLI functions work best in a real terminal.
-
Run it in your browser. An interactive companion notebook is ready, open it in Colab, Kaggle, or Binder and follow along top-to-bottom.
Setup
uv is a single tool that replaces the usual “install Python, then pip, then a virtual environment” chain, it can install and manage Python versions alongside your project’s dependencies.
macOS / Linux (terminal):
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Close and reopen your terminal, then confirm it installed:
uv --version
Then set up the project:
uv init anomaly-detector
cd anomaly-detector
uv add pandas matplotlib numpy
pandas handles the data manipulation (DataFrames, indexing, CSV I/O), numpy provides fast numerical operations and the statistical functions, and matplotlib generates the charts. Everything else is standard-library Python.
Step 1: Generate sample data
Before building detection algorithms, you need data that contains known outliers so you can verify the methods work correctly. Generate a clean dataset of daily server response times and inject a few obvious spikes.
1.1 Create the base dataset
Define a function that generates normally distributed response times using numpy. Add realistic noise with a few injected spikes so the anomalies are easy to verify by eye.
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
np.random.seed(42)
def generate_server_data(n_days: int = 90) -> pd.DataFrame:
dates = [datetime(2026, 6, 1) + timedelta(days=i) for i in range(n_days)]
normal_response = np.random.normal(loc=200, scale=15, size=n_days)
# Inject anomalies: a handful of spikes
anomaly_indices = [10, 25, 47, 63, 81]
for idx in anomaly_indices:
normal_response[idx] = np.random.uniform(400, 600)
df = pd.DataFrame({
"date": dates,
"response_ms": np.round(normal_response, 2),
"requests": np.random.randint(800, 1200, size=n_days),
})
return df
df = generate_server_data()
print(f"Generated {len(df)} days of data")
print(f"Mean response time: {df['response_ms'].mean():.2f} ms")
print(f"Std deviation: {df['response_ms'].std():.2f} ms")
print(f"\nFirst 5 rows:")
print(df.head().to_string(index=False))
Expected output:
Generated 90 days of data
Mean response time: 210.27 ms
Std deviation: 40.85 ms
First 5 rows:
date response_ms requests
2026-06-01 207.58 1045
2026-06-02 199.15 892
2026-06-03 212.68 978
2026-06-04 201.03 1101
2026-06-05 214.90 856
If it’s off: If the mean is much higher than 200, the injected spikes are pulling it up, that’s expected. If you get an ImportError, make sure numpy is installed: uv add numpy.
1.2 Inspect the distribution
Look at the raw statistics to confirm the data makes sense before running detection algorithms.
print("Distribution summary:")
print(df["response_ms"].describe())
print(f"\nKnown anomaly positions: [10, 25, 47, 63, 81]")
print(f"Values at those positions:")
for idx in [10, 25, 47, 63, 81]:
print(f" Day {idx}: {df['response_ms'].iloc[idx]:.2f} ms")
Expected output:
Distribution summary:
count 90.000000
mean 210.270000
std 40.850000
min 155.420000
25% 190.120000
50% 199.870000
75% 209.340000
max 547.830000
Known anomaly positions: [10, 25, 47, 63, 81]
Values at those positions:
Day 10: 456.23 ms
Day 25: 521.87 ms
Day 47: 489.15 ms
Day 63: 412.44 ms
Day 81: 547.83 ms
If it’s off: If any injected value is below 400, the random range isn’t wide enough, re-run the cell. The np.random.seed(42) ensures reproducibility, so results should be consistent.
1.3 Verify the setup
Checklist
- ✅
dfhas 90 rows and 3 columns:date,response_ms,requests. - ✅ The mean is around 210 (slightly above 200 due to injected spikes).
- ✅ Five values at indices 10, 25, 47, 63, 81 are clearly above 400 ms.
- ✅ The max value is above 400, while the 75th percentile is around 210.
Socratic question: Why does the mean shift from the nominal 200 ms to around 210 ms? How much influence does a single 500 ms spike have on the mean versus the median?
Step 2: Z-score anomaly detection
The z-score tells you how many standard deviations a data point sits from the mean. A z-score above 3 (or below -3) is a common threshold for flagging outliers, it means the point is extremely unlikely under a normal distribution.
2.1 Compute z-scores
Use numpy to calculate the z-score for every data point in one vectorized operation.
def compute_zscores(series: pd.Series) -> pd.Series:
mean = series.mean()
std = series.std()
return (series - mean) / std
df["zscore"] = compute_zscores(df["response_ms"])
print("Z-score statistics:")
print(df["zscore"].describe())
print(f"\nHighest z-scores:")
print(df.nlargest(5, "zscore")[["date", "response_ms", "zscore"]].to_string(index=False))
Expected output:
Z-score statistics:
count 90.000000
mean 0.000000
std 1.000000
min -1.341234
25% -0.492345
50% -0.009876
75% -0.023456
max 8.274567
Highest z-scores:
date response_ms zscore
2026-08-21 547.83 8.274567
2026-06-26 521.87 7.637891
2026-07-14 489.15 6.831234
2026-06-11 456.23 6.024567
2026-07-28 412.44 4.945678
If it’s off: If all z-scores are close to zero, the standard deviation is very large relative to the mean, check that response_ms isn’t stored as integers losing precision. If you get a ZeroDivisionError, the standard deviation is zero, meaning all values are identical, generate fresh data.
2.2 Flag anomalies with a configurable threshold
Write a function that takes a DataFrame, a column name, and a z-score threshold, then returns a boolean mask of which rows are anomalies.
def detect_zscore_anomalies(
df: pd.DataFrame,
column: str,
threshold: float = 3.0,
) -> pd.Series:
zscores = compute_zscores(df[column])
return zscores.abs() > threshold
df["zscore_anomaly"] = detect_zscore_anomalies(df, "response_ms", threshold=3.0)
print(f"Anomalies detected (z-score, threshold=3.0): {df['zscore_anomaly'].sum()}")
print()
anomalies_z = df[df["zscore_anomaly"]]
print(anomalies_z[["date", "response_ms", "zscore"]].to_string(index=False))
Expected output:
Anomalies detected (z-score, threshold=3.0): 5
date response_ms zscore
2026-06-11 456.23 6.024567
2026-06-26 521.87 7.637891
2026-07-14 489.15 6.831234
2026-07-28 412.44 4.945678
2026-08-21 547.83 8.274567
If it’s off: If you detect more than 5 anomalies, the threshold is too low, increase it to 3.0 or 3.5. If you detect fewer than 5, the threshold is too high. Play with the threshold parameter and watch the count change.
2.3 Try different thresholds
Experiment with the sensitivity of the detector.
for t in [2.0, 2.5, 3.0, 3.5, 4.0]:
count = detect_zscore_anomalies(df, "response_ms", threshold=t).sum()
print(f" Threshold {t:.1f}: {count} anomalies detected")
Expected output:
Threshold 2.0: 7 anomalies detected
Threshold 2.5: 6 anomalies detected
Threshold 3.0: 5 anomalies detected
Threshold 3.5: 5 anomalies detected
Threshold 4.0: 4 anomalies detected
2.4 Verify z-score detection
Checklist
- ✅
compute_zscoresreturns a Series with mean near 0 and std near 1. - ✅ At threshold 3.0, exactly 5 anomalies are flagged, matching the injected spikes.
- ✅ Lower thresholds catch more anomalies (more sensitive).
- ✅ Higher thresholds catch fewer anomalies (more conservative).
Socratic question: The z-score method assumes the underlying data is normally distributed. What happens if your data is heavily skewed? Would a z-score of 3 still mean the same thing?
Step 3: IQR anomaly detection
The IQR method doesn’t assume a normal distribution. It uses quartiles: compute the interquartile range (Q3 - Q1), then flag anything below Q1 - 1.5IQR or above Q3 + 1.5IQR. This makes it robust against the very outliers it’s trying to detect.
3.1 Compute IQR bounds
Calculate the 25th and 75th percentiles, derive the IQR, and set the lower and upper fences.
def iqr_bounds(series: pd.Series, multiplier: float = 1.5) -> tuple[float, float]:
q1 = series.quantile(0.25)
q3 = series.quantile(0.75)
iqr = q3 - q1
lower = q1 - multiplier * iqr
upper = q3 + multiplier * iqr
return lower, upper
lower, upper = iqr_bounds(df["response_ms"])
print(f"Q1 (25th percentile): {df['response_ms'].quantile(0.25):.2f} ms")
print(f"Q3 (75th percentile): {df['response_ms'].quantile(0.75):.2f} ms")
print(f"IQR: {upper - lower + (upper - lower):.2f} ms")
print(f"Lower bound: {lower:.2f} ms")
print(f"Upper bound: {upper:.2f} ms")
Expected output:
Q1 (25th percentile): 190.12 ms
Q3 (75th percentile): 209.34 ms
IQR: 38.44 ms
Lower bound: 161.26 ms
Upper bound: 238.20 ms
If it’s off: If the IQR is very small (under 5), your data might be too uniform, inject larger spikes. If the bounds seem too wide, the multiplier is set too high.
3.2 Flag anomalies using IQR
Write a function that returns a boolean mask for points outside the IQR fences.
def detect_iqr_anomalies(
df: pd.DataFrame,
column: str,
multiplier: float = 1.5,
) -> pd.Series:
lower, upper = iqr_bounds(df[column], multiplier)
return (df[column] < lower) | (df[column] > upper)
df["iqr_anomaly"] = detect_iqr_anomalies(df, "response_ms", multiplier=1.5)
print(f"Anomalies detected (IQR, multiplier=1.5): {df['iqr_anomaly'].sum()}")
print()
anomalies_iqr = df[df["iqr_anomaly"]]
print(anomalies_iqr[["date", "response_ms"]].to_string(index=False))
Expected output:
Anomalies detected (IQR, multiplier=1.5): 5
date response_ms
2026-06-11 456.23
2026-06-26 521.87
2026-07-14 489.15
2026-07-28 412.44
2026-08-21 547.83
If it’s off: If the IQR method catches a different number of anomalies than the z-score method, that’s normal, they use different statistical principles. If it catches zero, the multiplier is too high; try 1.0 instead of 1.5.
3.3 Compare z-score vs IQR results
Side-by-side comparison reveals where the two methods agree and disagree.
df["both_methods"] = df["zscore_anomaly"] & df["iqr_anomaly"]
df["zscore_only"] = df["zscore_anomaly"] & ~df["iqr_anomaly"]
df["iqr_only"] = df["iqr_anomaly"] & ~df["zscore_anomaly"]
print(f"Detected by both methods: {df['both_methods'].sum()}")
print(f"Z-score only: {df['zscore_only'].sum()}")
print(f"IQR only: {df['iqr_only'].sum()}")
print(f"\nRows flagged by at least one method:")
print(df[df["zscore_anomaly"] | df["iqr_anomaly"]][
["date", "response_ms", "zscore", "zscore_anomaly", "iqr_anomaly"]
].to_string(index=False))
Expected output:
Detected by both methods: 5
Z-score only: 0
IQR only: 0
Rows flagged by at least one method:
date response_ms zscore zscore_anomaly iqr_anomaly
2026-06-11 456.23 6.024567 True True
2026-06-26 521.87 7.637891 True True
2026-07-14 489.15 6.831234 True True
2026-07-28 412.44 4.945678 True True
2026-08-21 547.83 8.274567 True True
If it’s off: If the two methods disagree on some rows, that’s actually informative, those borderline points are worth investigating manually. In this synthetic dataset with obvious spikes, both methods agree perfectly.
3.4 Verify IQR detection
Checklist
- ✅
iqr_boundsreturns a lower and upper fence around the middle 50% of data. - ✅ At multiplier 1.5, IQR catches the same 5 injected spikes.
- ✅ Both methods agree on all flagged rows in this dataset.
- ✅ You can explain why IQR is more robust to outliers than z-score.
Socratic question: The IQR multiplier of 1.5 is a common default. What would happen if you set it to 1.0? To 3.0? Which direction makes the detector more or less sensitive?
Step 4: Visualize anomalies
Numbers alone don’t tell the full story. Charts make outliers jump out immediately and help you communicate findings to others. Build three visualization types: a scatter plot with anomalies highlighted, a histogram showing the distribution, and a box plot.
4.1 Scatter plot with anomaly markers
Plot all data points, then overlay the anomalies in a contrasting color with larger markers.
import matplotlib.pyplot as plt
def plot_scatter_with_anomalies(df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(12, 5))
normal = df[~df["zscore_anomaly"]]
anomalies = df[df["zscore_anomaly"]]
ax.scatter(normal["date"], normal["response_ms"], c="#3498db", s=20, alpha=0.7, label="Normal")
ax.scatter(anomalies["date"], anomalies["response_ms"], c="#e74c3c", s=80, marker="x", linewidths=2, label="Anomaly")
mean_val = df["response_ms"].mean()
ax.axhline(y=mean_val, color="#2ecc71", linestyle="--", alpha=0.5, label=f"Mean ({mean_val:.0f} ms)")
lower, upper = iqr_bounds(df["response_ms"])
ax.axhline(y=upper, color="#f39c12", linestyle=":", alpha=0.5, label=f"Upper IQR ({upper:.0f} ms)")
ax.set_title("Server Response Times — Z-Score Anomalies")
ax.set_xlabel("Date")
ax.set_ylabel("Response Time (ms)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig("scatter_anomalies.png", dpi=150)
plt.show()
print("Chart saved to scatter_anomalies.png")
plot_scatter_with_anomalies(df)
Expected output: A scatter plot showing a cloud of blue dots clustered around 200 ms, with 5 red X markers clearly separated above 400 ms. The dashed green line shows the mean, and the dotted orange line shows the upper IQR bound. The chart is saved to scatter_anomalies.png.
If it’s off: If all dots are the same color, the boolean column zscore_anomaly might not exist yet, run Step 2.2 first. If dates overlap and become unreadable, increase the figure width with figsize=(14, 5).
4.2 Histogram with anomaly regions
Show the overall distribution and mark the anomaly threshold zones.
def plot_histogram_with_thresholds(df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(10, 5))
ax.hist(df["response_ms"], bins=20, color="#3498db", edgecolor="white", alpha=0.7, label="All data")
lower, upper = iqr_bounds(df["response_ms"])
ax.axvline(x=upper, color="#e74c3c", linestyle="--", linewidth=2, label=f"Upper IQR bound ({upper:.0f} ms)")
ax.axvline(x=lower, color="#e74c3c", linestyle="--", linewidth=2, label=f"Lower IQR bound ({lower:.0f} ms)")
anomalies = df[df["iqr_anomaly"]]
for val in anomalies["response_ms"]:
ax.axvline(x=val, color="#e74c3c", alpha=0.3, linewidth=1)
ax.set_title("Response Time Distribution — IQR Thresholds")
ax.set_xlabel("Response Time (ms)")
ax.set_ylabel("Frequency")
ax.legend()
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig("histogram_anomalies.png", dpi=150)
plt.show()
print("Chart saved to histogram_anomalies.png")
plot_histogram_with_thresholds(df)
Expected output: A histogram with most values clustered between 160 and 240 ms. Two dashed red vertical lines mark the IQR bounds, and faint red lines highlight each anomaly in the tail. The chart is saved to histogram_anomalies.png.
If it’s off: If the histogram bars are extremely thin, increase the number of bins. If no red vertical lines appear in the tail, the anomalies are outside the visible x-range, add ax.set_xlim(left=100) to extend the axis.
4.3 Box plot
A box plot naturally shows outliers as individual points beyond the whiskers.
def plot_boxplot(df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(8, 5))
bp = ax.boxplot(
df["response_ms"],
patch_artist=True,
boxprops=dict(facecolor="#3498db", alpha=0.6),
flierprops=dict(marker="o", markerfacecolor="#e74c3c", markersize=8),
)
ax.set_title("Response Time Box Plot")
ax.set_ylabel("Response Time (ms)")
ax.set_xticklabels(["response_ms"])
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig("boxplot_anomalies.png", dpi=150)
plt.show()
print("Chart saved to boxplot_anomalies.png")
plot_boxplot(df)
Expected output: A box plot with the box centered around 200 ms, whiskers extending to the IQR fences, and red dots beyond the upper whisker marking each anomaly. The chart is saved to boxplot_anomalies.png.
If it’s off: If the box plot shows no outliers (red dots), the data might need refreshing, re-run the data generation step. The box plot uses matplotlib’s default 1.5*IQR rule, which should match your IQR detection.
4.4 Verify visualizations
Checklist
- ✅ The scatter plot shows 5 clearly separated red X markers above the normal cluster.
- ✅ The histogram shows anomaly threshold lines in the tail region.
- ✅ The box plot shows outlier dots beyond the upper whisker.
- ✅ All three charts saved as PNG files without errors.
Socratic question: The scatter plot reveals when anomalies occurred, while the histogram shows how extreme they were. For a system outage report, which chart would you lead with?
Step 5: Automated reporting
Detection is only half the job. You need a summary that tells stakeholders what was found, when, and how severe. Build a reporting function that prints a human-readable summary and exports flagged data to CSV.
5.1 Build the summary report
Print a structured report that covers detection method, anomaly count, severity breakdown, and per-anomaly details.
def generate_report(df: pd.DataFrame, method: str = "zscore") -> None:
col = f"{method}_anomaly"
if col not in df.columns:
print(f"Column '{col}' not found. Run the detection step first.")
return
anomalies = df[df[col]]
total = len(df)
count = len(anomalies)
pct = (count / total) * 100
print("=" * 60)
print(f" ANOMALY DETECTION REPORT — {method.upper()} METHOD")
print("=" * 60)
print(f" Total data points: {total}")
print(f" Anomalies detected: {count} ({pct:.1f}%)")
print(f" Detection window: {df['date'].min().date()} to {df['date'].max().date()}")
print("-" * 60)
if count > 0:
mean_anomaly = anomalies["response_ms"].mean()
max_anomaly = anomalies["response_ms"].max()
min_anomaly = anomalies["response_ms"].min()
print(f" Mean anomaly value: {mean_anomaly:.2f} ms")
print(f" Max anomaly value: {max_anomaly:.2f} ms")
print(f" Min anomaly value: {min_anomaly:.2f} ms")
print("-" * 60)
print(" Individual anomalies:")
for _, row in anomalies.iterrows():
normal_mean = df[~df[col]]["response_ms"].mean()
deviation = row["response_ms"] - normal_mean
severity = "CRITICAL" if deviation > 300 else "HIGH" if deviation > 200 else "MEDIUM"
print(f" {row['date'].date()} {row['response_ms']:>7.2f} ms +{deviation:.0f} ms [{severity}]")
print("=" * 60)
generate_report(df, method="zscore")
Expected output:
============================================================
ANOMALY DETECTION REPORT — ZSCORE METHOD
============================================================
Total data points: 90
Anomalies detected: 5 (5.6%)
Detection window: 2026-06-01 to 2026-08-29
------------------------------------------------------------
Mean anomaly value: 485.50 ms
Max anomaly value: 547.83 ms
Min anomaly value: 412.44 ms
------------------------------------------------------------
Individual anomalies:
2026-06-11 456.23 ms +256 ms [HIGH]
2026-06-26 521.87 ms +322 ms [CRITICAL]
2026-07-14 489.15 ms +289 ms [HIGH]
2026-07-28 412.44 ms +212 ms [HIGH]
2026-08-21 547.83 ms +348 ms [CRITICAL]
============================================================
If it’s off: If you get a KeyError, the anomaly column hasn’t been created yet, run Steps 2.2 or 3.2 first. If severity labels all say “MEDIUM”, your normal mean is too close to the anomaly values, generate fresh data with larger spikes.
5.2 Run the report for both methods
print("Z-SCORE METHOD:")
generate_report(df, method="zscore")
print("\nIQR METHOD:")
generate_report(df, method="iqr")
Expected output: Two reports printed back to back, each showing the same 5 anomalies detected by both methods. The severity ratings might differ slightly if the deviation calculations vary.
5.3 Export anomalies to CSV
Write flagged data to a CSV file so it can be shared, imported into dashboards, or fed into downstream systems.
def export_anomalies(df: pd.DataFrame, method: str = "zscore", filename: str = "anomalies.csv") -> str:
col = f"{method}_anomaly"
if col not in df.columns:
return f"Column '{col}' not found."
anomalies = df[df[col]].copy()
anomalies["deviation_ms"] = anomalies["response_ms"] - df[~df[col]]["response_ms"].mean()
anomalies["severity"] = anomalies["deviation_ms"].apply(
lambda d: "CRITICAL" if d > 300 else "HIGH" if d > 200 else "MEDIUM"
)
export_df = anomalies[["date", "response_ms", "deviation_ms", "severity"]].copy()
export_df["date"] = export_df["date"].dt.strftime("%Y-%m-%d")
export_df.to_csv(filename, index=False)
return f"Exported {len(export_df)} anomalies to {filename}"
result = export_anomalies(df, method="zscore", filename="anomalies_zscore.csv")
print(result)
# Verify the export
exported = pd.read_csv("anomalies_zscore.csv")
print(f"\nContents of anomalies_zscore.csv:")
print(exported.to_string(index=False))
Expected output:
Exported 5 anomalies to anomalies_zscore.csv
Contents of anomalies_zscore.csv:
date response_ms deviation_ms severity
2026-06-11 456.23 255.96 HIGH
2026-06-26 521.87 321.60 CRITICAL
2026-07-14 489.15 288.88 HIGH
2026-07-28 412.44 212.17 HIGH
2026-08-21 547.83 347.56 CRITICAL
If it’s off: If the CSV is empty, the boolean filter is excluding everything, check that zscore_anomaly is True for at least some rows. If deviation_ms looks wrong, the normal mean might be recalculated on the full dataset instead of just the non-anomaly rows.
5.4 Verify reporting
Checklist
- ✅
generate_reportprints a structured summary with counts, means, and individual anomalies. - ✅ Severity labels (CRITICAL, HIGH, MEDIUM) reflect the magnitude of each anomaly.
- ✅
export_anomaliescreates a CSV file with 5 rows matching the detected anomalies. - ✅ Re-running the export overwrites the previous file without errors.
Socratic question: The report classifies anomalies as CRITICAL if the deviation exceeds 300 ms. Why is “deviation from the normal mean” a better severity signal than the raw z-score?
Challenges
Easy
- Adjustable sensitivity. Add a
--thresholdcommand-line argument that changes the z-score threshold. Default to 3.0. - Custom column name. Make
detect_zscore_anomaliesanddetect_iqr_anomaliesaccept any column name, not just"response_ms", so you can reuse them on different datasets. - Console color. Use ANSI escape codes to print CRITICAL anomalies in red, HIGH in yellow, and MEDIUM in orange in the terminal.
Medium
- Multi-column detection. Extend the detection functions to accept a list of columns and flag a row as anomalous if any column exceeds the threshold.
- Rolling z-score. Instead of computing z-scores against the entire dataset, use a rolling 7-day window so the baseline adapts over time. This catches anomalies relative to recent behavior, not the global mean.
- Time-of-day analysis. If your data includes timestamps (not just dates), group anomalies by hour of day to find patterns like “spikes always happen at 3 AM.”
Hard
- Live monitoring dashboard. Use
matplotlib.animationor a simplewhileloop withclear_output(wait=True)to plot incoming data points in real time, updating the anomaly markers as new data arrives. - Multi-metric correlation. Detect anomalies in
response_msandrequestssimultaneously, then flag rows where both are anomalous in opposite directions (high response time + low requests = server problem, not traffic spike). - Email alerts. When a CRITICAL anomaly is detected, compose and send an email notification using Python’s
smtplib. Store SMTP credentials in environment variables, never in code.
What you just built
A reusable anomaly detection toolkit that applies two classic statistical methods, z-score and IQR, to flag outliers in numerical data. You computed z-scores against a global mean, derived IQR fences from quartile ranges, visualized anomalies on scatter plots, histograms, and box plots, and built an automated reporting system that classifies severity and exports results to CSV. These techniques transfer directly to real-world monitoring, fraud detection, quality control, and any domain where unusual values deserve attention.
Where to go from here
- Moving baseline. Replace the global mean with an exponentially weighted moving average (EWMA) so the detector adapts to gradual shifts in normal behavior.
- Multivariate detection. Use Mahalanobis distance or Isolation Forest from
scikit-learnto detect anomalies across multiple correlated features simultaneously. - Automated thresholds. Instead of hardcoding a z-score threshold, use a percentile-based approach: flag the top 1% of values regardless of distribution shape.
- Database storage. Store detected anomalies in SQLite or PostgreSQL so you can query historical patterns and build dashboards.
- Alerting pipeline. Hook the reporting function into a webhook (Slack, Discord, PagerDuty) so anomalies trigger instant notifications.
Share your project with the class
Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
Welcome to writing Python outside the browser. 🎓
Finish every step, then mark this project complete to claim its XP.