A crop classifier confuses two crops every year

On this page

Problem statement

The overall accuracy is 84.5% and a single pair of classes accounts for most of the errors. On the Flevoland parcels used on this page, 41 of the 50 red-onion fields are predicted as yellow onion; elsewhere it is winter wheat as winter barley, or a cover crop as grassland — and adding more trees, more features or a different classifier changes nothing.

That is the signature of a problem the model cannot solve: the two classes are genuinely similar in the features provided. Either the feature that separates them is missing, or the dates that would show it were cloudy, or the labels themselves are unreliable for one of the pair.

Quick answer

Read the confusion matrix, then look at the two classes' mean profiles:

import numpy as np, pandas as pd
from sklearn.metrics import confusion_matrix

print(f"overall accuracy {(pred == y).mean():.3f} on {len(y)} parcels")
labels = sorted(np.unique(y))
cm = pd.DataFrame(confusion_matrix(y, pred, labels=labels), index=labels, columns=labels)

off = cm.values.copy()
np.fill_diagonal(off, 0)
i, j = np.unravel_index(off.argmax(), off.shape)
print(f"largest confusion: {labels[i]} → {labels[j]} "
      f"({off[i, j]} of {cm.values[i].sum()}, {off[i, j] / cm.values[i].sum():.1%})")

for a, b in [(labels[i], labels[j])]:
    pa = X[y == a].mean(axis=0)
    pb = X[y == b].mean(axis=0)
    sep = np.abs(pa - pb) / np.sqrt((X[y == a].var(axis=0) + X[y == b].var(axis=0)) / 2)
    print("best separating feature:", feature_names[int(np.nanargmax(sep))],
          f"(effect size {np.nanmax(sep):.2f})")
overall accuracy 0.845 on 890 parcels
largest confusion: red onion → yellow onion (41 of 50, 82.0%)
best separating feature: ndvi_07-02 (effect size 0.43)

The two onions peak at 0.43, on NDVI in early July: nothing in eleven dates of four indices tells them apart. An effect size below about 0.5 on every feature means the pair is not separable with what you have. Above 1.5 on one date says the class means differ, and that the information may be there for a feature to use — a different problem with a different fix. It does not promise the fix will work: Example 3 shows a pair at 1.58 that a targeted feature barely helps, because an effect size compares averages and ignores how many of the larger class overlap the smaller one.

Five causes of a persistent two-class confusion. the same pair, every yearcausefixprofiles overlap everywheremerge the classes and say sothe separating date was cloudyadd a sensor, not a modelthe index saturates over thewindowuse a red-edge index therethe feature is buried amonggenericsadd one targeted featurethe labels are declarationsaudit the training labelsOnly the fourth is fixed by changing the model spatialworkflow.io
Five causes; only one of them is fixed by changing the model.

One pair or general noise, mean profiles, critical dates

1. Confirm it is one pair, not general noise

A confusion matrix with one large off-diagonal cell is a different problem from one with errors spread everywhere. The first is a feature problem; the second is usually a labelling or a validation problem.

2. Plot the two classes' mean temporal profiles

If they overlap everywhere, no classifier can separate them. If they diverge on two dates in April, the model has the information and the features are burying it.

3. Check whether the separating date was cloudy

Two cereals differ most at heading and at senescence, which are narrow windows. If the nearest clear observation is three weeks away, the interpolated value at the critical date is an average of the two sides of the gap. Count the clear observations near each class's divergence.

4. Check the index for saturation over the critical window

Two cereals at heading are both at full canopy, where NDVI is flat. On a June 2025 scene over this same part of Flevoland, 50.7% of vegetation pixels were above NDVI 0.8, and across 379 dense arable parcels NDRE spread 0.231 between fields where NDVI spread 0.123.

5. Add a feature that targets the difference

A red-edge index at heading, a shortwave index at senescence, the slope of the falling limb, or the date each class reached a thermal threshold. One targeted feature usually beats a hundred generic ones.

