• Product Engineering
  • Posts
  • Synthetic Controls: The Technique Powering the World’s Best Product Teams

Synthetic Controls: The Technique Powering the World’s Best Product Teams

How to build alternate realities and escape the correlation trap.

At a startup I was consulting for, I once greenlit a feature that boosted metrics overnight. 2 weeks later, it tanked retention. Why? Because we trusted the spike.

Most product teams operate with a dangerous assumption: that if a metric moves after a feature ships, the feature caused it. This misconception has shaped roadmaps, justified headcount, and defined "success" across the industry for too long. It's a particular kind of statistical naivety that I witnessed repeatedly during my years at Big Tech – the confusion of correlation with causation.

The problem is fundamental: conventional analytics only shows what happened, not what would have happened otherwise. This gap between observation and understanding is where synthetic control methodology comes in – a powerful framework that's transforming how elite teams measure product impact.

I've implemented these approaches at scale and seen the difference between teams that rely on dashboard correlations versus those who rigorously isolate causal effects.

I used to think more data was the answer. I was wrong. Without counterfactuals, more data just means more convincing lies.

The disparity in decision quality is staggering.

Beyond Traditional Experimentation

There’s a cult in every company: the Dashboard Cult.

They worship deltas, ignore context, and sacrifice insight for convenience. The limitations of conventional A/B testing are rarely acknowledged:

  1. Spillover effects: When treatment affects control groups through network effects

  2. Sample size constraints: Small user bases produce underpowered tests

  3. Implementation overhead: Feature flagging infrastructure adds complexity

  4. Political friction: Withholding features creates organizational resistance

  5. Temporal blindness: Long-term effects aren't captured in short test windows

These constraints aren't theoretical – they're practical barriers I've encountered repeatedly when implementing experimentation frameworks. They're also why many teams default to the before/after dashboard comparisons I critiqued in my article on how dashboards kill great products.

Synthetic control methods provide an alternative by constructing a counterfactual – a statistically rigorous prediction of what would have happened without your intervention.

Mathematical Foundations of Synthetic Controls

At its core, the synthetic control method creates a weighted combination of unaffected units to form a "synthetic" version that closely resembles the unit that received the treatment or intervention. The approach was pioneered by Alberto Abadie and colleagues who faced a rather morbid but fascinating problem: How do you measure the economic impact of terrorism?

The first major application was analyzing the economic consequences of terrorist conflict in the Basque region of Spain. You can't exactly run an A/B test with terrorism – "Let's randomly assign some regions to experience political violence and others to serve as controls!" Ethically catastrophic, obviously. So instead, they created a "synthetic Basque Country" by finding the perfect weighted blend of other Spanish regions that matched Basque economic patterns before the terrorism began.

It's a perfect example of necessity driving methodological innovation. When you can't run the experiment you want, you build the statistical machinery to simulate what would have happened anyway. This same principle applies directly to product development.

The mathematical approach is intuitive when you strip away the fancy notation:

  1. Finding the right mix: The method searches for the perfect combination of control units (like other Spanish regions) that, when blended together, most closely match your treatment unit (the Basque region) before the intervention happened.

  2. Optimizing the weights: Imagine you're mixing ingredients for a recipe. You need to figure out exactly how much of each ingredient gives you the perfect flavor. The algorithm does this by finding weights for each control unit to minimize the difference between the synthetic blend and the real unit before intervention.

  3. Extending the forecast: Once you've found this perfect mix, you apply the same recipe of weights after the intervention. This gives you a prediction of what would have happened without the intervention.

  4. Measuring the effect: The difference between what actually happened and what your synthetic control predicts would have happened is your estimated causal effect.

Why does this approach work? Because it addresses the fundamental limitation of before-after comparisons. Simple before-after analysis can't separate the effect of your intervention from other factors that might have changed over time. Synthetic controls solve this by creating a counterfactual that incorporates those time-varying factors.

