You have spent two weeks on a churn model for 5,000 subscribers, and it scores 85.8% accuracy. A colleague asks a simple question: what would a model that always says "will not cancel" score? You run it. 94%. Higher than yours. Nothing was miscalculated. Only 6% of subscribers cancel, so the answer with no intelligence in it is right almost every time, and your two weeks of work had no reference point until now.

This post builds that reference point in a few lines, then uses it to decide whether the real model is worth shipping. The example is one SaaS product with 5,000 subscribers and two questions: will this subscriber cancel in the next 30 days, and how many support tickets will they open next month. The data below comes from a short generator script, not a real company.

What a baseline is, and the two dumbest ones

A baseline is the score you get from a rule that uses none of the real signal in your data, just its shape. It is the number a model has to clear before it has proven it learned anything. Call it the Floor: a model's score only means something once you know where the Floor sits under it.

For a label like "churned," the dumbest possible rule is the majority class baseline: predict whichever value shows up most often, for every row, regardless of the actual features. If 94% of subscribers do not cancel, predicting "will not cancel" for all 5,000 of them is right 94% of the time with zero information about any individual subscriber. That is class imbalance: one outcome so much rarer than the other that guessing the common one scores well by accident.

For a number like "tickets next month," the equivalent is the mean baseline: predict the average value for every row. Score it with MAE, mean absolute error, the average size of the miss in tickets, ignoring whether the guess ran high or low. A mean baseline minimizes squared error; when the metric is MAE, as it is here, a median baseline usually scores at least as well, worth checking on your own data.

Neither baseline touches a single feature. That is the point: they set the Floor a model with no real information would already reach, so you can tell how much of a model's score is signal and how much was free.

Build both in a few lines

Here is the generator, in full, so the numbers below are reproducible. It builds 5,000 subscriber rows, fixes the churn rate at about 6% by construction, and adds a ticket count for the second target.

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
n = 5_000

tenure_months       = rng.integers(1, 60, n)
logins_last_30d     = rng.poisson(8, n)
tickets_last_month  = rng.poisson(1.2, n)
monthly_spend       = rng.normal(45, 15, n).clip(10, 150)
plan_tier           = rng.choice(["basic", "pro", "enterprise"], n, p=[0.6, 0.3, 0.1])

# a risk score built from real signal, plus the noise real churn always has
risk = (
    -0.04 * tenure_months
    - 0.06 * logins_last_30d
    + 0.40 * tickets_last_month
    + rng.normal(0, 1, n)
)
cutoff = np.quantile(risk, 0.94)          # top 6% of risk churns, by construction
churned = (risk >= cutoff).astype(int)

tickets_next_month = rng.poisson(0.5 + 0.4 * tickets_last_month)

subscribers = pd.DataFrame({
    "tenure_months": tenure_months,
    "logins_last_30d": logins_last_30d,
    "tickets_last_month": tickets_last_month,
    "monthly_spend": monthly_spend,
    "plan_tier": plan_tier,
    "churned": churned,
    "tickets_next_month": tickets_next_month,
})

risk mixes three real signals, tenure, recent logins, last month's ticket count, with random noise, because real churn is never fully explained by the features you happen to have. Taking the top 6% of that risk score as "churned" fixes the churn rate by design, so the baseline below is not an estimate. It is exact.

from sklearn.dummy import DummyClassifier, DummyRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, mean_absolute_error, precision_score, recall_score

X = pd.get_dummies(subscribers.drop(columns=["churned", "tickets_next_month"]))

X_train, X_test, y_train, y_test = train_test_split(
    X, subscribers["churned"], test_size=0.2, stratify=subscribers["churned"], random_state=7
)
baseline = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
preds = baseline.predict(X_test)
print("accuracy:", accuracy_score(y_test, preds))                        # 0.940
print("precision:", precision_score(y_test, preds, zero_division=0))     # 0.0
print("recall:", recall_score(y_test, preds, zero_division=0))           # 0.0