6. Check the labels

Declared crop is the intended crop. A field declared as barley and sown with wheat is a permanently mislabelled training sample, and a pair of similar crops is exactly where declaration errors concentrate.

7. If it still cannot be separated, merge the classes

A "winter cereal" class that is 96% accurate is more useful than wheat and barley classes that are each 70% accurate. Merging is a legitimate result, and it should be reported as one.

Class profiles that overlap everywhere against profiles that diverge briefly. no separationnot fixable with these dataa narrow windowadd a feature thereAn effect size below about 0.5 everywhere means the pair is not separable spatialworkflow.io
If the profiles never diverge, the fix is data; if they diverge briefly, the fix is a feature.

A separability report for two crops

The data used on this page

Every 2025 parcel of nine crops, of at least a hectare, in the Dutch parcel register BRP Gewaspercelen within 5.55–5.75° E, 52.48–52.56° N in Flevoland — the box the crop phenology and NDVI saturation guides use. The crops include three pairs that could plausibly be confused: yellow and red onions, ware and seed potatoes, temporary and permanent grassland. The features are four indices — NDVI, NDRE, NDWI and a shortwave-infrared ratio — as each parcel's median over clear pixels (vegetation or bare soil in the scene classification) on eleven nearly cloud-free Sentinel-2 dates from March to September 2025, read at 20 m from Earth Search, with the parcel shrunk 20 m to keep edge pixels out. A date a parcel was clouded on is filled from its neighbours in time. The classifier is a 500-tree random forest scored with 5-fold cross-validation; each parcel is one sample.

import os

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import requests
from rasterio.enums import Resampling
from rasterio.features import rasterize
from rasterio.windows import bounds as window_bounds, from_bounds
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_predict

