Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

39.1. Feedback Loops & Popularity Bias

Status: Draft Version: 1.0.0 Tags: #RecSys, #Bias, #Rust, #Simulation, #Ethics Author: MLOps Team


Table of Contents

  1. The Self-Fulfilling Prophecy
  2. Case Study: The YouTube Pivot
  3. Types of RecSys Bias
  4. Mathematical Formulation: Propensity Scoring
  5. Rust Simulation: The Death of the Long Tail
  6. Mitigation Strategies: IPS & Exploration
  7. Infrastructure: The Bias Monitor
  8. Deployment: Dockerizing the Simulation
  9. Troubleshooting: Common Bias Issues
  10. MLOps Interview Questions
  11. Glossary
  12. Summary Checklist

Prerequisites

Before diving into this chapter, ensure you have the following installed:

  • Rust: 1.70+
  • Plotting: gnuplot or Python matplotlib for visualizing tail distributions.
  • Data: A sample interaction log (e.g., MovieLens).
  • Docker: For running the monitoring sidecar.

The Self-Fulfilling Prophecy

In Computer Vision, predicting “Cat” doesn’t make the image more likely to be a “Cat”. In Recommender Systems, predicting “Item X” makes the user more likely to click “Item X”.

The Loop Visualized

       +---------------------+
       |   User Preference   |
       |      (Unknown)      |
       +----------+----------+
                  |
                  v
       +---------------------+        +---------------------+
       |   Interaction Log   | -----> |   MlOps Training    |
       |  (Biased Clicks)    |        |     Pipeline        |
       +----------+----------+        +----------+----------+
                  ^                              |
                  |                        New Model Weights
                  |                              |
       +----------+----------+                   v
       |    User Clicks      |        +---------------------+
       |    (Action)         | <----- |  Inference Service  |
       +----------+----------+        |  (Biased Ranking)   |
                  ^                   +---------------------+
                  |
       +---------------------+
       |  Exposure (Top-K)   |
       +---------------------+
  1. Model shows Harry Potter to everyone because it’s popular.
  2. Users click Harry Potter because it’s the only thing they see.
  3. Model sees high clicks for Harry Potter and thinks “Wow, this is even better than I thought!”
  4. Model shows Harry Potter even more.
  5. Small indie books get 0 impressions, 0 clicks. The System assumes they are “bad”.

This is the Feedback Loop (or Echo Chamber). It destroys the Long Tail of your catalog, reducing diversity and eventually revenue.


Case Study: The YouTube Pivot

In 2012, YouTube optimized for Clicks.

  • Result: Clickbait thumbnails (“You won’t believe this!”) and short, shocking videos.
  • Feedback Loop: The model learned that shocked faces = Clicks.
  • User Sentiment: Negative. People felt tricked.

In 2015, YouTube pivoted to Watch Time.

  • Goal: Maximize minutes spent on site.
  • Result: Long-form gaming videos, tutorials, podcasts (The “Joe Rogan” effect).
  • Bias Shift: The bias shifted from “Clickability” to “Duration”.
  • Lesson: You get exactly what you optimize for. Feedback loops amplify your objective function’s flaws.

Types of RecSys Bias

1. Popularity Bias

The head of the distribution gets all the attention. The tail is invisible.

  • Symptom: Metrics ($Recall@K$) look great, but users complain about “boring” recommendations.
  • Metric: Gini Coefficient of impressions.

2. Positional Bias

Users click the first result 10x more than the second result, purely because of Position.

  • Correction: You must model $P(\text{click} | \text{seen}, \text{rank})$.
  • Formula: $P(C=1) = P(C=1|E=1) \cdot P(E=1)$.

3. Selection Bias

You only have labels for items you showed. You have NO labels for items you didn’t show (Missing Not At Random). If you train mainly on “Shown Items”, your model will fail to predict the quality of “Unshown Items”.


Mathematical Formulation: Propensity Scoring

How do we unbias the training data? We treat it like a Causal Inference problem. We define Propensity $p_{ui}$ as the probability that User $u$ viewed Item $i$.

Naive Loss (Biased): $$ L_{Naive} = \frac{1}{|O|} \sum_{(u,i) \in O} \delta_{ui} $$ Where $O$ is the set of observed interactions based on the old recommender.

Inverse Propensity Scoring (IPS) Loss (Unbiased): $$ L_{IPS} = \frac{1}{|U||I|} \sum_{(u,i) \in O} \frac{\delta_{ui}}{p_{ui}} $$

We downweight items that were shown frequently (high $p_{ui}$) and upweight items that were shown rarely (low $p_{ui}$). Ideally, this reconstructs the true preference matrix.

The Variance Problem in IPS

While IPS is Unbiased, it has High Variance. If $p_{ui}$ is very small (e.g., $10^{-6}$), the weight becomes $10^6$. A single click on a rare item can dominate the gradients.

Solution: Clipped IPS (CIPS) $$ w_{ui} = \min(\frac{1}{p_{ui}}, M) $$ Where $M$ is a max clip value (e.g., 100). This re-introduces some bias but drastically reduces variance.


