Shipping a new ML model to production is not like shipping a UI feature. If a button breaks, users complain and you roll back. If your recommendation model degrades, you might not notice for days — by then, revenue has bled. Feature flags give you the control plane you need: route traffic, compare models side by side, and roll back in seconds without a redeploy.
This article walks through a production-grade implementation of feature flags for ML model A/B testing in Python, using a simple but extensible pattern.
Why Feature Flags, Not Blue/Green Deploys
Blue/green deploys solve infrastructure rollout. Feature flags solve logical rollout — they let you separate "code is deployed" from "code is active." For ML, this distinction matters for several reasons:
- You want to run model A and model B simultaneously on the same infrastructure
- You want to split traffic by user segment, not just percentage
- You want to roll back a model without touching your deployment pipeline
A feature flag for ML A/B testing typically needs three things: a consistent assignment function (same user → same model), percentage-based traffic splits, and observable metadata per request.
The Core Assignment Function
Start with a flag definition and a deterministic variant assignment:
from dataclasses import dataclass
from typing import Optional
import hashlib
@dataclass
class ModelFlag:
name: str
control_model: str # "model_a"
treatment_model: str # "model_b"
traffic_split: float # 0.0 to 1.0 → fraction sent to treatment
enabled: bool = True
segment_key: Optional[str] = None # restrict to a user segment
def assign_variant(flag: ModelFlag, user_id: str, segment: Optional[str] = None) -> str:
"""Deterministic: same user_id always gets the same variant for this flag."""
if not flag.enabled:
return flag.control_model
if flag.segment_key and segment != flag.segment_key:
return flag.control_model
# Hash user_id + flag name: consistent and flag-specific
raw = f"{flag.name}:{user_id}".encode()
bucket = int(hashlib.md5(raw).hexdigest(), 16) % 1000
threshold = int(flag.traffic_split * 1000)
return flag.treatment_model if bucket < threshold else flag.control_model
The hashlib.md5 trick is intentional: it is fast, produces a uniform distribution, and makes assignment stable across restarts. Do not use random.random() — it breaks consistency between requests.
Loading Flags at Runtime Without a Redeploy
Hard-coding flags defeats the purpose. You want to change splits without redeploying. The simplest production-ready approach: a JSON config file (or S3 object) polled on a background thread.
import json
import threading
import time
from pathlib import Path
class FlagStore:
def __init__(self, config_path: str, poll_interval: int = 30):
self._path = Path(config_path)
self._flags: dict[str, ModelFlag] = {}
self._lock = threading.RLock()
self._load()
self._start_polling(poll_interval)
def _load(self):
try:
raw = json.loads(self._path.read_text())
with self._lock:
self._flags = {
name: ModelFlag(**cfg)
for name, cfg in raw.items()
}
except Exception as e:
print(f"[FlagStore] reload failed: {e}") # keep stale config, do not crash
def _start_polling(self, interval: int):
def loop():
while True:
time.sleep(interval)
self._load()
t = threading.Thread(target=loop, daemon=True)
t.start()
def get(self, flag_name: str) -> Optional[ModelFlag]:
with self._lock:
return self._flags.get(flag_name)
The RLock matters: the GIL does not protect dict mutations during _load. The daemon=True thread dies cleanly with the process.
Your flags.json looks like:
{
"recommendation_v2": {
"name": "recommendation_v2",
"control_model": "rec_model_v1",
"treatment_model": "rec_model_v2",
"traffic_split": 0.1,
"enabled": true
}
}
Start at 10%, watch metrics, ramp to 50%, then 100%. To kill the experiment: set enabled: false or traffic_split: 0.0 and reload.
Integrating With Your Inference Layer
The flag system is useless without routing. Assuming you have a model registry:
class ModelRouter:
def __init__(self, flag_store: FlagStore, registry: dict):
self.flags = flag_store
self.registry = registry # {"model_name": model_instance}
def predict(self, flag_name: str, user_id: str, payload: dict,
segment: str = None) -> dict:
flag = self.flags.get(flag_name)
if flag is None:
raise ValueError(f"Unknown flag: {flag_name}")
assigned_model = assign_variant(flag, user_id, segment)
model = self.registry[assigned_model]
result = model.predict(payload)
# Attach observability metadata — your analytics pipeline needs this
return {
"prediction": result,
"_meta": {
"flag": flag_name,
"model": assigned_model,
"user_id": user_id,
"variant": "treatment" if assigned_model == flag.treatment_model else "control",
}
}
Log _meta to your data warehouse on every request. Without it, you cannot compute experiment results after the fact.
What to Measure and When to Stop
A/B testing a model without a stopping rule is wishful thinking. Before you start the experiment, commit to these in writing:
- Primary metric: what does "better" mean? Click-through rate, conversion, latency p99, error rate?
- Minimum detectable effect: what delta is worth shipping? Do not stop at the first statistically significant fluctuation.
-
Sample size: run a power analysis (
statsmodels.stats.power) upfront to know how many users you need before the result is reliable.
A common trap: checking significance daily and stopping when p < 0.05. This inflates false positive rates. Either pre-commit to a fixed runtime, or use sequential testing (Bayesian or SPRT) if early stopping is a hard requirement.
The free security and deployment checklists we publish include an ML deployment checklist that covers experiment hygiene alongside infrastructure hardening — the two concerns are more entangled than they look.
Operational Concerns That Tutorials Skip
Carryover effects: if users experience model B today and you roll back to A tomorrow, their behavior is already influenced. Plan your rollback window accordingly and do not reuse experiment groups too quickly.
Cache invalidation: if you cache model outputs per user, include the assigned model name in the cache key. Otherwise a user rolling from treatment to control gets stale treatment outputs.
Logging volume: at scale, logging every _meta block adds up. Sampling at 1–5% is fine for aggregate metrics. Use structured logs so you can filter by flag and variant without regex gymnastics.
Flag hygiene: once an experiment concludes, remove the flag and ship the winning model directly. Stale flags accumulate technical debt faster than stale comments.
The Takeaway
Feature flags for ML A/B testing are not complicated, but they require discipline: stable assignment, observable metadata, pre-defined stopping rules, and cleanup. The pattern above is intentionally thin — no external service, no SDK dependency. For small to medium traffic, a polled JSON file and a 60-line router get you 90% of the value of heavier solutions like LaunchDarkly or Statsig.
If you need more sophisticated segmentation — multi-variate tests, contextual bandits, online learning — the structure scales: swap assign_variant for a bandit policy and log rewards back to the flag store.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)