os.environ.update(AWS_NO_SIGN_REQUEST="YES", GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR")
CROPS = {"Tarwe, winter-": "winter wheat", "Aardappelen, consumptie": "ware potato",
         "Aardappelen, poot NAK": "seed potato", "Uien, gele zaai-": "yellow onion",
         "Uien, rode zaai-": "red onion", "Bieten, suiker-": "sugar beet", "Winterpeen, productie": "carrot",
         "Grasland, tijdelijk": "temporary grass", "Grasland, blijvend": "permanent grass"}
SCENES = ["S2B_31UFU_20250307_0_L2A", "S2A_31UFU_20250403_0_L2A", "S2C_31UFU_20250501_0_L2A",
          "S2A_31UFU_20250513_0_L2A", "S2B_31UFU_20250519_0_L2A", "S2A_31UFU_20250612_0_L2A",
          "S2C_31UFU_20250630_0_L2A", "S2A_31UFU_20250702_0_L2A", "S2A_31UFU_20250814_1_L2A",
          "S2C_31UFU_20250819_0_L2A", "S2B_31UFU_20250906_0_L2A"]

if not os.path.exists("parcel_series.csv"):
    url, params, feats = ("https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections/brpgewas/items",
                          {"f": "json", "bbox": "5.55,52.48,5.75,52.56", "limit": 1000}, [])
    while url:
        page = requests.get(url, params=params, timeout=120).json()
        feats += page["features"]
        url, params = next((l["href"] for l in page["links"] if l["rel"] == "next"), None), None
    brp = gpd.GeoDataFrame.from_features(feats, crs=4326)
    brp["ha"] = brp.to_crs(28992).area / 1e4
    parcels = brp[brp["gewas"].isin(CROPS) & (brp["ha"] >= 1)].reset_index(drop=True)
    parcels["crop"], parcels["pid"] = parcels["gewas"].map(CROPS), np.arange(1, len(parcels) + 1)
    items = {i["id"]: i for i in requests.post("https://earth-search.aws.element84.com/v1/search", timeout=120,
             json={"collections": ["sentinel-2-l2a"], "ids": SCENES, "limit": 50}).json()["features"]}
    utm = parcels.to_crs(32631)
    box = utm.total_bounds + np.array([-40, -40, 40, 40])
    rows = []
    for scene in SCENES:
        a = items[scene]["assets"]
        with rasterio.open(a["swir16"]["href"]) as src:                       # the 20 m grid
            win = from_bounds(*box, transform=src.transform).round_offsets().round_lengths()
            shape, grid, extent = (int(win.height), int(win.width)), src.window_transform(win), window_bounds(win, src.transform)
            swir = src.read(1, window=win).astype("float32")
        band = {}
        for k in ("red", "green", "nir", "rededge1"):
            with rasterio.open(a[k]["href"]) as src:
                band[k] = src.read(1, window=from_bounds(*extent, transform=src.transform), out_shape=shape,
                                   resampling=Resampling.average).astype("float32")
        with rasterio.open(a["scl"]["href"]) as src:
            clear = np.isin(src.read(1, window=win), [4, 5])
        ids = rasterize(zip(utm.geometry.buffer(-20), utm["pid"]), out_shape=shape, transform=grid, fill=0, dtype="int32")
        ok = (ids > 0) & clear
        red, green, nir, re1 = band["red"], band["green"], band["nir"], band["rededge1"]
        with np.errstate(all="ignore"):
            idx = {"ndvi": (nir - red) / (nir + red), "ndre": (nir - re1) / (nir + re1),
                   "ndwi": (green - nir) / (green + nir), "swir": (swir - nir) / (swir + nir)}
        frame = pd.DataFrame({"pid": ids[ok], **{k: v[ok] for k, v in idx.items()}})
        med = frame.groupby("pid").median()
        med["date"] = items[scene]["properties"]["datetime"][:10]
        rows.append(med.reset_index())
    pd.concat(rows).to_csv("parcel_series.csv", index=False)
    parcels.drop(columns="geometry").to_csv("parcels.csv", index=False)

series = pd.read_csv("parcel_series.csv")
wide = series.pivot(index="pid", columns="date", values=["ndvi", "ndre", "ndwi", "swir"])
feature_names = [f"{index}_{date[5:]}" for index, date in wide.columns]
feature_dates = [date for _, date in wide.columns]
wide.columns = feature_names
for index in ("ndvi", "ndre", "ndwi", "swir"):                    # fill clouded dates along time
    cols = [c for c in feature_names if c.startswith(index)]
    wide[cols] = wide[cols].interpolate(axis=1, limit_direction="both")
data = pd.read_csv("parcels.csv").set_index("pid").join(wide, how="inner")
X, y = data[feature_names].to_numpy(), data["crop"].to_numpy()
pred = cross_val_predict(RandomForestClassifier(500, n_jobs=4, random_state=0), X, y,
                         cv=StratifiedKFold(5, shuffle=True, random_state=0))
print(len(y), "parcels,", X.shape[1], "features;", data["crop"].value_counts().to_dict())
890 parcels, 44 features; {'ware potato': 202, 'winter wheat': 181, 'yellow onion': 121, 'sugar beet': 108, 'temporary grass': 90, 'carrot': 67, 'red onion': 50, 'seed potato': 46, 'permanent grass': 25}

Example 1 — the separability report for a pair

import numpy as np, pandas as pd

def separability(X, y, a, b, feature_names, dates=None):
    A, B = X[y == a], X[y == b]
    mu_a, mu_b = A.mean(axis=0), B.mean(axis=0)
    pooled = np.sqrt((A.var(axis=0) + B.var(axis=0)) / 2)
    effect = np.abs(mu_a - mu_b) / np.where(pooled == 0, np.nan, pooled)

    df = pd.DataFrame({"feature": feature_names,
                       f"mean_{a}": mu_a.round(3), f"mean_{b}": mu_b.round(3),
                       "effect_size": effect.round(3)})
    if dates is not None:
        df["date"] = dates
    df = df.sort_values("effect_size", ascending=False)
    print(df.head(8).to_string(index=False))
    print(f"\nn({a}) = {len(A)}, n({b}) = {len(B)}; "
          f"max effect size {np.nanmax(effect):.2f}")
    if np.nanmax(effect) < 0.5:
        print("! these classes are not separable with these features")
    return df
onions = separability(X, y, "red onion", "yellow onion", feature_names, feature_dates)
print()
potatoes = separability(X, y, "seed potato", "ware potato", feature_names, feature_dates)
   feature  mean_red onion  mean_yellow onion  effect_size       date
ndvi_07-02           0.592              0.530        0.426 2025-07-02
swir_07-02          -0.318             -0.256        0.413 2025-07-02
ndre_07-02           0.430              0.387        0.370 2025-07-02
ndwi_07-02          -0.573             -0.534        0.357 2025-07-02
swir_06-30          -0.359             -0.303        0.339 2025-06-30
ndvi_06-30           0.627              0.577        0.319 2025-06-30
ndwi_06-30          -0.604             -0.572        0.273 2025-06-30
ndre_06-30           0.437              0.409        0.240 2025-06-30

n(red onion) = 50, n(yellow onion) = 121; max effect size 0.43
! these classes are not separable with these features

   feature  mean_seed potato  mean_ware potato  effect_size       date
ndvi_08-19             0.205             0.564        1.579 2025-08-19
swir_08-19             0.137            -0.158        1.567 2025-08-19
ndwi_08-19            -0.297            -0.524        1.556 2025-08-19
swir_08-14             0.118            -0.198        1.496 2025-08-14
ndvi_08-14             0.233             0.608        1.492 2025-08-14
ndre_08-19             0.121             0.323        1.487 2025-08-19
ndwi_08-14            -0.312            -0.550        1.438 2025-08-14
ndre_08-14             0.127             0.367        1.365 2025-08-14

n(seed potato) = 46, n(ware potato) = 202; max effect size 1.58

The two pairs are opposite cases. The onions never separate: the same species, sown and lifted on the same calendar, in fields that look alike from space on every date. The potatoes separate strongly in August — seed potatoes have their haulm destroyed early so the tubers stay small, while ware potatoes are still green — so their confusion, if any, is a feature or a date problem, not a data problem.

Example 2 — is the critical date observed?

import numpy as np, pandas as pd

def observation_support(sep_df, observed_dates, window_days=7, top_n=3):
    obs = pd.DatetimeIndex(observed_dates)
    rows = []
    for _, r in sep_df.head(top_n).iterrows():
        d = pd.Timestamp(r["date"])
        near = np.abs((obs - d).days) <= window_days
        gaps = np.diff(np.sort(obs)).astype("timedelta64[D]").astype(int)
        rows.append({"date": str(d.date()), "effect_size": r["effect_size"],
                     "clear_obs_within_7d": int(near.sum()),
                     "days_to_nearest": int(np.abs((obs - d).days).min()),
                     "largest_season_gap": int(gaps.max())})
    df = pd.DataFrame(rows)
    print(df.to_string(index=False))
    if (df["clear_obs_within_7d"] == 0).any():
        print("! the most separating date has no nearby clear observation")
    return df
observed = sorted(set(feature_dates))
support = observation_support(potatoes, observed)
      date  effect_size  clear_obs_within_7d  days_to_nearest  largest_season_gap
2025-08-19        1.579                    2                0                  43
2025-08-19        1.567                    2                0                  43
2025-08-19        1.556                    2                0                  43

A separating date with no clear observation within a week is an argument for adding a sensor — Landsat, or a radar series — rather than for tuning the model.

Seed and ware potato parcels in Flevoland already brown (NDVI below 0.4) on 19 August 2025. potato parcels already brown on 19 Augustseed potato, all46seed potato, brown42 (91%)ware potato, all202ware potato, brown64 (32%)0100200An effect size of 1.58, and still more brown ware parcels than brown seed parcels spatialworkflow.io
Why a strongly separating feature still left seed-potato recall at 0.46.

Example 3 — targeted features, and merging as a fallback

import numpy as np, pandas as pd
from sklearn.metrics import classification_report

def add_targeted_features(series, dates, window, index="ndre"):
    """Mean, slope and range of one index within a specific window."""
    d = pd.DatetimeIndex(dates)
    sel = (d >= pd.Timestamp(window[0])) & (d <= pd.Timestamp(window[1]))
    sub = series[:, sel]
    return {
        f"{index}_mean_{window[0]}": np.nanmean(sub, axis=1),
        f"{index}_slope_{window[0]}": np.nanmean(np.diff(sub, axis=1), axis=1),
        f"{index}_range_{window[0]}": np.nanmax(sub, axis=1) - np.nanmin(sub, axis=1),
    }

def merge_classes(y, pred, groups):
    """groups: {'winter cereal': ['winter wheat', 'winter barley'], ...}"""
    remap = {old: new for new, olds in groups.items() for old in olds}
    ym = np.array([remap.get(v, v) for v in y])
    pm = np.array([remap.get(v, v) for v in pred])
    print(classification_report(ym, pm, zero_division=0))
    return ym, pm
from sklearn.metrics import recall_score

for pair in (["red onion", "yellow onion"], ["seed potato", "ware potato"]):
    r = recall_score(y, pred, labels=pair, average=None)
    print(pair, "recall", r.round(2).tolist())
ym, pm = merge_classes(y, pred, {"onion": ["red onion", "yellow onion"]})
['red onion', 'yellow onion'] recall [0.16, 0.85]
['seed potato', 'ware potato'] recall [0.43, 0.92]
                 precision    recall  f1-score   support

         carrot       0.91      0.90      0.90        67
          onion       0.98      0.98      0.98       171
permanent grass       0.79      0.60      0.68        25
    seed potato       0.59      0.43      0.50        46
     sugar beet       1.00      0.96      0.98       108
temporary grass       0.86      0.87      0.86        90
    ware potato       0.83      0.92      0.87       202
   winter wheat       0.98      0.99      0.98       181

       accuracy                           0.91       890
      macro avg       0.87      0.83      0.84       890
   weighted avg       0.90      0.91      0.90       890

The potato pair is the other branch. They separate on 19 August at an effect size of 1.58, so a targeted feature — the NDVI mean, slope and range over July and August — should be the fix, and class weights should stop the 202 ware parcels outvoting the 46 seed parcels:

from sklearn.model_selection import StratifiedKFold, cross_val_predict

ndvi_cols = [i for i, n in enumerate(feature_names) if n.startswith("ndvi")]
extra = add_targeted_features(X[:, ndvi_cols], [feature_dates[i] for i in ndvi_cols],
                              ("2025-07-02", "2025-08-19"), index="ndvi")
X_targeted = np.column_stack([X, *extra.values()])
for label, features, weight in (("as before", X, None), ("balanced weights", X, "balanced_subsample"),
                                ("+ targeted features", X_targeted, None)):
    p = cross_val_predict(RandomForestClassifier(500, n_jobs=4, random_state=0, class_weight=weight), features, y,
                          cv=StratifiedKFold(5, shuffle=True, random_state=0))
    print(f"{label:22} seed potato recall {recall_score(y, p, labels=['seed potato'], average=None)[0]:.2f}")

aug19 = pd.Series(X[:, feature_names.index("ndvi_08-19")], index=y)
for crop in ("seed potato", "ware potato"):
    v = aug19[crop]
    print(f"{crop:12} NDVI < 0.4 on 19 August: {(v < 0.4).sum()} of {len(v)} ({(v < 0.4).mean():.0%})")
as before              seed potato recall 0.43
balanced weights       seed potato recall 0.46
+ targeted features    seed potato recall 0.46
seed potato  NDVI < 0.4 on 19 August: 42 of 46 (91%)
ware potato  NDVI < 0.4 on 19 August: 64 of 202 (32%)

Neither helps much, and the last two lines show why. Seed potatoes have their haulm destroyed early, and 91% of them are brown by 19 August — but so are 32% of the ware-potato fields, harvested early for the new-potato market. Sixty-four brown ware parcels against forty-two brown seed parcels: the feature is real, the averages differ enormously, and among the parcels it picks out, the larger class is still the majority. The fix here is an earlier date — before either kind is killed off — or information that is not optical at all, such as the certification register seed potatoes are grown under.

Reporting both the split and the merged result is the honest way to present it: the reader can see what was gained by merging and decide whether the distinction mattered to them.

Why more model capacity does not help

Why more model capacity does not help

A classifier can only separate classes that are separated in the feature space. If two crops' feature distributions overlap almost completely, the Bayes-optimal error rate is high, and a random forest, a gradient booster and a neural network will all converge on roughly the same accuracy. The flat response to model changes is itself the diagnosis.

Why two cereals are the hardest pair

Winter wheat and winter barley are sown within weeks of each other, reach full canopy at the same time and senesce within a fortnight of each other. The differences that exist — barley heads earlier and senesces earlier — are expressed in narrow windows, which optical satellites may or may not observe, and both crops are at full canopy for the period when observations are most reliable.

Why declared labels concentrate errors on similar pairs

A farmer who intended barley and sowed wheat rarely amends the declaration, and a declaration covering a parcel with two crops carries only one. Both errors fall disproportionately on similar crops, because those are the ones substituted for each other. A model trained on such labels learns the confusion.

Why merging is a result rather than a retreat

A classification is used for something: an area statistic, a rotation analysis, an input-demand estimate. If the user needs "winter cereal area" and the model gives it at 96%, the wheat-barley distinction was never required. Reporting the merged accuracy alongside the split one lets the user make that call rather than the modeller.

Reporting two confused classes separately against reporting them merged. merging is a resultsplit✕red onion 16%, yellow 85%✕the distinction is unreliable✕users will use it anyway✕overall accuracy looks finemerged✓"onion" 98%✓the distinction is dropped✓honest about what is known✓report both, let the user chooseFlevoland 2025, recall by class: if the user needs onion area, the colour was never required spatialworkflow.io
Report both, and let the user decide whether the distinction mattered to them.

Off-diagonal cells, directions and imbalance

  • One large off-diagonal cell is a feature problem; scattered errors are usually labels.
  • Check both directions. Wheat → barley and barley → wheat can differ a lot.
  • Class imbalance inflates one direction. Try balanced weights before concluding — for the potatoes they moved seed recall by three points.
  • A large effect size is not separability. It compares means; count how many of the larger class sit in the smaller one's range.
  • Radar is unaffected by cloud and separates cereals reasonably well.
  • Sub-field mixtures cannot be resolved by a field-level classifier.
  • Cover crops confuse everything in the shoulder seasons.
  • Report per-class recall, not overall accuracy.
  • Publish the merged result too when merging is the recommendation.

FAQ

Why does my classifier always confuse the same two crops?

Because they are genuinely similar in the features provided. Check the effect size between their mean profiles: below about 0.5 everywhere, no model can separate them.

Will a better model fix it?

No. A flat response to model changes is the diagnosis, not a tuning problem.

What feature would separate two cereals?

A red-edge index at heading or the slope of the senescence limb — the narrow windows where they differ. One targeted feature beats a hundred generic ones.

What if the separating date was cloudy?

Then the information is not in the data. Add another sensor — Landsat, or a radar series — rather than tuning the model.

Could the labels be wrong?

Often. Declared crop is the intended crop, and substitution errors concentrate exactly on similar pairs.

Is merging the two classes acceptable?

Yes, and it is often the right answer. Here red onions were recalled 16% of the time and yellow onions 85%; merged, the onion class is recalled 98% of the time. Report both.