For example, if you launch a new feature during a holiday season, a simple before-after comparison would confuse seasonal effects with feature impact. A synthetic control would use patterns from comparable periods to factor out the seasonality, giving you a cleaner read on the actual feature impact.

Modern implementations have evolved beyond this classical approach to include:

  1. Augmented synthetic control: Combines traditional method with outcome modeling to improve accuracy

  2. Robust synthetic control: Addresses issues with outliers and noisy data

  3. Penalized synthetic control: Adds constraints to prevent overfitting

  4. Machine learning approaches: Leverages advanced algorithms to identify complex patterns

QUICK SIDEBAR — A NOTE ON MONETIZATION

Wait, what? An ad? In my technically rigorous newsletter?

Yeah, I know. Let me explain.

This morning I got an opportunity to place an ad from the folks at Synthflow. While I do have monetization plans for this newsletter, I'm still experimenting. I'm not about to start throwing affiliate links into every post or chasing low-trust revenue. That's not my style.

But when a legit company like Synthflow reaches out—and I already know and have used their product—I start listening.

Last year when I was building my career coach agent based on voice, I wish I had been building it with Synthflow.

The Playbook for Tomorrow’s Voice-First Enterprises

Voice is the most natural, accessible interface—already used across 8.4 billion devices worldwide.

This guide reveals how leading enterprises are capitalizing on the shift to voice to reduce missed calls, improve customer access, and deploy scalable AI agents in just weeks.

From strategy to execution, learn how to turn voice into a competitive edge for your business.

Building Your Implementation Stack

The technical stack for implementing synthetic controls has matured significantly. Here are the key components you'll need:

1. Python Libraries

Several specialized packages make implementation straightforward:

  • SyntheticControlMethods: The classic implementation with comprehensive visualization tools

  • pysyncon: Focused package with implementations of original, robust, augmented, and penalized synthetic control

  • CausalPy: Broader causal inference package that includes synthetic control alongside other approaches

  • SparseSC: Optimized for high-dimensional applications with many potential control units

These typically work with the standard data science stack (pandas, numpy, statsmodels, matplotlib) to handle everything from data preparation to result visualization.

2. Data Requirements

Successful implementation requires:

  • Panel data structure: Sequential measures for both treated and control units

  • Pre-intervention period: Sufficient data before the intervention (ideally 5+ time points)

  • Potential control pool: Multiple untreated units that can serve as donors

  • Outcome variables and predictors: Clear definition of metrics and influencing factors

3. Implementation Workflow

Here's a concrete implementation example using the SyntheticControlMethods package:

# Load and prepare data
data = pd.read_csv("product_metrics.csv")

# 1. Define core parameters
outcome_variable = "retention_d7"    # Target metric to analyze
unit_variable = "country"            # Unit of analysis (could be region, cohort, etc.)
time_variable = "week"               # Time dimension
treatment_period = 26                # When feature launched (time index)
treated_unit = "US"                  # Unit that received treatment

# 2. Initialize and fit the synthetic control model
sc = Synth(
    data=data, 
    outcome_var=outcome_variable, 
    unit_var=unit_variable, 
    time_var=time_variable, 
    treatment_period=treatment_period, 
    treated_unit=treated_unit,
    control_units=None,              # Use all available controls
    predictors=["sessions", "dau", "notifications_opened"],
    predictor_periods=range(1, treatment_period)  # All pre-treatment periods
)
sc.fit()

# 3. Analyze treatment effects
treatment_effect = sc.get_treatment_effect()
print(f"Average treatment effect: {treatment_effect.mean()}")
print(f"Cumulative impact: {treatment_effect.sum()}")

# 4. Visualize results
sc.plot(["original", "synthetic", "gap"])

# 5. Run validation tests
sc.run_placebo_tests()
sc.plot_placebo_tests()

4. Validation Approaches

