Human Preference Data › Implementing Preference Data Collection

Michael BrenndoerferDecember 22, 202516 min read
Language AI HandbookAll chapters
Part 37 of 67Alignment and RLHF
Chapter 273Human Preference Data › Implementing Preference Data Collection
Reading section 7 of 13 · Implementing Preference Data Collection · 16 minSection 7 of 13 · 16 min

Book contents

Section 7 of 13 · 16 min read

Human Preference Data › Implementing Preference Data CollectionOne section · Sections in this chapter7 / 13 ↓

Let's implement a simple preference data processing pipeline that demonstrates key concepts in code. This implementation will illustrate the data structures, quality metrics, and analysis workflows you would use in a real preference collection project.

The goal here is not to show production-ready code but to make the concepts concrete. A real preference collection platform would require a web interface, database backend, authentication system, and many other components. What we show here is the core data processing and analysis logic.

In[5]:
Code
from dataclasses import dataclass
from typing import Literal


@dataclass
class PreferenceSample:
    """A single preference comparison sample."""

    prompt: str
    response_a: str
    response_b: str
    preference: Literal["a", "b", "tie"]
    annotator_id: str
    annotation_time_seconds: float
    metadata: dict = None

    def __post_init__(self):
        if self.metadata is None:
            self.metadata = {}


@dataclass
class PreferenceDataset:
    """Collection of preference samples with quality metrics."""

    samples: list[PreferenceSample]

    def compute_agreement(self, other_samples: list[PreferenceSample]) -> float:
        """Compute agreement rate between two sets of annotations."""
        # Match samples by prompt
        prompt_to_self = {s.prompt: s for s in self.samples}

        agreements = 0
        comparisons = 0

        for other in other_samples:
            if other.prompt in prompt_to_self:
                self_pref = prompt_to_self[other.prompt].preference
                if self_pref == other.preference:
                    agreements += 1
                comparisons += 1

        return agreements / comparisons if comparisons > 0 else 0.0

The PreferenceSample dataclass captures all the information we need about a single preference comparison: the prompt, both responses, the annotator's choice, the annotator's identity (for quality tracking), the time spent on the annotation, and a flexible metadata field for additional tracking information. The PreferenceDataset wraps a collection of samples and provides analysis utilities.

Now let's create some example preference data that illustrates common patterns:

In[6]:
Code
import random

# Simulate realistic preference data
example_prompts = [
    "Explain quantum computing to a beginner.",
    "Write a haiku about artificial intelligence.",
    "What are the pros and cons of remote work?",
    "How do I debug a Python memory leak?",
    "Summarize the French Revolution in 3 sentences.",
]


def generate_mock_responses(prompt: str) -> tuple[str, str]:
    """Generate mock response pairs for demonstration."""
    responses = {
        "Explain quantum computing to a beginner.": (
            "Quantum computing uses qubits instead of regular bits. While normal bits are either 0 or 1, qubits can be both at once through superposition. This lets quantum computers solve certain problems much faster than regular computers.",
            "Quantum computing is a revolutionary paradigm in computational theory that leverages the principles of quantum mechanics, specifically superposition and entanglement, to perform calculations that would be intractable for classical computing architectures. The fundamental unit of quantum information is the qubit, which exists in a probabilistic superposition of states until measurement collapses the wave function.",
        ),
        "Write a haiku about artificial intelligence.": (
            "Silicon mind wakes\nPatterns emerge from chaos\nLearning never ends",
            "Artificial\nIntelligence is really cool\nRobots are the best",
        ),
        "What are the pros and cons of remote work?": (
            "Remote work offers flexibility and eliminates commuting, but can lead to isolation and blurred work-life boundaries. Success depends on individual work style and team communication practices.",
            "Pros: flexibility, no commute, comfortable environment. Cons: isolation, distractions, communication challenges. Remote work has both advantages and disadvantages that vary by person.",
        ),
        "How do I debug a Python memory leak?": (
            "Use the tracemalloc module to track memory allocations. Start with tracemalloc.start(), run your code, then use tracemalloc.take_snapshot() to see what's using memory. Look for objects that grow over time.",
            "Memory leaks in Python are rare due to garbage collection, but can occur with circular references or C extensions. You should probably just restart your program periodically to clear memory.",
        ),
        "Summarize the French Revolution in 3 sentences.": (
            "The French Revolution (1789-1799) overthrew the monarchy and established a republic based on Enlightenment ideals. It began with the storming of the Bastille and led to radical social change, including the abolition of feudalism. The revolution ended with Napoleon's rise to power.",
            "The French Revolution was when French people got mad at the king. There was lots of fighting and the king got his head cut off. Then Napoleon became the leader.",
        ),
    }
    return responses.get(prompt, ("Response A", "Response B"))


