Machine Learning · Module 24 of 26 · 11 concepts · about 21 min

ML Experimentation

Running modelling as a sequence of recorded, reproducible comparisons — and knowing when an observed difference is real.

At work: Proving that a new model actually makes the business better, with tests that stand up to scrutiny.

One change, one measurement

What it is

An experiment is a written hypothesis, exactly one deliberate change from a named baseline, and a metric decided before the run. Everything else — split, seed, data version, evaluation code — is held fixed.

Why it matters

Without it, a project becomes one long undifferentiated attempt whose current state nobody can explain. With it, every result is attributable: you can say which of nine changes produced the gain, and you can undo the eight that did not.

In the real world: Changing both the features and the model in one go leaves nobody sure which one helped.

Visual example

runs.jsonl

A run log makes a project legible: what changed, against what, and by how much.

read the spread

(not just the mean)

one line per experiment
runone change from baselineCV AUCverdict
exp-009baseline: logistic + scaler0.762 ± 0.019baseline
exp-010+ spend_7d rolling feature0.788 ± 0.017keep
exp-011swap to gradient boosting0.795 ± 0.021keep
exp-012group split (bug fix)0.791 ± 0.014keep, re-baseline

exp-010 vs exp-009

+0.026, larger than either spread

exp-011 vs exp-010

+0.007, well inside the spread

Two real gains and one that is indistinguishable from fold noise

Which differences survive the spread

exp-010: +0.026 against a fold sd of 0.017-0.019 — plausibly real

exp-011: +0.007 against a fold sd of 0.021 — a coin flip dressed as progress

Boosting may still be worth keeping for other reasons, but not on this evidence

Why this matters

The log does not just record history; it prices each change against the noise floor, which is the only way to know whether the last two weeks produced anything.

Common misconception

Wrong idea: the run with the best mean score is the best model.

Correct idea: the best model is the one whose advantage exceeds the uncertainty of the comparison. A 0.007 improvement measured with a 0.021 spread is not a result.

import json, hashlib, pathlib, datetime

def log_run(name, hypothesis, params, metrics, data_version, seed):
    record = {
        'name': name,
        'hypothesis': hypothesis,
        'params': params,
        'metrics': metrics,
        'data_version': data_version,
        'seed': seed,
        'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }
    record['id'] = hashlib.sha1(json.dumps(record, sort_keys=True).encode()).hexdigest()[:10]
    pathlib.Path('runs.jsonl').open('a').write(json.dumps(record) + '\n')
    return record['id']

log_run(
    name='exp-012-group-split',
    hypothesis='the random split leaked users; '
               'a group split will lower AUC to the honest value',
    params={'cv': 'StratifiedGroupKFold(5)', 'model': 'hgb', 'lr': 0.05},
    metrics={'cv_auc_mean': 0.791, 'cv_auc_std': 0.014},
    data_version='snap-2026-09-01', seed=0,
)

The tempting shortcut is to bundle a data fix with a model change because both look like improvements. If the pair nets out to zero you will discard both, including the one that worked; if it gains, you will keep the one that does not. Land them as two runs even when it costs an extra hour.

What to log for a run to be reproducible

What it is

Six things, and a result is only comparable if all six are captured: the data version (a snapshot id or content hash, not 'the prod table'), the code version (a commit sha, with a note if the tree was dirty), the full resolved hyperparameters, the random seeds, the environment (library versions), and the metrics with their evaluation protocol.

Why it matters

A metric without these is an anecdote. Three months later the question is always 'why can I not reproduce 0.83' and the answer is always one of the six — usually the data snapshot, because it is the only one that changes without anybody editing anything.

In the real world: Recording the data version, code and settings so a model from six months ago can be rebuilt for an audit.

Visual example

Reproducibility inputs

The six axes of a run. Change one silently and the number stops being comparable.

the usual culprit

(data moved, nothing else did)

metrics + protocol

'AUC 0.79' needs the split to be a number

what makes 0.791 mean something
axisrecorded asif unlogged
datasnap-2026-09-01drifts weekly, silently
codegit sha + dirty flagcannot rebuild the run
paramsget_params(deep=True)defaults change per version
seed0, per generator±0.01 of free noise
envnumpy/sklearn versionsbehaviour changes under you

params: get_params(deep=True)

resolved values, not the ones you typed

Six logged axes turn a score into a claim somebody else can check

Why this matters

Reproducibility here is not a virtue for its own sake: it is the precondition for comparing two runs at all. Two numbers produced under unknown conditions cannot be subtracted.

Common misconception

Wrong idea: logging the hyperparameters and the metric is enough.

Correct idea: the data snapshot is the axis that moves without anyone changing code, and it is the one most often missing. A rerun on fresh data is a different experiment, not a replication.

import subprocess, sys, sklearn, numpy as np, platform

def environment():
    out = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True)
    sha = out.stdout.strip()
    status = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True)
    dirty = bool(status.stdout)
    return {
        'git_sha': sha,
        'git_dirty': dirty,
        'python': sys.version.split()[0],
        'platform': platform.platform(),
        'numpy': np.__version__,
        'sklearn': sklearn.__version__,
    }