Xr_train, Xr_test, yr_train, yr_test = train_test_split(
    X, subscribers["tickets_next_month"], test_size=0.2, random_state=7
)
mean_baseline = DummyRegressor(strategy="mean").fit(Xr_train, yr_train)
print("MAE:", mean_absolute_error(yr_test, mean_baseline.predict(Xr_test)))   # 0.83

Target

Baseline rule

Score

Churned in 30 days

predict "no" for every subscriber

94.0% accuracy

Tickets next month

predict about 1.0 for every subscriber

0.83 MAE

None of this involved tuning a single hyperparameter. In practice, building both baselines is closer to a ten-minute job than the two weeks the tuned model took, which is most of the argument for building it first.

Read the margin: what must the model beat?

The two Dummy baselines above have no real counterpart to compare against yet. Here is that missing piece, reusing the exact splits from the baseline code:

from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
from sklearn.metrics import recall_score, precision_score
from sklearn.model_selection import cross_val_score

# First attempt: a default RandomForestClassifier, no tuning.
model = RandomForestClassifier(random_state=7, n_estimators=300)
model.fit(X_train, y_train)
pred = model.predict(X_test)
print("accuracy:", accuracy_score(y_test, pred))                    # 0.933
print("recall:", recall_score(y_test, pred))                        # 0.050
print("precision:", precision_score(y_test, pred))                  # 0.231

cv_scores = cross_val_score(model, X, subscribers["churned"], cv=5, scoring="accuracy")
print("cv scores:", cv_scores)                                      # [0.938 0.939 0.937 0.933 0.942]
print("cv spread:", cv_scores.max() - cv_scores.min())              # 0.009

# Tuned: a RandomForestClassifier reweighted for this imbalanced target
# (6% positive). These specific parameters came out of a hyperparameter
# search, not a default fit -- how that search was run is its own topic
# for a different post.
model = RandomForestClassifier(
    random_state=7,
    n_estimators=700,
    class_weight="balanced",
    min_samples_leaf=10,
    max_depth=None,
    max_features="sqrt",
)
model.fit(X_train, y_train)
pred = model.predict(X_test)
print("accuracy:", accuracy_score(y_test, pred))                    # 0.858
print("recall:", recall_score(y_test, pred))                        # 0.467
print("precision:", precision_score(y_test, pred))                  # 0.203

cv_scores = cross_val_score(model, X, subscribers["churned"], cv=5, scoring="accuracy")
print("cv scores:", cv_scores)                                      # [0.853 0.853 0.845 0.846 0.85 ]
print("cv spread:", cv_scores.max() - cv_scores.min())              # 0.008

model_reg = RandomForestRegressor(random_state=7, n_estimators=300).fit(Xr_train, yr_train)
print("MAE:", mean_absolute_error(yr_test, model_reg.predict(Xr_test)))   # 0.832

model_reg_tuned = RandomForestRegressor(random_state=7, n_estimators=200, min_samples_split=5,
                                         min_samples_leaf=10, max_features=None,
                                         max_depth=5).fit(Xr_train, yr_train)
print("MAE:", mean_absolute_error(yr_test, model_reg_tuned.predict(Xr_test)))   # 0.790

A first attempt with a default RandomForestClassifier looks like a wash: accuracy comes in at 93.3%, actually a hair below the Floor's 94.0%, and recall is 5%, barely off the Floor's 0%. Nothing in a default fit tells the trees to pay extra attention to the 6% of subscribers who churn, so mostly they don't. class_weight="balanced" is what changes that: it reweights training so the model treats the rare churners as seriously as everyone else, and once it stops ignoring them it starts predicting "will churn" far more often than either the Floor or the default fit did. Every one of those extra churn predictions that turns out wrong costs a point of accuracy, on a target where 94% of subscribers were never going to churn anyway. That's why churn accuracy moves from 94.0% down to 85.8% once the reweighting kicks in, a drop of more than eight points. That looks like a step backward, and a leaderboard glance would call it one. Don't stop there: that trade is exactly what recall was built to measure.

