The Problem
Imagine you are running a free health camp in rural Bihar. If your diagnostic test misses a single Tuberculosis (TB) patient, they go back home, infect their family, and face life-threatening risks; but if your test falsely flags a healthy person as sick, they suffer immense mental trauma and undergo painful, expensive, and unnecessary treatment.
How do you tune your machine learning model to balance these two catastrophic mistakes?
What is precision vs recall in ml? (And Why Should You Care?)
Before we look at any math, let’s stand at the gates of a high-security government building in New Delhi.
Think of your machine learning model as a security guard at the gate:
The Paranoid Guard (High Recall): This guard’s absolute priority is to make sure no unauthorized trespasser slips inside. To achieve this, they search everyone thoroughly, check multiple IDs, and flag almost anyone who looks even slightly suspicious. They catch 100% of the bad guys. But in doing so, they also hold up dozens of genuine, honest employees in long queues, falsely accusing them of being suspicious.
The Easygoing Guard (High Precision): This guard’s absolute priority is to never falsely accuse an honest employee. They only stop someone if they are absolutely, 100% certain that the person is a trespasser. Honest employees breeze through the gate with zero hassle. But because the guard is so cautious about making a false accusation, a few clever trespassers easily slip past the gate.
In Machine Learning:
Precision is your model’s trustworthiness. When it claims someone is a “trespasser” (positive), how often is it actually correct?
Recall is your model’s thoroughness. Out of all the actual “trespassers” in the crowd, how many did the model successfully catch?

Why Simpler Models Fail: The Accuracy Paradox —
Why can’t we just use a simple metric like Accuracy (the percentage of correct predictions)?
Imagine a rare financial fraud scenario in an Indian cooperative bank. Out of 10,000 daily transactions, only 10 are actually fraudulent. If you build an incredibly lazy “dummy” model that simply predicts “No Fraud” for every single transaction, what is its accuracy?
Accuracy = (9,990 correct predictions) / (10,000 total transactions) = 99.9%
This is the Accuracy Paradox. A completely useless model that catches zero fraud shows a spectacular 99.9% accuracy! This is why we need Precision and Recall. They force us to look directly at how we handle the rare, critical events instead of hiding behind a high accuracy score.
Where is precision vs recall in ml Used in Real Life?
Example 1: Loan Default Prediction at a Public Sector Bank
X (Inputs): Customer’s monthly salary, CIBIL score, existing debts, and employment history.
Y (Output): Will they default on their loan? (1 = Yes, 0 = No).
Why it fits: If the bank prioritizes Precision, they only reject loan applications when they are absolutely certain of a default. This keeps genuine customers happy but risks giving loans to bad borrowers, leading to massive Non-Performing Assets (NPAs). If they prioritize Recall, they catch every potential defaulter, but they end up rejecting many honest applicants, losing massive business to competitors.
Example 2: Cyclone Landfall Prediction in Coastal Odisha
X (Inputs): Wind speed, atmospheric pressure, sea surface temperature, and satellite cloud imagery.
Y (Output): Will a severe cyclone make landfall at a specific coastal village? (1 = Yes, 0 = No).
Why it fits: Here, a False Negative (predicting no cyclone when one actually hits) is a human catastrophe—lives will be lost because people weren’t evacuated. A False Positive (evacuating a village when no cyclone hits) is expensive and causes panic, but lives are saved. In this life-and-death scenario, we must tune our model for near-100% Recall, even if it means accepting lower Precision.
Example 3: Assembly Line Defect Detection for EV Batteries in Tamil Nadu
X (Inputs): Thermal imaging scans, battery cell voltage stability, and laser weld measurements.
Y (Output): Is the battery cell defective? (1 = Yes, 0 = No).
Why it fits: If a defective lithium-ion battery slips through the assembly line (False Negative), it could catch fire in a customer’s electric scooter, destroying the brand’s reputation. If a good battery is flagged as defective (False Positive), it is sent to the scrap heap, wasting expensive raw materials. The manufacturer must carefully balance Precision and Recall to protect both customer safety and profit margins.
The Math Behind precision vs recall in ml —
To understand how these metrics are calculated, we must first look at the Confusion Matrix—the scoreboard of our model’s predictions:
| Actual Positive (1) | Actual Negative (0) | |
|---|---|---|
| Predicted Positive (1) | True Positive (TP) | False Positive (FP) |
| Predicted Negative (0) | False Negative (FN) | True Negative (TN) |
Let’s write down the exact mathematical equations for our metrics:
Precision = TP / (TP + FP)
Recall = TP / (TP + FN)
F1 Score = 2 × (Precision × Recall) / (Precision + Recall)
Let’s break down these symbols one by one:
TP (True Positives): The number of times the model correctly predicted the positive class (e.g., correctly identifying a defective battery).
FP (False Positives): The “False Alarms”. The model predicted positive, but the actual ground truth was negative (e.g., flagging a perfectly healthy battery as defective).
FN (False Negatives): The “Misses”. The model predicted negative, but the actual ground truth was positive (e.g., letting a defective battery slip through as healthy).
TN (True Negatives): The number of times the model correctly predicted the negative class (e.g., correctly identifying a healthy battery as healthy).
F1 Score: This is the harmonic mean of Precision and Recall. Why do we use a harmonic mean instead of a simple average?
Imagine a model with a Precision of 1.0 (perfect) and a Recall of 0.0 (it missed everything). A simple average would give us:
(1.0 + 0.0) / 2 = 0.5 (Looks okay on paper, but the model is actually useless!)
The harmonic mean, however, severely penalizes extreme values:
2 × (1.0 × 0.0) / (1.0 + 0.0) = 0.0 (Accurately reflects that the model is useless!)