meta = {'env': environment(), 'seed': 0, 'data_version': 'snap-2026-09-01',
        'params': pipe.get_params(deep=True), 'protocol': 'StratifiedGroupKFold(5), roc_auc'}

git_dirty: true invalidates the commit sha as a record of what ran, and it is true for most exploratory runs. Either commit before a run you intend to cite, or log the diff itself — a sha that does not describe the executed code is worse than no sha, because it looks authoritative.

Offline versus online evaluation

What it is

Offline evaluation scores a model against frozen historical data with the labels already known. Online evaluation puts the model in front of real traffic and measures what actually happens — clicks, conversions, losses prevented, time saved.

Why it matters

They measure different things and can disagree. Offline asks 'would this model have ranked the past correctly'; online asks 'does acting on this model change behaviour'. A recommender that predicts historical clicks well may simply be predicting what the old recommender showed.

In the real world: A recommender that wins on historical data still has to prove itself with real users in a live test.

Visual example

Offline vs online

Ten shipped models: offline gain against online gain. The relationship is positive and loose.

Shipped experimentsBest-fit relationship
offline gain too small to trust0.010.020.030.040.050.06-0.500.511.522.53Offline AUC gain vs controlOnline conversion lift (%)no online effectoffline +0.03, online -0.2%

What the scatter says

correlation: r = 0.72 — offline gain predicts online lift, but explains only about half its variance

below zero: 3 of 10 experiments with a positive offline gain had no positive online effect

Practical rule: use offline evaluation to decide what is worth an A/B test, never to declare a win

Why this matters

This is exactly why the release path has two stages. Offline evaluation is the cheap filter that stops you spending two weeks of traffic on a candidate that is not even better historically.

Common misconception

Wrong idea: a model that wins offline will win online; the A/B test is a formality.

Correct idea: offline evaluation scores ranking of the past. Online evaluation measures changed behaviour in the present, under a feedback loop the historical log cannot contain.

# Offline: one number per candidate, on a frozen sample.
offline = {m: roc_auc_score(y_val, p.predict_proba(X_val)[:, 1]) for m, p in candidates.items()}

# Online: the metric the business actually cares about, on live traffic.
def assign_variant(user_id, salt='exp-014', variants=('control', 'challenger')):
    import hashlib
    h = hashlib.md5(f'{salt}:{user_id}'.encode()).hexdigest()
    return variants[int(h, 16) % len(variants)]     # stable, so a user never flips

# online_metrics: conversion rate per variant, over the assignment period.

Feedback loops make offline evaluation on logged data structurally optimistic. The log only contains outcomes for items the previous model chose to show, so a new model that would have shown something different has no ground truth at all — which is why an offline win of +0.04 AUC can land as a flat online result.

Practise it: A Split That Does Not Leak Across Groups

A/B test mechanics

What it is

Randomise at the level of the entity that must have a consistent experience — usually the user, not the request — with a stable hash so the assignment survives across sessions. Fix one primary metric in advance, declare guardrail metrics (latency, error rate, complaint rate), and decide the run length before starting.

Why it matters

Randomisation is what makes the comparison causal: the two groups differ only by which model they saw, so the difference in outcome is attributable to the model rather than to who happened to arrive. Randomise per request and one user sees both variants, which both contaminates the comparison and looks broken to them.

In the real world: Showing half of users the new recommendation model and comparing purchases fairly.