Rust Simulation: The Death of the Long Tail

To truly understand this, we simulate a closed-loop system in Rust. We start with a uniform catalog and watch the feedback loop destroy diversity.

Project Structure

recsys-sim/
├── Cargo.toml
└── src/
    └── main.rs

Cargo.toml:

[package]
name = "recsys-sim"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.8"
rand_distr = "0.4"
histogram = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

src/main.rs:

//! Feedback Loop Simulation
//! This simulates a simplified Recommender System where the model learns
//! essentially from its own actions, leading to a collapse of diversity.

use rand::distributions::{Distribution, WeightedIndex};
use rand::seq::SliceRandom;
use std::collections::HashMap;
use rand::Rng; // Import Rng trait

const N_ITEMS: usize = 1000;
const N_USERS: usize = 100;
const N_STEPS: usize = 50;

#[derive(Clone, Debug)]
struct Item {
    /// Unique ID of the item
    id: usize,
    /// The Ground Truth quality [0.0, 1.0]. Unknown to model.
    true_quality: f64, 
    /// The Model's current estimate of quality [0.0, 1.0].
    est_quality: f64,  
    /// Total number of times this item was exposed to a user
    impressions: u64,
    /// Total number of times this item was clicked
    clicks: u64,
}

fn main() {
    let mut rng = rand::thread_rng();

    // 1. Initialize Catalog
    // Some items are naturally better, but initially we don't know (est_quality = 0.5)
    let mut catalog: Vec<Item> = (0..N_ITEMS).map(|id| Item {
        id,
        true_quality: rng.gen::<f64>(), // 0.0 to 1.0 (Uniform)
        est_quality: 0.5,
        impressions: 1, // Smoothing to avoid div-by-zero
        clicks: 0,
    }).collect();

    println!("Starting Simulation: {} Items, {} Steps", N_ITEMS, N_STEPS);

    for step in 0..N_STEPS {
        // 2. The Loop
        for _user in 0..N_USERS {
            
            // RECOMMENDATION STEP:
            // The model picks top K items based on est_quality
            // Greedy strategy amplified by popularity
            catalog.sort_by(|a, b| b.est_quality.partial_cmp(&a.est_quality).unwrap());
            let top_k = &mut catalog[0..5];

            // USER INTERACTION STEP:
            // User picks ONE item from top_k, prob proportional to true_quality
            // Simulates Positional Bias (top items more likely seen) via WeightedIndex?
            
            // Simplified: User clicks if true_quality > random threshold
            for item in top_k.iter_mut() {
                item.impressions += 1;
                
                // Click Logic: True Quality + Random Noise
                if rng.gen::<f64>() < item.true_quality {
                    item.clicks += 1;
                }
            }
        }
        
        // TRAINING STEP:
        // Update est_quality = clicks / impressions
        for item in catalog.iter_mut() {
            item.est_quality = (item.clicks as f64) / (item.impressions as f64);
        }
        
        // METRICS: Gini Coefficient of Impressions
        let impressions: Vec<u64> = catalog.iter().map(|x| x.impressions).collect();
        let gini = calculate_gini(&impressions);
        
        if step % 10 == 0 {
            println!("Step {}: Gini = {:.4} (High = Inequality)", step, gini);
        }
    }
}

/// Calculate Gini Coefficient
/// 0.0 means perfect equality (everyone gets same impressions)
/// 1.0 means perfect inequality (one person gets all impressions)
fn calculate_gini(data: &[u64]) -> f64 {
    if data.is_empty() { return 0.0; }
    
    let mut sorted = data.to_vec();
    sorted.sort();
    let n = sorted.len() as f64;
    let sum: u64 = sorted.iter().sum();
    
    if sum == 0 { return 0.0; }
    
    let mean = sum as f64 / n;
    
    let mut numerator = 0.0;
    for (i, &val) in sorted.iter().enumerate() {
        numerator += (i as f64 + 1.0) * val as f64;
    }
    
    (2.0 * numerator) / (n * sum as f64) - (n + 1.0) / n
}

Interpretation of Results

When you run this, you will see the Gini Coefficient rise from 0.0 (Equality) to ~0.9 (Extreme Inequality).

  • Step 0: Random recommendations. Gini ~0.0.
  • Step 10: The “lucky” items that got initial clicks rise to the top.
  • Step 50: The model has converged on a tiny subset of items. Even better items in the tail are never shown again.

Mitigation Strategies: IPS & Exploration

To fix this, we must stop purely exploiting est_quality.

1. Epsilon-Greedy / Bandit Exploration

Randomly verify the tail.

  • 90% of time: Show Top 5.
  • 10% of time: Show 5 random items from the Tail.

2. Inverse Propensity Scoring (IPS)

When training the model, weight the click.

  • Item A (Shown 1,000,000 times, 1000 clicks): Weight = 1/1,000,000.
  • Item B (Shown 10 times, 5 clicks): Weight = 1/10.

Item B’s signal is amplified because it overcame the “lack of visibility” bias.


Infrastructure: The Bias Monitor

Just like we monitor Latency, we must monitor Bias in production.