The Critical Method: Decision-Threshold Tuning and the Tradeoff Curves
How do we actually control the balance between Precision and Recall?
Under the hood, classification algorithms (like Logistic Regression) do not output a direct “1” or “0“. Instead, they output a raw probability score between 0.0 and 1.0 (e.g., “There is an 82% chance this EV battery is defective”). To make a final decision, we apply a Decision Threshold (t):
Predicted Class = 1 if Probability ≥ t, else 0
By default, most machine learning libraries set t = 0.5. But we can tune this threshold to shift our model’s behavior step-by-step:

Step 1: Raising the Threshold (t → 0.9)
We tell the model: “Only flag a battery as defective if you are 90% or more certain.”
The Ripple Effect: The model becomes highly conservative. The number of False Alarms (FP) drops to near zero, causing Precision to shoot up. However, because the bar is so high, the model misses many borderline defective batteries. The number of Misses (FN) climbs, causing Recall to drop.
Step 2: Lowering the Threshold (t → 0.1)
We tell the model: “If there is even a tiny 10% suspicion that a battery is defective, flag it immediately!”
The Ripple Effect: The model becomes highly paranoid. It catches almost every single defective battery, so Misses (FN) drop to near zero, causing Recall to shoot up. However, it now flags many perfectly healthy batteries as defective. False Alarms (FP) skyrocket, causing Precision to plunge.
Step 3: Plotting the Tradeoff Curves
To find the perfect sweet spot, practitioners plot two curves across all possible thresholds from 0.0 to 1.0:
The Precision-Recall (PR) Curve: We plot Precision on the Y-axis and Recall on the X-axis. A perfect model would have a curve that hugs the top-right corner (Area Under Curve, or AUC-PR = 1.0). We choose the threshold point on this curve that matches our business cost tolerance.
The ROC Curve (Receiver Operating Characteristic): We plot the True Positive Rate (Recall) on the Y-axis against the False Positive Rate (FPR = FP / (FP + TN)) on the X-axis. The Area Under the ROC Curve (ROC-AUC) tells us how incredibly good our model is at separating the two classes, regardless of the threshold.
The Cost Function: Minimizing Real-World Consequences
While we train models using mathematical loss functions like Cross-Entropy, we evaluate and tune our final threshold using a Business Cost Function:
Total Cost = (C_FP × FP) + (C_FN × FN)
Where:
C_FP is the financial or human cost of a single False Positive (e.g., the cost of scrapping a good EV battery cell = ₹500).
C_FN is the cost of a single False Negative (e.g., the cost of a battery fire lawsuit and recall = ₹5,00,000).
“Minimizing” this cost function in human terms means finding the exact decision threshold t that results in the lowest possible total rupee loss for the factory.
Parameter Sensitivity Analysis
| Parameter | If it increases (↑) | If it decreases (↓) | Real-world analogy |
| Decision Threshold (t) | Precision increases, Recall decreases. The model becomes highly selective. It makes fewer positive predictions, reducing False Positives but increasing False Negatives. | Recall increases, Precision decreases. The model becomes highly eager. It flags almost everything as positive, reducing False Negatives but increasing False Positives. | A strict college admissions officer who only admits students with perfect scores vs. an officer who admits everyone to fill seats. |
| True Positives (TP) | Both Precision and Recall increase. The model is successfully identifying more actual positive cases without making mistakes. | Both Precision and Recall decrease. The model is failing to identify the actual positive cases, hurting overall performance | A detective who successfully catches more actual criminals based on solid evidence. |
| False Positives (FP) | Precision decreases. The model is generating too many false alarms. The F1 score drops, and the model’s positive predictions can no longer be trusted. | Precision increases. The model’s positive predictions become highly reliable and trustworthy. | A car alarm that goes off every time a stray cat walks past vs. one that only sounds during an actual break-in. |
| False Negatives (FN) | Recall decreases. The model is letting critical positive cases slip through undetected. This leads to dangerous underfitting of the positive class. | Recall increases. The model is successfully capturing almost all positive cases, ensuring high safety and coverage. | An airport security scanner that fails to detect prohibited liquids in bags vs. one that catches every single bottle. |
| True Negatives (TN) | Specificity increases. The model is highly accurate at identifying and clearing the negative class. It has no direct mathematical impact on Precision or Recall, but stabilizes the ROC curve. | Specificity decreases. The model struggles to confidently identify the normal, negative cases. | A doctor who correctly reassures healthy patients that they do not have a disease. |
| F1 Score | Indicates a balanced, robust model. The model has successfully found a sweet spot where both Precision and Recall are high. | Indicates an imbalanced model. The model is severely lacking in either Precision or Recall, making it highly biased or highly volatile. | A well-balanced cricket all-rounder who can both bat and bowl exceptionally well vs. a specialist who can only do one. |
How precision vs recall in ml Actually Works ?
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# Set random seed for reproducibility
np.random.seed(42)
# =====================================================================
# 1. DATA CREATION (EV Battery Defect Detection in Tamil Nadu)
# =====================================================================
# We simulate 80 healthy batteries and 80 defective batteries.
# The model outputs a probability score of a battery being defective.
n_samples_class = 80
# Healthy batteries: centered around 0.30 probability of defect
scores_healthy = np.random.normal(loc=0.30, scale=0.12, size=n_samples_class)
# Defective batteries: centered around 0.70 probability of defect
scores_defective = np.random.normal(loc=0.70, scale=0.12, size=n_samples_class)
# Clip scores to keep them strictly within valid probability bounds [0.01, 0.99]
scores_healthy = np.clip(scores_healthy, 0.01, 0.99)
scores_defective = np.clip(scores_defective, 0.01, 0.99)
# Combine into a single dataset
all_scores = np.concatenate([scores_healthy, scores_defective])
all_labels = np.concatenate([np.zeros(n_samples_class), np.ones(n_samples_class)])
# Add vertical jitter to scatter points so they don’t overlap on a single line
jitter = np.random.uniform(-0.15, 0.15, size=len(all_scores))
# =====================================================================
# 2. PRECOMPUTING THE PRECISION-RECALL CURVE
# =====================================================================
# We sweep the decision threshold from 1.0 down to 0.0
thresholds = np.linspace(1.0, 0.0, 100)
ref_precisions = []
ref_recalls = []
ref_f1s = []
for t in thresholds:
preds = (all_scores >= t).astype(int)
tp = np.sum((preds == 1) & (all_labels == 1))
fp = np.sum((preds == 1) & (all_labels == 0))
fn = np.sum((preds == 0) & (all_labels == 1))
prec = tp / (tp + fp) if (tp + fp) > 0 else 1.0
rec = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * (prec * rec) / (prec + rec) if (prec + rec) > 0 else 0.0
ref_precisions.append(prec)
ref_recalls.append(rec)
ref_f1s.append(f1)
# Find the optimal threshold that maximizes the F1-Score (Convergence/Sweet Spot)
opt_idx = np.argmax(ref_f1s)
opt_thresh = thresholds[opt_idx]
opt_rec = ref_recalls[opt_idx]
opt_prec = ref_precisions[opt_idx]
# =====================================================================
# 3. MATPLOTLIB SETUP
# =====================================================================
fig, (ax_left, ax_right) = plt.subplots(1, 2, figsize=(15, 6.5))
fig.suptitle(“EV Battery Defect Detection (Tamil Nadu Factory)\nPrecision vs. Recall Tradeoff Live”,
fontsize=14, fontweight=’bold’, color=’#2c3e50′)
# Left Plot: Live Classification on Data
ax_left.set_xlim(-0.05, 1.05)
ax_left.set_ylim(-0.25, 0.25)
ax_left.set_xlabel(“Model Predicted Probability of Defect”, fontsize=11, fontweight=’bold’)
ax_left.set_ylabel(“Jittered Samples”, fontsize=11, fontweight=’bold’)
ax_left.axhline(0, color=’gray’, linestyle=’–‘, alpha=0.3)
ax_left.get_yaxis().set_ticks([]) # Hide Y-axis ticks for clean look
# Right Plot: Precision-Recall Curve
ax_right.set_xlim(-0.05, 1.05)
ax_right.set_ylim(-0.05, 1.05)
ax_right.set_xlabel(“Recall (Thoroughness: How many defects caught?)”, fontsize=11, fontweight=’bold’)
ax_right.set_ylabel(“Precision (Trustworthiness: How many flagged are actually defective?)”, fontsize=11, fontweight=’bold’)
ax_right.grid(True, linestyle=’:’, alpha=0.6)
# Plot the background reference curve path
ax_right.plot(ref_recalls, ref_precisions, color=’#bdc3c7′, linestyle=’–‘, linewidth=1.5, label=’Full PR Curve Path’)
# Annotate the optimal F1-Score convergence point
ax_right.annotate(‘Optimal F1 Sweet Spot\n(Balanced Risk)’,
xy=(opt_rec, opt_prec),
xytext=(opt_rec – 0.35, opt_prec – 0.25),
arrowprops=dict(facecolor=’#27ae60′, shrink=0.08, width=1.5, headwidth=8),
fontsize=10, fontweight=’bold’, color=’#27ae60′,
bbox=dict(boxstyle=”round,pad=0.3″, fc=”#e8f8f5″, ec=”#27ae60″, lw=1))
# Dynamic elements to update frame-by-frame
threshold_line = ax_left.axvline(x=1.0, color=’#2c3e50′, linestyle=’-‘, linewidth=2.5, label=’Decision Threshold’)
# Scatter plots for TP, FP, TN, FN
scatter_tp = ax_left.scatter([], [], color=’#e74c3c’, marker=’o’, s=60, edgecolors=’black’, label=’True Positive (Correct Defect)’)
scatter_fp = ax_left.scatter([], [], color=’#f39c12′, marker=’^’, s=60, edgecolors=’black’, label=’False Positive (False Alarm)’)
scatter_tn = ax_left.scatter([], [], color=’#2ecc71′, marker=’o’, s=60, edgecolors=’black’, label=’True Negative (Correct Healthy)’)
scatter_fn = ax_left.scatter([], [], color=’#3498db’, marker=’v’, s=60, edgecolors=’black’, label=’False Negative (Missed Defect)’)
ax_left.legend(loc=’upper left’, fontsize=8.5, framealpha=0.9)
# Right plot dynamic elements
pr_line, = ax_right.plot([], [], color=’#c0392b’, linewidth=3, label=’Current Path’)
current_point, = ax_right.plot([], [], color=’#e74c3c’, marker=’*’, markersize=14, markeredgecolor=’black’, label=’Current Threshold’)
ax_right.legend(loc=’lower left’, fontsize=9.5)
# Text boxes for live metrics
stats_text = ax_left.text(0.02, -0.22, “”, fontsize=10, family=’monospace’,
bbox=dict(boxstyle=”round,pad=0.5″, facecolor=’#f8f9f9′, edgecolor=’#bdc3c7′, alpha=0.9))
metric_text = ax_right.text(0.05, 0.12, “”, fontsize=10, family=’monospace’,
bbox=dict(boxstyle=”round,pad=0.5″, facecolor=’#f8f9f9′, edgecolor=’#bdc3c7′, alpha=0.9))
# =====================================================================
# 4. ANIMATION UPDATE FUNCTION
# =====================================================================
def update(frame):
t = thresholds[frame]
# Predictions at current threshold
preds = (all_scores >= t).astype(int)
# Identify indices for each confusion matrix quadrant
tp_mask = (preds == 1) & (all_labels == 1)
fp_mask = (preds == 1) & (all_labels == 0)
tn_mask = (preds == 0) & (all_labels == 0)
fn_mask = (preds == 0) & (all_labels == 1)
# Update scatter plots safely (handling empty masks)
if np.any(tp_mask):
scatter_tp.set_offsets(np.column_stack((all_scores[tp_mask], jitter[tp_mask])))
else:
scatter_tp.set_offsets(np.empty((0, 2)))
if np.any(fp_mask):
scatter_fp.set_offsets(np.column_stack((all_scores[fp_mask], jitter[fp_mask])))
else:
scatter_fp.set_offsets(np.empty((0, 2)))
if np.any(tn_mask):
scatter_tn.set_offsets(np.column_stack((all_scores[tn_mask], jitter[tn_mask])))
else:
scatter_tn.set_offsets(np.empty((0, 2)))
if np.any(fn_mask):
scatter_fn.set_offsets(np.column_stack((all_scores[fn_mask], jitter[fn_mask])))
else:
scatter_fn.set_offsets(np.empty((0, 2)))
# Update threshold line position
threshold_line.set_xdata([t, t])
# Calculate metrics
tp = np.sum(tp_mask)
fp = np.sum(fp_mask)
tn = np.sum(tn_mask)
fn = np.sum(fn_mask)
precision = ref_precisions[frame]
recall = ref_recalls[frame]
f1 = ref_f1s[frame]
# Update left plot text box
stats_text.set_text(f”CONFUSION MATRIX:\n—————–\nTP: {tp:2d} (Defects Caught)\nFP: {fp:2d} (False Alarms)\nFN: {fn:2d} (Missed Defects)\nTN: {tn:2d} (Healthy Saved)”)
# Update right plot path and current point
pr_line.set_data(ref_recalls[:frame+1], ref_precisions[:frame+1])
current_point.set_data([recall], [precision])
# Update right plot text box
metric_text.set_text(f”Threshold: {t:.2f}\nPrecision: {precision:.2f}\nRecall: {recall:.2f}\nF1-Score: {f1:.2f}”)
# Dynamic title narration based on the threshold phase
if t > 0.75:
ax_left.set_title(f”Threshold = {t:.2f} — Easygoing Guard (High Precision, Low Recall). F1 = {f1:.2f}”,
color=’#d35400′, fontsize=10.5, fontweight=’bold’)
elif 0.35 <= t <= 0.75:
if abs(t – opt_thresh) < 0.03:
ax_left.set_title(f”Threshold = {t:.2f} — Converged! Optimal Balance. F1 = {f1:.2f}”,
color=’#27ae60′, fontsize=10.5, fontweight=’bold’)
else:
ax_left.set_title(f”Threshold = {t:.2f} — Balancing Act… F1 = {f1:.2f}”,
color=’#2980b9′, fontsize=10.5, fontweight=’bold’)
else:
ax_left.set_title(f”Threshold = {t:.2f} — Paranoid Guard (High Recall, Low Precision). F1 = {f1:.2f}”,
color=’#c0392b’, fontsize=10.5, fontweight=’bold’)
return scatter_tp, scatter_fp, scatter_tn, scatter_fn, threshold_line, pr_line, current_point, stats_text, metric_text
# Create the animation
ani = FuncAnimation(fig, update, frames=len(thresholds), interval=120, blit=True)
plt.tight_layout()
# Save the animation as a high-quality GIF
ani.save(‘algo_working_precision_vs_recall_in_ml.gif’, writer=’pillow’, fps=10)
What Happens at the Extremes ?
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# Set random seed for reproducibility
np.random.seed(42)
# =====================================================================
# 1. DATA CREATION (EV Battery Defect Detection in Tamil Nadu)
# =====================================================================
# We simulate 60 healthy batteries and 60 defective batteries.
n_samples = 60
# Generate base random offsets once so points move smoothly across frames
noise_healthy = np.random.normal(0, 0.07, n_samples)
noise_defective = np.random.normal(0, 0.07, n_samples)
# Add vertical jitter to scatter points so they don’t overlap on a single line
jitter_healthy = np.random.uniform(-0.12, 0.12, n_samples)
jitter_defective = np.random.uniform(-0.12, 0.12, n_samples)
jitter = np.concatenate([jitter_healthy, jitter_defective])
labels = np.concatenate([np.zeros(n_samples), np.ones(n_samples)])
# =====================================================================
# 2. MODEL LEARNING SIMULATION (Probability Separation Over Epochs)
# =====================================================================
# Over 80 epochs, the model learns to separate healthy and defective batteries.
# Their predicted probability distributions start highly overlapped and gradually separate.
max_frames = 80
def get_scores(frame):
# alpha goes from 0.0 (fully overlapped) to 1.0 (well separated)
alpha = frame / (max_frames – 1)
# Interpolate means over time
mean_healthy = 0.47 * (1 – alpha) + 0.28 * alpha
mean_defective = 0.53 * (1 – alpha) + 0.72 * alpha
scores_h = mean_healthy + noise_healthy
scores_d = mean_defective + noise_defective
# Keep scores strictly within valid probability bounds [0.01, 0.99]
scores_h = np.clip(scores_h, 0.01, 0.99)
scores_d = np.clip(scores_d, 0.01, 0.99)
return np.concatenate([scores_h, scores_d]), mean_healthy, mean_defective
# =====================================================================
# 3. MATPLOTLIB SETUP (3 Panels Side-by-Side)
# =====================================================================
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(18, 6.5), sharey=True)
# Define the three extreme decision thresholds to compare
thresholds = {
“low”: 0.25, # Too Low -> Paranoid Guard
“mid”: 0.50, # Just Right -> Balanced Sweet Spot
“high”: 0.75 # Too High -> Conservative Guard
}
axes = [ax1, ax2, ax3]
keys = [“low”, “mid”, “high”]
titles = [
“Too Low Threshold (t = 0.25)\n[ High Recall / Low Precision ]”,
“Just Right Threshold (t = 0.50)\n[ Balanced Sweet Spot ]”,
“Too High Threshold (t = 0.75)\n[ High Precision / Low Recall ]”
]
# Keep track of plot elements to update dynamically
scatters_data = []
fill_collections = [[] for _ in range(3)]
x_eval = np.linspace(0, 1, 200)
for i, (ax, key, title) in enumerate(zip(axes, keys, titles)):
ax.set_xlim(-0.05, 1.05)
ax.set_ylim(-0.25, 0.25)
ax.set_xlabel(“Model Predicted Probability of Defect”, fontsize=10, fontweight=’bold’)
ax.get_yaxis().set_visible(False) # Hide Y-axis ticks for clean look
# Draw the static decision threshold line
t_val = thresholds[key]
ax.axvline(t_val, color=’#2c3e50′, linestyle=’-‘, linewidth=2.5)
ax.text(t_val + 0.02, 0.21, f”Threshold = {t_val:.2f}”, color=’#2c3e50′, fontweight=’bold’, fontsize=9)
# Set panel titles with distinct colors
title_color = ‘#c0392b’ if key == ‘low’ else (‘#27ae60’ if key == ‘mid’ else ‘#d35400′)
ax.set_title(title, fontsize=12, fontweight=’bold’, color=title_color)
# Initialize scatter plots for TP, FP, TN, FN
s_tp = ax.scatter([], [], color=’#e74c3c’, marker=’o’, s=45, edgecolors=’black’, label=’TP (Caught Defect)’)
s_fp = ax.scatter([], [], color=’#f39c12′, marker=’^’, s=45, edgecolors=’black’, label=’FP (False Alarm)’)
s_tn = ax.scatter([], [], color=’#2ecc71′, marker=’o’, s=45, edgecolors=’black’, label=’TN (Correct Healthy)’)
s_fn = ax.scatter([], [], color=’#3498db’, marker=’v’, s=45, edgecolors=’black’, label=’FN (Missed Defect)’)
# Text box for live metrics
t_box = ax.text(0.02, -0.23, “”, fontsize=10, family=’monospace’,
bbox=dict(boxstyle=”round,pad=0.4″, facecolor=’#f8f9f9′, edgecolor=’#bdc3c7′, alpha=0.9))
scatters_data.append((s_tp, s_fp, s_tn, s_fn, t_box, t_val))
# Add legend to the first panel only to avoid clutter
ax1.legend(loc=’upper left’, fontsize=8.5, framealpha=0.9)
# =====================================================================
# 4. ANIMATION UPDATE FUNCTION
# =====================================================================
def update(frame):
scores, mean_h, mean_d = get_scores(frame)
artists = []
for i, (s_tp, s_fp, s_tn, s_fn, t_box, t_val) in enumerate(scatters_data):
ax = axes[i]
# Remove old background distribution fills to redraw them smoothly
for coll in fill_collections[i]:
coll.remove()
fill_collections[i] = []
# Compute live probability density functions (PDFs) for visualization
density_h = (1 / (0.07 * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x_eval – mean_h) / 0.07)**2)
density_d = (1 / (0.07 * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x_eval – mean_d) / 0.07)**2)
# Scale densities to fit nicely in the lower portion of the plot
density_h_scaled = (density_h / np.max(density_h)) * 0.12 – 0.24
density_d_scaled = (density_d / np.max(density_d)) * 0.12 – 0.24
# Draw shaded distribution curves
f_h = ax.fill_between(x_eval, -0.24, density_h_scaled, color=’#2ecc71′, alpha=0.12)
f_d = ax.fill_between(x_eval, -0.24, density_d_scaled, color=’#e74c3c’, alpha=0.12)
fill_collections[i].extend([f_h, f_d])
# Classify points based on current threshold
preds = (scores >= t_val).astype(int)
tp_mask = (preds == 1) & (labels == 1)
fp_mask = (preds == 1) & (labels == 0)
tn_mask = (preds == 0) & (labels == 0)
fn_mask = (preds == 0) & (labels == 1)
# Update scatter point positions
if np.any(tp_mask):
s_tp.set_offsets(np.column_stack((scores[tp_mask], jitter[tp_mask])))
else:
s_tp.set_offsets(np.empty((0, 2)))
if np.any(fp_mask):
s_fp.set_offsets(np.column_stack((scores[fp_mask], jitter[fp_mask])))
else:
s_fp.set_offsets(np.empty((0, 2)))
if np.any(tn_mask):
s_tn.set_offsets(np.column_stack((scores[tn_mask], jitter[tn_mask])))
else:
s_tn.set_offsets(np.empty((0, 2)))
if np.any(fn_mask):
s_fn.set_offsets(np.column_stack((scores[fn_mask], jitter[fn_mask])))
else:
s_fn.set_offsets(np.empty((0, 2)))
# Calculate metrics
tp = np.sum(tp_mask)
fp = np.sum(fp_mask)
tn = np.sum(tn_mask)
fn = np.sum(fn_mask)
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0
# Update live metrics text box
t_box.set_text(
f”Precision: {precision:.2f}\n”
f”Recall: {recall:.2f}\n”
f”F1-Score: {f1:.2f}\n”
f”FPs: {fp:2d} | FNs: {fn:2d}”
)
artists.extend([s_tp, s_fp, s_tn, s_fn, t_box, f_h, f_d])
fig.suptitle(f”How Decision Threshold Shapes Performance (Epoch {frame:02d})\n”
f”EV Battery Defect Detection — Tamil Nadu Factory Context”,
fontsize=14, fontweight=’bold’, color=’#2c3e50′)
return artists
# Create the animation
ani = FuncAnimation(fig, update, frames=max_frames, interval=150, blit=True)
plt.tight_layout()
# Save the animation as a high-quality GIF
ani.save(‘hyperparameter_edge_precision_vs_recall_in_ml.gif’, writer=’pillow’, fps=8)
Hyperparameter Extremes
What You Will See in This GIF:
Panel 1: Too Low Threshold (t = 0.25) — “The Paranoid Guard” — Start (Epoch 0): The threshold line is set very far to the left. Almost all points are classified as defective. We catch almost all defects (high Recall), but we generate a massive number of False Alarms (low Precision). Middle (Epoch 40): As the distributions begin to separate, some healthy points slide to the left of the threshold and turn green (TN). However, because the threshold is so low, many healthy points are still trapped on the right side, remaining as orange triangles (FP). End (Epoch 80): Even with a highly trained model, this threshold is too eager. It maintains a perfect Recall of 1.00 (zero missed defects), but its Precision is bottlenecked at around 0.70 because it continues to flag healthy batteries as defective.
Panel 2: Just Right Threshold (t = 0.50) — “The Balanced Sweet Spot” — Start (Epoch 0): Because the distributions are heavily overlapped, the model makes many mistakes on both sides. FPs and FNs are both high, resulting in a mediocre F1-Score. Middle (Epoch 40): As the model learns, the green points slide left and the red points slide right. The number of orange triangles (FP) and blue triangles (FN) drops rapidly. End (Epoch 80): The model achieves a beautiful, balanced state. Both Precision and Recall reach high values (≈ 0.95), maximizing the F1-Score. This represents the optimal operational threshold for standard factory conditions.
Panel 3: Too High Threshold (t = 0.75) — “The Conservative Guard” — Start (Epoch 0): The threshold line is set very far to the right. Almost all points are classified as healthy. Precision is high because the few points flagged as defective are indeed defective, but Recall is near zero because we miss almost all actual defects. Middle (Epoch 40): As the defective distribution slides to the right, some defective points cross the threshold and turn red (TP). However, many borderline defective points remain on the left as blue triangles (FN). End (Epoch 80): Even when fully trained, this threshold is too strict. It achieves a perfect Precision of 1.00 (zero false alarms), but its Recall is severely limited (≈ 0.65) because it lets many actual defective batteries slip through the line undetected.
Which Model Actually Wins Here?