import hashlib
import numpy as np

def bucket(user_id, salt, buckets=100):
    h = hashlib.sha256(f'{salt}:{user_id}'.encode()).digest()
    return int.from_bytes(h[:8], 'big') % buckets

def variant(user_id, salt='exp-014', treatment_share=0.5):
    return 'treatment' if bucket(user_id, salt) < treatment_share * 100 else 'control'

# Sanity checks before reading any result:
users = [f'u-{i}' for i in range(20000)]
assign = np.array([variant(u) for u in users])
print((assign == 'treatment').mean())          # ~0.50: the split is balanced
print(all(variant(u) == variant(u) for u in users[:100]))   # stable across calls

A sample ratio mismatch — 50/50 intended, 47/53 observed — invalidates the whole experiment and is a bug in the assignment or the logging, not a curiosity. Check it before reading the metric, because a broken randomisation makes every subsequent number meaningless.

Significance, intervals and the noise floor

What it is

Two conversion rates measured on finite samples will differ even when the underlying rates are identical. A significance test asks how likely a difference this large is under the assumption that there is no real effect; a confidence interval on the difference reports the range of effects the data is consistent with.

Why it matters

Without it, every experiment 'wins' roughly half the time, and a team can ship a year of changes that net to zero while every launch document reported a gain. The interval also answers the question the p-value does not: how big might the effect actually be?

In the real world: Deciding whether a 0.3% lift in conversions is real or just daily noise.

Visual example

Conversion lift, 95% CI

The observed difference wanders at first and settles: the interval narrows as the sample grows.

Upper 95% boundObserved differenceLower 95% bound
interval straddles zero — undecided010203040-2-10123Users per arm (thousands)Treatment - control (pp)no effectCI clears zero at 40k

The same experiment, read at three moments

2k/arm: +1.2pp, CI [-2.1, +3.1] — consistent with a large loss and a large win

5k/arm: -0.4pp — had you stopped here you would have killed a working change

40k/arm: +0.3pp, CI [+0.02, +0.6] — small, real, and finally measurable

Standard error shrinks with sqrt(n): quartering the interval costs sixteen times the traffic

Why this matters

The point estimate at 2k and the point estimate at 40k differ by 0.9pp with no change in the world. Everything the early reading told you was the width of that interval.

Common misconception

Wrong idea: the observed difference is the effect, and more data just makes you more confident in it.

Correct idea: the early observed difference is mostly noise. More data does not confirm the early estimate; it usually replaces it, and with a small true effect the sign itself can flip.

import numpy as np
from scipy import stats

n_c, x_c = 40000, 2000      # control:   5.00%
n_t, x_t = 40000, 2120      # treatment: 5.30%
p_c, p_t = x_c / n_c, x_t / n_t

diff = p_t - p_c
se = np.sqrt(p_c * (1 - p_c) / n_c + p_t * (1 - p_t) / n_t)
ci = (diff - 1.96 * se, diff + 1.96 * se)
z = diff / se
p_value = 2 * (1 - stats.norm.cdf(abs(z)))
print(f'diff {diff:+.4f}  95% CI [{ci[0]:+.4f}, {ci[1]:+.4f}]  z {z:.2f}  p {p_value:.4f}')
# diff +0.0030  95% CI [+0.0000, +0.0060]  z 1.96  p 0.0498

A p-value is not the probability that the treatment is better, and failing to reach significance is not evidence that the effect is zero — with a small sample, a real 3% lift routinely fails to clear the bar. Report the interval: '[-0.5%, +4.1%]' honestly says 'we still do not know', where 'not significant' is often misread as 'no effect'.

Practise it: A Bootstrap Confidence Interval for a Metric

Sample size and the minimum detectable effect

What it is

Before running anything, compute how much traffic is needed to detect the smallest effect worth acting on, given the baseline rate, the desired power (conventionally 80%) and the significance level (conventionally 5%). The result is either a run length or the discovery that the experiment is impossible.

Why it matters

Most failed experiments were under-powered from the start: the honest conclusion was never available at that traffic level. Computing the minimum detectable effect first turns two wasted weeks into a five-minute decision about whether to run at all.

In the real world: Working out that the test needs 200,000 users to detect a 1% lift, before starting it.