Ensuring validity is critical for reliable inference:

  • Pre-intervention fit assessment: Root Mean Square Error (RMSE) between synthetic and actual should be < 10% of outcome variable's standard deviation

  • Placebo tests: Apply the method to units known not to be affected

  • Leave-one-out robustness: Iteratively remove control units to assess stability

  • In-time placebos: Test the method on periods before the actual intervention

From DIY Implementation to Scalable Infrastructure

As I outlined in my article on going from zero to data-driven, your approach should evolve with your organization. Here's how synthetic control implementations can scale:

Stage 1: Manual Implementation (1-5 Engineers)

  • Use standalone Python scripts

  • Analyze results ad-hoc for key product decisions

  • Focus on methodological correctness over scalability

# Simple validation approach for small teams
def validate_synthetic_control(model, data, treated_unit, treatment_period):
    # Check pre-intervention fit quality
    pre_rmse = model.get_fit_metrics()['rmse']
    pre_std = data[data[model.unit_var] == treated_unit][model.outcome_var][:treatment_period].std()
    fit_quality = pre_rmse / pre_std
    
    # Placebo testing on first 5 control units
    placebo_effects = []
    control_units = data[model.unit_var].unique()[:5]
    control_units = control_units[control_units != treated_unit]
    
    for unit in control_units:
        placebo_model = Synth(data, model.outcome_var, model.unit_var, 
                           model.time_var, treatment_period, unit)
        placebo_model.fit()
        effect = placebo_model.get_treatment_effect().mean()
        placebo_effects.append(effect)
    
    # Compare treatment effect to placebo distribution
    actual_effect = model.get_treatment_effect().mean()
    placebo_rank = sum([abs(p) >= abs(actual_effect) for p in placebo_effects])
    p_value = placebo_rank / len(placebo_effects)
    
    return {
        'fit_quality': fit_quality,
        'p_value': p_value,
        'reliable': fit_quality < 0.1 and p_value < 0.1
    }

Stage 2: Production Integration (5-20 Engineers)

  • Integrate with feature flagging system

  • Automate data collection and preprocessing

  • Implement consistent logging standards

# Feature flagging integration with synthetic controls
class FeatureFlagSyntheticControl:
    def __init__(self, feature_key, metric_name, db_client):
        self.feature_key = feature_key
        self.metric_name = metric_name
        self.db = db_client
        
    def setup_analysis(self, launch_date, lookback_days=60):
        # Fetch relevant metrics
        query = f"""
        SELECT date, region, {self.metric_name}, feature_enabled
        FROM metrics_table
        WHERE date BETWEEN DATE_SUB('{launch_date}', INTERVAL {lookback_days} DAY)
          AND CURRENT_DATE()
          AND feature_key = '{self.feature_key}'
        """
        self.data = self.db.run_query(query)
        
        # Identify full rollout date when feature_enabled became 100%
        rollout_dates = self.data.groupby('date')['feature_enabled'].mean()
        self.treatment_date = rollout_dates[rollout_dates > 0.95].index[0]
        
        # Transform for synthetic control format
        # ...additional preprocessing...
        
    def run_analysis(self):
        # Apply synthetic control method
        model = Synth(
            data=self.data,
            outcome_var=self.metric_name,
            unit_var='region',
            time_var='date',
            treatment_period=self.treatment_date,
            treated_unit='global'  # Aggregate impact
        )
        model.fit()
        return model

Stage 3: Enterprise Scale (20+ Engineers)

  • Implement computational optimization for large datasets

  • Build visualization and alerting dashboards

  • Create automated validation pipelines