def simulate_annotation(
    prompt: str, response_a: str, response_b: str, annotator_id: str
) -> PreferenceSample:
    """Simulate an annotator making a preference judgment."""
    # Simple heuristics to simulate realistic annotation patterns
    len_diff = len(response_b) - len(response_a)

    # Simulate some annotators preferring verbose responses
    if "verbose" in annotator_id:
        preference = "b" if len(response_b) > len(response_a) else "a"
    # Others prefer concise, clear responses
    elif "concise" in annotator_id:
        preference = "a" if len(response_a) < len(response_b) else "b"
    # Most annotators have mixed preferences
    else:
        # Prefer response A for prompts about technical topics (our mock data has better A responses)
        if (
            "debug" in prompt.lower()
            or "quantum" in prompt.lower()
            or "revolution" in prompt.lower()
        ):
            preference = "a"
        else:
            preference = random.choice(["a", "b"])

    # Add some noise to simulate disagreement
    if random.random() < 0.15:
        preference = "b" if preference == "a" else "a"

    # Simulate annotation time (longer responses take longer to read)
    base_time = 15.0
    time_per_char = 0.02
    total_chars = len(response_a) + len(response_b)
    annotation_time = (
        base_time + total_chars * time_per_char + random.gauss(0, 5)
    )
    annotation_time = max(5.0, annotation_time)  # Minimum 5 seconds

    return PreferenceSample(
        prompt=prompt,
        response_a=response_a,
        response_b=response_b,
        preference=preference,
        annotator_id=annotator_id,
        annotation_time_seconds=annotation_time,
        metadata={"len_a": len(response_a), "len_b": len(response_b)},
    )

The simulation models three types of annotators: those who systematically prefer longer responses ("verbose"), those who systematically prefer shorter responses ("concise"), and neutral annotators who tend to prefer the better response but with some random noise. This mirrors the heterogeneity you observe in real annotator pools.

In[7]:
Code
# Generate preference data from multiple annotators
annotator_ids = ["ann_1", "ann_2", "ann_3_verbose", "ann_4_concise", "ann_5"]

all_samples = []
for prompt in example_prompts:
    response_a, response_b = generate_mock_responses(prompt)
    for annotator in annotator_ids:
        sample = simulate_annotation(prompt, response_a, response_b, annotator)
        all_samples.append(sample)

dataset = PreferenceDataset(samples=all_samples)
Out[8]:
Console
Total samples collected: 25
Unique prompts: 5
Annotators: 5

The dataset contains 25 samples distributed across 5 prompts and 5 annotators. This scale is sufficient to demonstrate the data structures and analysis pipelines, though real-world datasets typically contain thousands to hundreds of thousands of examples. The InstructGPT dataset used approximately 40,000 preference comparisons; Anthropic's HH-RLHF dataset contains roughly 169,000 comparisons. Scale matters because reward models are being trained on this data and need sufficient coverage of the prompt distribution.

Let's analyze the preference distribution and look for potential biases:

In[9]:
Code
from collections import defaultdict