Visual example

Power planning, 5% baseline

Halving the effect you want to detect quadruples the traffic required.

not runnable

(52 days of all traffic)

n per arm for 80% power at alpha = 0.05
relative liftabsolute liftusers per armdays at 120k/day
10%0.50pp31,0000.5
5%0.25pp125,0002.1
2%0.10pp784,00013.1
1%0.05pp3,140,00052.3

n_per_arm(0.05, 0.05)

a two-day test — plan around this

n_per_arm(0.05, 0.01)

the effect is real and undetectable here

Decide the minimum effect worth shipping first; the run length follows from it

The square law, worked

10% -> 5%: effect halves, n goes 31,000 -> 125,000 (x4)

5% -> 2.5%: effect halves again, n x4 again

n proportional to 1 / effect^2 — the single most useful fact in experiment planning

Why this matters

This table converts an argument about whether to ship into an arithmetic question about traffic. If the smallest interesting effect needs more traffic than exists, the A/B test is not the right instrument.

Common misconception

Wrong idea: running the test longer will eventually resolve any effect.

Correct idea: eventually can be years. At a fixed traffic rate there is a floor on the effect you can detect, and effects below it have to be decided on other grounds.

import numpy as np
from scipy import stats

def n_per_arm(baseline, rel_lift, alpha=0.05, power=0.8):
    p1 = baseline
    p2 = baseline * (1 + rel_lift)
    pbar = (p1 + p2) / 2
    z_a = stats.norm.ppf(1 - alpha / 2)
    z_b = stats.norm.ppf(power)
    return int(np.ceil(2 * pbar * (1 - pbar) * (z_a + z_b) ** 2 / (p2 - p1) ** 2))

for lift in (0.01, 0.02, 0.05, 0.10):
    n = n_per_arm(0.05, lift)
    print(f'{lift:.0%} relative lift -> {n:>9,} users per arm '
          f'-> {n * 2 / 120000:.1f} days at 120k/day')

Sample size scales with the inverse square of the effect: detecting a 1% relative lift costs a hundred times the traffic of a 10% one. Teams routinely plan a two-week test for an effect that would need two years, then read the inconclusive result as 'the model did not help'.

Reproducibility as engineering discipline

What it is

Three habits: explicit seeds carried in the config rather than hardcoded at the call site, a pinned environment (a lockfile, and ideally an image), and versioned data referenced by snapshot id or content hash. Every generator is created from the config's seed and passed explicitly.

Why it matters

It is what makes a difference between two runs attributable to the change rather than to the machine. It also makes a regression findable: when a score drops, you can rebuild last week's exact conditions and bisect.

In the real world: Anyone on the team can rerun last quarter's pricing model and get exactly the same numbers.

import numpy as np

CONFIG = {'seed': 20260919, 'data_version': 'snap-2026-09-01', 'n_folds': 5}

root = np.random.default_rng(CONFIG['seed'])
split_rng, init_rng, aug_rng = root.spawn(3)      # independent, reproducible streams

# Each consumer gets its own generator; none share hidden global state.
folds = make_folds(n=len(X), k=CONFIG['n_folds'], rng=split_rng)
model = build_model(random_state=int(init_rng.integers(0, 2**31 - 1)))

Seeding the global np.random.seed and believing the run is pinned — the same trap the NumPy module's Randomness & Sampling section named, and it resurfaces here with higher stakes. Anything in the process that draws from the global generator shifts your numbers, and library code does draw from it. Also remember that seeds do not pin thread counts, GPU reduction order or library versions: a bit-identical rerun needs the environment pinned too.

Practise it: A Bootstrap Confidence Interval for a Metric, A Split That Does Not Leak Across Groups, Permutation Importance, Averaged Honestly

Comparing models fairly

What it is

The same folds, the same metric, the same preprocessing discipline, and a spread reported beside every mean. For a paired comparison use the per-fold differences — the same folds scored by both models — rather than comparing two independent means.

Why it matters

Fold-to-fold variance is usually larger than the difference between two reasonable models, so an unpaired comparison of means is mostly reading noise. Pairing removes the shared fold difficulty and often turns an ambiguous comparison into a clear one.

In the real world: Testing both models on the same data, splits and metric before declaring a winner.

Visual example

Paired comparison