# Example enterprise optimization for performance
def optimize_control_selection(data, outcome_var, unit_var, time_var, treatment_period, treated_unit, max_controls=10):
    """
    Selects optimal control units using penalized regression to improve computation speed
    for large datasets while maintaining prediction accuracy.
    """
    # Extract pre-treatment data
    pre_data = data[data[time_var] < treatment_period]
    treated_data = pre_data[pre_data[unit_var] == treated_unit][outcome_var].values
    
    # Prepare potential control data
    control_units = pre_data[unit_var].unique()
    control_units = control_units[control_units != treated_unit]
    
    # Create feature matrix
    X = np.zeros((len(treated_data), len(control_units)))
    for i, unit in enumerate(control_units):
        X[:, i] = pre_data[pre_data[unit_var] == unit][outcome_var].values
    
    # Apply LASSO regression to select controls
    alpha = 0.1  # Regularization strength
    model = sklearn.linear_model.Lasso(alpha=alpha, positive=True)
    model.fit(X, treated_data)
    
    # Select units with non-zero coefficients
    selected_units = control_units[model.coef_ > 0]
    
    # Limit to top units by coefficient value if needed
    if len(selected_units) > max_controls:
        top_indices = np.argsort(-model.coef_)[:max_controls]
        selected_units = control_units[top_indices]
    
    return list(selected_units)

Advanced Applications Beyond Traditional Product Analytics

The synthetic control framework extends beyond simple feature evaluation:

1. Algorithmic Tuning

Modern recommendation systems can be evaluated using synthetic controls to measure the true impact of algorithm changes:

def evaluate_algorithm_change(historical_data, algorithm_launch_date):
    """
    Evaluates recommendation algorithm changes using synthetic controls
    to isolate the causal impact on engagement metrics.
    """
    # Define metrics to track
    engagement_metrics = ['clicks_per_session', 'time_spent', 'conversion_rate']
    results = {}
    
    for metric in engagement_metrics:
        # Create synthetic control for each metric
        model = Synth(
            data=historical_data,
            outcome_var=metric,
            unit_var='user_segment',  # Segmented by user type
            time_var='date',
            treatment_period=algorithm_launch_date,
            treated_unit='all_users'  # Global impact
        )
        model.fit()
        
        # Calculate and store effects
        effect = model.get_treatment_effect()
        results[metric] = {
            'average_effect': effect.mean(),
            'percent_change': effect.mean() / model.get_synthetic_outcome().mean() * 100,
            'statistically_significant': model.inference()['p_value'] < 0.05
        }
    
    return results

2. Counterfactual Forecasting

Synthetic controls can be used for forward-looking predictions:

def forecast_with_counterfactuals(historical_data, forecast_periods=30, scenarios=None):
    """
    Uses synthetic control methodology to generate forecasts under different
    potential scenarios.
    """
    # Default scenarios if none provided
    if scenarios is None:
        scenarios = {
            'base': lambda x: x,  # No change
            'optimistic': lambda x: x * 1.1,  # 10% improvement
            'pessimistic': lambda x: x * 0.9  # 10% decline
        }
    
    # Fit synthetic model on historical data
    model = Synth(
        data=historical_data,
        outcome_var='revenue',
        unit_var='product_line',
        time_var='week',
        treatment_period=len(historical_data['week'].unique()),
        treated_unit='main_product'
    )
    model.fit()
    
    # Extract pattern components
    trend = model.get_synthetic_outcome()
    seasonality = model.get_seasonal_factors()
    
    # Generate forecasts for each scenario
    forecasts = {}
    last_value = trend[-1]
    
    for name, modifier in scenarios.items():
        forecast = []
        for i in range(forecast_periods):
            # Apply trend continuation
            next_value = last_value + (trend[-1] - trend[-2])
            # Apply scenario modifier
            next_value = modifier(next_value)
            # Apply seasonality
            season_index = (len(trend) + i) % len(seasonality)
            next_value *= seasonality[season_index]
            
            forecast.append(next_value)
            last_value = next_value
            
        forecasts[name] = forecast
    
    return forecasts

3. Digital Twins for Simulation

The most advanced implementations create synthetic users that simulate how real users would respond to changes:

class SyntheticUserSimulation:
    def __init__(self, historical_user_data, feature_set):
        """
        Creates a synthetic user base that can simulate responses to
        product changes based on historical behavior patterns.
        """
        self.user_data = historical_user_data
        self.feature_set = feature_set
        self.user_segments = self._identify_user_segments()
        self.behavior_models = self._train_behavior_models()
    
    def _identify_user_segments(self, n_segments=5):
        """Cluster users into behavioral segments"""
        from sklearn.cluster import KMeans
        
        # Extract behavioral features
        features = [
            'avg_sessions_per_week',
            'avg_session_duration',
            'feature_usage_diversity',
            'retention_days',
            'conversion_likelihood'
        ]
        X = self.user_data[features].values
        
        # Cluster users
        kmeans = KMeans(n_clusters=n_segments)
        self.user_data['segment'] = kmeans.fit_predict(X)
        
        return self.user_data['segment'].unique()
    
    def _train_behavior_models(self):
        """Train predictive models for each user segment"""
        models = {}
        
        for segment in self.user_segments:
            segment_data = self.user_data[self.user_data['segment'] == segment]
            
            # Train models to predict key behaviors
            models[segment] = {
                'engagement': self._train_engagement_model(segment_data),
                'retention': self._train_retention_model(segment_data),
                'conversion': self._train_conversion_model(segment_data)
            }
        
        return models
    
    def simulate_feature_impact(self, new_feature_config, simulation_days=90):
        """
        Simulate how different user segments would respond to a new feature
        or feature change.
        """
        results = {
            'overall': {'engagement': 0, 'retention': 0, 'conversion': 0},
            'by_segment': {}
        }
        
        # Get distribution of users by segment
        segment_distribution = self.user_data['segment'].value_counts(normalize=True)
        
        for segment in self.user_segments:
            # Apply behavior models with new feature config
            segment_impact = {
                'engagement': self.behavior_models[segment]['engagement'].predict(new_feature_config),
                'retention': self.behavior_models[segment]['retention'].predict(new_feature_config),
                'conversion': self.behavior_models[segment]['conversion'].predict(new_feature_config)
            }
            
            results['by_segment'][segment] = segment_impact
            
            # Update overall results (weighted by segment size)
            for metric in ['engagement', 'retention', 'conversion']:
                results['overall'][metric] += segment_impact[metric] * segment_distribution[segment]
        
        return results

The Causal Revolution in Product Development

The shift to synthetic controls represents a broader movement toward causal inference in product development. While observational analytics tell you what happened, causal methods tell you why it happened and what would have happened otherwise.

This distinction isn't academic—it's the difference between roadmaps built on correlations versus those built on validated causal relationships. It's the difference between chasing phantom signals and focusing engineering resources on what truly matters.

Implementation challenges exist:

  1. Data requirements: You need sufficient pre-intervention history

  2. Statistical expertise: The methods demand deeper understanding than traditional analytics

  3. Organizational resistance: Teams comfortable with dashboard thinking may resist counterfactual frameworks

  4. Validation complexity: Ensuring model validity requires rigorous testing

But the payoff is substantial: decisions grounded in actual causal relationships rather than illusory correlations. The ability to separate signal from noise. The confidence to know your feature truly made a difference—not just that a metric moved after you shipped.

This approach complements the experimentation framework I outlined in my zero-to-data-driven guide. While basic experimentation helps teams develop the muscle of hypothesis-driven development, synthetic controls add the critical dimension of counterfactual thinking.

The most advanced product teams are already making this transition. They're combining traditional randomized experiments with synthetic controls to get the best of both worlds—the statistical cleanness of randomization with the flexibility and scope of synthetic approaches.

If you've been building products based on dashboard correlations, now is the time to upgrade your toolkit. The synthetic control framework isn't just a statistical technique—it's a fundamental shift in how we understand product impact. It's the difference between guessing and knowing. Between hoping and measuring. Between building features and building value.

And that's a difference worth investing in.

If you found this valuable, subscribe below for weekly insights on product engineering, causal inference, and building things that actually matter. No hype—just hard-won lessons from the trenches of elite product development.

Reply

or to participate.