Recall is the share of real churners the model finds: true positives divided by all actual positives. The majority-class baseline predicts "no churn" for every subscriber, so its recall on churners is exactly 0%. It catches none of them, no matter how good 94% sounds next to it. The default fit barely moves that, to 5%: it keeps almost all of the Floor's accuracy and almost none of its usefulness. Only the tuned model, trading accuracy for reweighting, lifts recall to 46.7%, finding nearly half of the subscribers who were about to cancel, for an eight-point drop in accuracy from the Floor. That is where the two weeks went.

The trade is not free: precision on those same predictions is 20.3%, so of every five subscribers the model flags as a churn risk, only one actually cancels. Whether the trade is worth it depends on what a false alarm costs the team acting on it, a retention email is cheap to waste on the other four; a steep discount offer is not.

So what has to be true before this model is worth shipping? Three things.

  1. Its margin over the Floor has to be measured in the metric the decision needs, recall here, not accuracy, because accuracy alone would read as a loss.

  2. The margin has to beat the spread you would see from one cross-validation fold to the next, the same model scored on different slices of the same data: five folds on this data moved accuracy across 0.853, 0.853, 0.845, 0.846, and 0.85, a spread of under a point. The eight-point drop in accuracy dwarfs that spread, so it is signal, not fold-to-fold luck.

  3. The gain has to be worth what the model costs to keep running: retraining, monitoring, a slower prediction path than "always say no."

A change that does not clear the Floor by more than fold-to-fold noise, in the metric that matters, by a margin that pays for its own upkeep, is not a decision yet.

Target

Metric

Baseline

Default model

Tuned model

Churned in 30 days

Accuracy

94.0%

93.3%

85.8%

Churned in 30 days

Recall

0%

5.0%

46.7%

Churned in 30 days

Precision

0%

23.1%

20.3%

Tickets next month

MAE

0.83

0.83

0.79

Figures above are from one executed run of the code above (an 80/20 split, random_state=7). Baseline accuracy (94.0%) and baseline recall/precision (0%) are exact by construction: the generator fixes churn at 6% by quantile cutoff, and a majority-class rule never predicts churn.

What tuning buys you, and what it doesn't

The churn target just showed that pattern once: a default fit that barely earns its keep, and a tuned fit that trades one metric for another. Run the same procedure on the second target, tickets next month, and the first attempt looks like the baseline already wins. The mean baseline scores an MAE of about 0.83 tickets: on average, guessing "about 1 ticket" for every subscriber misses by under a ticket. A default RandomForestRegressor lands at about 0.83 tickets too, a gain of essentially zero.

Before writing the target off, tune the way you would for any target: constrain how deep and how greedy the trees are allowed to get (max_depth=5, min_samples_leaf=10, min_samples_split=5) instead of only adding more trees. That tuned model gets an MAE of about 0.79, a real 0.04-ticket improvement over the baseline. The default model wasn't proof the target lacks signal; it was an untuned model that hadn't found the signal yet.

Put that 0.04 through the same three checks as the churn model. Does it beat the spread you'd see from one cross-validation fold to the next on this target? A margin this small needs that check more than a large one does. Does it pay for what the model costs? A support team acting on a prediction that's off by 0.79 tickets instead of 0.83 is unlikely to change staffing on its own. The honest read isn't "the Floor won" or "the model won": tuning showed exactly how much room there was, and that room was small.

This is the case most tutorials skip: not every target has a gain worth the cost of chasing it, and the only way to know how large the gain really is is to tune before comparing, then measure the tuned model's margin over the Floor, not the first attempt's.

Takeaway

  • A model's score means nothing on its own. It means something next to the Floor: the score a rule with no real information would already get.

  • Build both dumbest baselines in a few lines: majority class for a label, mean for a number, straight out of DummyClassifier and DummyRegressor.

  • Judge the margin in the metric the decision needs. On an imbalanced target, accuracy can hide a real gain, or hide that there isn't one; recall showed it here.

  • A margin only counts if it beats the fold-to-fold spread and pays for what the model costs to run and maintain, not just clears zero.

  • A model that doesn't beat the Floor might just be untuned, not proof the target lacks signal. On the ticket-count target here, tuning turned a ~0.00 gain into a real but modest 0.04 one, small enough to still question whether it's worth shipping.