Unpaired, the two models are a wash. Paired on identical folds, B wins every fold.

fold difficulty

(varies by 0.07 — shared by both)

b.mean() - a.mean()

+0.010, against a fold sd of 0.028

ROC AUC, identical StratifiedKFold(5, random_state=0)
foldmodel Amodel BB - A
10.8020.811+0.009
20.7710.783+0.012
30.8290.837+0.008
40.7580.769+0.011
50.7950.806+0.011

ttest_rel(b, a)

paired sd 0.0016 — p = 0.0002

Same numbers, opposite conclusions: pairing is what makes the 0.010 legible

Where the noise went

unpaired: A 0.791 ± 0.028, B 0.801 ± 0.028 — the means overlap heavily

paired: differences +0.009, +0.012, +0.008, +0.011, +0.011 — sd 0.0016, 5/5 positive

Fold difficulty (sd 0.028) is common to both models and cancels exactly in the difference

Why this matters

The fold-to-fold spread is nearly twenty times the effect. Comparing unpaired means throws away the one fact that makes the effect visible: both models saw the same folds.

Common misconception

Wrong idea: an improvement smaller than the cross-validation standard deviation is undetectable.

Correct idea: it is undetectable unpaired. Most of that standard deviation is fold difficulty, which cancels when you subtract fold by fold.

import numpy as np
from scipy import stats
from sklearn.model_selection import StratifiedKFold, cross_val_score

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)   # identical folds for both
a = cross_val_score(pipe_a, X, y, cv=cv, scoring='roc_auc')
b = cross_val_score(pipe_b, X, y, cv=cv, scoring='roc_auc')

d = b - a
print(f'A {a.mean():.3f} ± {a.std():.3f}   B {b.mean():.3f} ± {b.std():.3f}')
print(f'paired diff {d.mean():+.4f} ± {d.std():.4f}')
print(stats.ttest_rel(b, a))

Two cross_val_score calls with different random_state values are not a comparison: part of the gap is simply that one model got easier folds. Build the splitter once and pass the same object to both — and never quote a mean without its spread, because the spread is what decides whether the mean's difference means anything.

Shadow deployment and champion-challenger

What it is

Shadow mode runs the new model on real production traffic and logs its predictions without letting them affect any decision. Champion-challenger keeps the incumbent serving while one or more challengers score the same requests, and promotes a challenger only on accumulated evidence.

Why it matters

It separates two risks that a straight cutover bundles together: 'does this model behave sanely on real inputs at real volume' and 'does acting on it improve the outcome'. Shadow mode answers the first for free, catching schema mismatches, latency blowups, null explosions and absurd prediction distributions before any user is affected.

In the real world: Running a new fraud model silently alongside the current one to compare decisions before switching.

def handle(request, champion, challenger, log):
    features = build_features(request)

    decision = champion.predict_proba(features)[0, 1]          # this is what ships

    try:
        shadow = float(challenger.predict_proba(features)[0, 1])   # observed, never acted on
        error = None
    except Exception as exc:
        shadow, error = None, repr(exc)

    log.write({'request_id': request['id'], 'champion': float(decision),
               'challenger': shadow, 'challenger_error': error})
    return decision

Shadow mode cannot measure a causal effect, because nobody ever saw its output — the shadow model's agreement with the champion says nothing about whether acting on it would have been better. It is a safety and distribution check, and it must be followed by a real randomised test. Also wrap the shadow call: an unguarded challenger exception takes the live request down with it.

Peeking

What it is

Checking an experiment's significance repeatedly as data arrives and stopping the moment it crosses the threshold. Each look is another chance to cross by luck, so the real false-positive rate is far above the nominal 5% — with continuous monitoring it approaches certainty.

Why it matters

It is the single most common way a team convinces itself of effects that are not there. The fix is either to fix the sample size in advance and look once, or to use a method designed for continuous monitoring: a sequential test, an alpha-spending boundary, or a Bayesian stopping rule.

In the real world: Stopping an A/B test the moment it looks good leads to launching changes that don't actually work.

Visual example

Optional stopping

Two identical arms, no real effect: the z-statistic wanders and crosses the line twice by chance.

1234-3-2-10123Users per arm (thousands)z-statistic+1.96-1.96truth'treatment wins!''treatment loses!'