def analyze_preferences(dataset: PreferenceDataset) -> dict:
    """Analyze preference patterns in the dataset."""
    analysis = {
        "preference_distribution": defaultdict(int),
        "annotator_preferences": defaultdict(lambda: defaultdict(int)),
        "length_correlation": {
            "preferred_longer": 0,
            "preferred_shorter": 0,
            "same_length": 0,
        },
        "avg_annotation_time": 0.0,
        "fast_annotations": 0,  # Under 10 seconds
    }

    total_time = 0.0
    for sample in dataset.samples:
        # Preference distribution
        analysis["preference_distribution"][sample.preference] += 1

        # Per-annotator preferences
        analysis["annotator_preferences"][sample.annotator_id][
            sample.preference
        ] += 1

        # Length correlation
        len_a = sample.metadata.get("len_a", len(sample.response_a))
        len_b = sample.metadata.get("len_b", len(sample.response_b))

        if sample.preference == "a":
            if len_a > len_b:
                analysis["length_correlation"]["preferred_longer"] += 1
            elif len_a < len_b:
                analysis["length_correlation"]["preferred_shorter"] += 1
            else:
                analysis["length_correlation"]["same_length"] += 1
        elif sample.preference == "b":
            if len_b > len_a:
                analysis["length_correlation"]["preferred_longer"] += 1
            elif len_b < len_a:
                analysis["length_correlation"]["preferred_shorter"] += 1
            else:
                analysis["length_correlation"]["same_length"] += 1

        # Annotation time
        total_time += sample.annotation_time_seconds
        if sample.annotation_time_seconds < 10.0:
            analysis["fast_annotations"] += 1

    analysis["avg_annotation_time"] = total_time / len(dataset.samples)

    return dict(analysis)


analysis = analyze_preferences(dataset)
Out[10]:
Console
Preference Distribution:
  a: 15 (60.0%)
  b: 10 (40.0%)

Average annotation time: 21.5 seconds
Fast annotations (<10s): 1

Length correlation (excluding ties):
  Preferred longer response: 56.0%
  Preferred shorter response: 44.0%

The distribution reveals a mix of preferences, with some annotators showing distinct biases. The length correlation metrics help identify whether annotators systematically favor longer responses, a common quality issue where verbosity is mistaken for helpfulness. In a real dataset, finding that 65% of preferences go to the longer response would be a red flag requiring investigation and possible guideline revision.

Out[11]:
Visualization
Scatter plot of response length differences (preferred minus rejected character count) across annotation samples, colored by annotator type, with verbose-preferring annotators clustering above zero and concise-preferring annotators clustering below.
Response length differences between preferred and rejected outputs, colored by annotator type. Positive values indicate the preferred response was longer than the rejected one. The distinct clustering of verbose-preferring annotators (red) above the zero line visually confirms their systematic length bias, while concise-preferring annotators (blue) cluster below. Neutral annotators (gray) show a mixed pattern reflecting genuine quality judgments.

Now let's compute inter-annotator agreement using Cohen's Kappa:

In[12]:
Code
def compute_cohens_kappa(annotations_1: list, annotations_2: list) -> float:
    """
    Compute Cohen's Kappa for two sets of annotations.

    Kappa = (p_o - p_e) / (1 - p_e)
    where p_o is observed agreement and p_e is expected agreement by chance.
    """
    assert len(annotations_1) == len(annotations_2)
    n = len(annotations_1)

    if n == 0:
        return 0.0

    # Get unique labels
    labels = sorted(set(annotations_1) | set(annotations_2))

    # Compute observed agreement
    agreements = sum(
        1 for a1, a2 in zip(annotations_1, annotations_2) if a1 == a2
    )
    p_o = agreements / n

    # Compute expected agreement by chance
    p_e = 0.0
    for label in labels:
        # Proportion of times annotator 1 chose this label
        p1 = sum(1 for a in annotations_1 if a == label) / n
        # Proportion of times annotator 2 chose this label
        p2 = sum(1 for a in annotations_2 if a == label) / n
        p_e += p1 * p2

    # Compute kappa
    if p_e == 1.0:
        return 1.0  # Perfect agreement

    kappa = (p_o - p_e) / (1 - p_e)
    return kappa