Metric: Distribution of Impressions across Catalog Head/Torso/Tail.

#![allow(unused)]
fn main() {
// bias_monitor.rs
use std::collections::HashMap;

pub struct BiasMonitor {
    head_cutoff: usize,
    tail_counts: u64,
    head_counts: u64,
}

impl BiasMonitor {
    pub fn new(head_cutoff: usize) -> Self {
        Self {
            head_cutoff,
            tail_counts: 0,
            head_counts: 0,
        }
    }

    pub fn observe(&mut self, item_rank: usize) {
        if item_rank < self.head_cutoff {
            self.head_counts += 1;
        } else {
            self.tail_counts += 1;
        }
    }
    
    pub fn get_tail_coverage(&self) -> f64 {
        let total = self.head_counts + self.tail_counts;
        if total == 0 { return 0.0; }
        self.tail_counts as f64 / total as f64
    }
}
}

Dashboard Visualization (Vega-Lite)

{
  "description": "Impression Lorenz Curve",
  "mark": "line",
  "encoding": {
    "x": {"field": "cumulative_items_percent", "type": "quantitative"},
    "y": {"field": "cumulative_impressions_percent", "type": "quantitative"}
  }
}

Alert Rule: If Top 1% Items get > 90% Impressions, Trigger P2 Incident.


Deployment: Dockerizing the Simulation

To run this simulation as a regression test in your CI/CD pipeline, use this Dockerfile.

# Dockerfile
# Build Stage
FROM rust:1.70 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo install --path .

# Runtime Stage
FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y extra-runtime-deps && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/recsys-sim /usr/local/bin/recsys-sim

# Command to run (output JSON logs)
CMD ["recsys-sim", "--json"]

Troubleshooting: Common Bias Issues

Here are the most common issues you will encounter when tackling popularity bias.

Scenario 1: Gini Coefficient is 0.99

  • Symptom: The system only recommends the Top 10 items.
  • Cause: Your exploration_rate (epsilon) is 0.0. Or, you are training for Accuracy without any Propensity Weighting.
  • Fix: Force 5% random traffic immediately.

Scenario 2: High CTR, Low Revenue

  • Symptom: Users click a lot, but don’t buy/watch.
  • Cause: The model optimized for “Clickbait”.
  • Fix: Switch your objective function to Conversion or Dwell Time.

Scenario 3: “My recommendations are random”

  • Symptom: Users complain results are irrelevant.
  • Cause: Aggressive IPS weighting using unclipped propensities. One random click on a trash item exploded its gradient.
  • Fix: Implement Clipped IPS (max weight = 100).

MLOps Interview Questions

  1. Q: What is the “Cold Start” problem in relation to Feedback Loops? A: Feedback loops make Cold Start worse. New items start with 0 history. If the system only recommends popular items, new items never get the initial “kickstart” needed to enter the loop.

  2. Q: Explain “Exposure Bias”. A: The user’s interaction is conditioned on exposure. $P(click) = P(click|exposure) * P(exposure)$. Our logs only show $P(click|exposure=1)$. We treat non-clicks as “don’t like”, but often it’s “didn’t see”.

  3. Q: How does “Thompson Sampling” help? A: Thompson Sampling treats the quality estimate as a probability distribution (Beta distribution). For items with few views, the variance is high. The algorithm samples from the tail of the distribution, naturally exploring uncertain items optimistically.

  4. Q: Can you fix bias by just boosting random items? A: Yes, but it hurts Conversion Rate (CTR). Users hate random irrelevant stuff. The art is “Smart Exploration” (Bandits) rather than uniform random.

  5. Q: What features prevent feedback loops? A: Positional features! Include position_in_list as a feature during training. During inference, set position=0 for all items (counterfactual inference) to predict their intrinsic appeal independent of position.


Glossary

  • Feedback Loop: System outputs affecting future inputs.
  • Propensity: Probability of treatment (exposure).
  • IPS: Re-weighting samples by inverse propensity.
  • Gini Coefficient: Metric of inequality (0=Equal, 1=Monopoly).
  • Long Tail: The large number of items with low individual popularity but high aggregate volume.

Summary Checklist

  1. Monitor Gini: Add Gini Coefficient of Impressions to your daily dashboard.
  2. Log Positions: Always log the rank at which an item was shown.
  3. IPS Weighting: Use weighted loss functions during training.
  4. Exploration Slice: Dedicate 5% of traffic to Epsilon-Greedy or Boltmann exploration to gather unbiased data.
  5. Calibration: Ensure predicted probabilities match meaningful click rates, not just rank order.
  6. Positional Bias Feature: Add position as a feature in training, and set it to a constant bias (e.g., pos=1) during inference.
  7. Holdout Group: Keep a 1% “Random” holdout group to measure the true baseline.
  8. Alerts: Set alerts on “Tail Coverage %”. If it drops below 20%, your model has collapsed.
  9. Diversity Re-Ranking: Use Maximal Marginal Relevance (MMR) or Determinantal Point Processes (DPP) in the final ranking stage.
  10. Audit: Periodically manually review the “Top 100” items to spot content farms exploiting the loop.