The arithmetic of many looks

1 look: false positive rate 5% — as advertised

15 looks: simulated rate ~25%: one experiment in four 'wins' with no effect at all

continuous: the boundary is crossed with probability approaching 1 given enough time

Fix: pre-register n and look once, or spend alpha across planned looks

Why this matters

The wandering line is not a badly run experiment; it is what a null effect looks like. Any rule that stops on the first crossing will stop on noise, and it will stop on the upward crossing first more often than you notice.

Common misconception

Wrong idea: if it is significant now, it is significant — the data can only get stronger.

Correct idea: significance computed at a moment you chose because it looked good is not a 5% test. The threshold is only valid for a sample size fixed before the data arrived.

import numpy as np

rng = np.random.default_rng(0)
false_positives = 0
trials = 2000
for _ in range(trials):
    a = rng.random(4000) < 0.05      # identical true rates: no effect exists
    b = rng.random(4000) < 0.05
    crossed = False
    for n in range(500, 4001, 250):  # peek every 250 users, 15 looks
        pa, pb = a[:n].mean(), b[:n].mean()
        se = np.sqrt(pa * (1 - pa) / n + pb * (1 - pb) / n) + 1e-12
        if abs((pb - pa) / se) > 1.96:
            crossed = True
            break
    false_positives += crossed
print(false_positives / trials)      # ~0.25, not 0.05

The asymmetry is what makes peeking so persuasive: nobody stops an experiment early because it looks *bad*. Optional stopping in one direction only guarantees that the effects you ship are biased upward, which is exactly the pattern of a launch history full of gains and a flat top-line metric.

Multiple comparisons

What it is

Testing many hypotheses at once — twenty features, twelve segments, five metrics, eight model variants — inflates the chance that at least one crosses the threshold by luck. At 5% per test, twenty independent tests have a 64% chance of at least one false positive.

Why it matters

Subgroup analysis is where this bites hardest in ML. Slice an experiment by country, device, plan and tenure bucket and something will always look significant, and there will always be a plausible-sounding story for whichever slice it was.

In the real world: Testing 20 variants at once guarantees one will look like a winner by pure chance.

Visual example

Family-wise error

The probability of at least one false positive, against how many tests you ran at 5% each.

No correction (alpha = 0.05)Bonferroni (alpha / m)
routine subgroup slicing lives here010203040506000.20.40.60.81Number of independent testsP(at least one false positive)intended 5%20 slices -> 64%

Where 64% comes from

one test: P(no false positive) = 0.95

20 tests: P(none) = 0.95^20 = 0.36, so P(at least one) = 0.64

bonferroni: test each at 0.05/20 = 0.0025; P(at least one) back to ~0.05

Cost of the correction: real effects below the smaller threshold are now missed

Why this matters

Four slices by country, three by device and two by plan is twenty-four tests, which almost guarantees a 'finding'. The curve is the reason that finding needs its own experiment before anyone acts on it.

Common misconception

Wrong idea: each test was run correctly at 5%, so the overall conclusion is sound at 5%.

Correct idea: 5% applies per test. The claim 'something here is significant' is a claim about the whole family, and its error rate is 1 - 0.95^m.

import numpy as np
from statsmodels.stats.multitest import multipletests

p_values = np.array([0.003, 0.011, 0.028, 0.041, 0.046, 0.19, 0.31, 0.52, 0.68, 0.84,
                     0.02, 0.033, 0.049, 0.07, 0.12, 0.25, 0.44, 0.61, 0.77, 0.91])

for method in ('bonferroni', 'fdr_bh'):
    reject, adjusted, _, _ = multipletests(p_values, alpha=0.05, method=method)
    print(f'{method:<11} survivors: {reject.sum():>2}  '
          f'smallest adjusted p: {adjusted.min():.4f}')
# bonferroni  survivors:  1   smallest adjusted p: 0.0600
# fdr_bh      survivors:  5   smallest adjusted p: 0.0600

Bonferroni is safe and brutally conservative; Benjamini-Hochberg controls the false discovery rate and is usually the right choice for exploratory slicing. But the real discipline is upstream: name the primary metric and the primary segment before the run, and treat every other cut as a hypothesis to be tested in the *next* experiment rather than a result from this one.