def compute_pairwise_agreement(dataset: PreferenceDataset) -> dict:
    """Compute agreement between all pairs of annotators."""
    # Group samples by prompt and annotator
    prompt_annotations = defaultdict(dict)
    for sample in dataset.samples:
        prompt_annotations[sample.prompt][sample.annotator_id] = (
            sample.preference
        )

    # Get all annotators
    annotators = sorted(set(s.annotator_id for s in dataset.samples))

    # Compute pairwise kappa
    agreement_matrix = {}
    for i, ann1 in enumerate(annotators):
        for ann2 in annotators[i + 1 :]:
            # Get annotations from both annotators for shared prompts
            ann1_labels = []
            ann2_labels = []
            for prompt, annotations in prompt_annotations.items():
                if ann1 in annotations and ann2 in annotations:
                    ann1_labels.append(annotations[ann1])
                    ann2_labels.append(annotations[ann2])

            if ann1_labels:
                kappa = compute_cohens_kappa(ann1_labels, ann2_labels)
                agreement_matrix[(ann1, ann2)] = kappa

    return agreement_matrix


agreement = compute_pairwise_agreement(dataset)
Out[13]:
Console
Pairwise Inter-Annotator Agreement (Cohen's Kappa):
  ann_1 vs ann_2: κ = 0.000 (slight)
  ann_1 vs ann_3_verbose: κ = 0.000 (slight)
  ann_1 vs ann_4_concise: κ = 0.000 (slight)
  ann_1 vs ann_5: κ = 0.000 (slight)
  ann_2 vs ann_3_verbose: κ = 0.167 (slight)
  ann_2 vs ann_4_concise: κ = 0.000 (slight)
  ann_2 vs ann_5: κ = -0.364 (slight)
  ann_3_verbose vs ann_4_concise: κ = 0.000 (slight)
  ann_3_verbose vs ann_5: κ = -0.364 (slight)
  ann_4_concise vs ann_5: κ = 0.000 (slight)

Average Kappa: -0.056

The agreement analysis reveals important patterns. Annotators with different biases (verbose versus concise preferences) show lower agreement with each other than they do with annotators who share their biases. The neutral annotators show moderate agreement with each other and mixed agreement with the biased annotators, depending on whether their quality judgments happen to align with the biased annotator's length-based heuristic. In real preference collection, observing this pattern would motivate investigating what drives the low-agreement annotator pairs and potentially removing or retraining the most extreme outliers.

Out[14]:
Visualization
Heatmap grid of pairwise Cohen's Kappa scores between five annotators, with green diagonal cells at 1.00 and mostly orange or red off-diagonal cells at low or negative agreement.
Pairwise Cohen's Kappa agreement matrix for five annotators. Apart from perfect self-agreement on the diagonal, most pairwise scores are near zero or negative. The low agreement involving differently biased annotators shows how inconsistent internal criteria can degrade the training signal if they are not detected and corrected.

Let's visualize the preference patterns across annotators to understand how their biases manifest in the data:

In[15]:
Code
annotators = sorted(set(s.annotator_id for s in dataset.samples))
preferences = ["a", "b", "tie"]

# Build data matrix
data = np.zeros((len(annotators), len(preferences)))
for i, ann in enumerate(annotators):
    ann_samples = [s for s in dataset.samples if s.annotator_id == ann]
    for j, pref in enumerate(preferences):
        data[i, j] = sum(1 for s in ann_samples if s.preference == pref)

# Normalize to percentages
data_pct = data / data.sum(axis=1, keepdims=True) * 100
Out[16]:
Visualization
Stacked bar chart of five annotators'' Response A and Response B selections: ann_1 is 100 percent A, ann_2 and ann_3_verbose are 60 percent A, ann_4_concise is 100 percent B, and ann_5 is 80 percent A.
Preference distributions for five annotators showing the proportion of A, B, and Tie selections in this small seeded example. The concise-preferring annotator selects Response B throughout, while the verbose-preferring annotator has a more modest 60/40 split. The neutral annotators vary substantially, illustrating why per-annotator diagnostics need enough samples before a pattern is treated as a stable bias.
Out[17]:
Visualization
Histogram of annotation completion times in seconds, with a dashed red vertical line at 10 seconds indicating the minimum reliable annotation threshold, and a solid green line showing the mean annotation time.
Distribution of annotation times in seconds across all 25 samples. The vertical dashed red line at 10 seconds marks the quality control threshold; annotations to the left of this line are flagged as potentially unreliable and subject to review. The distribution is right-skewed because longer responses require more reading time, with the mean typically falling between 20 and 40 seconds for the response lengths in this dataset.