Workspace Price Tiers the competition, step by step

Lesson 0

The first model

The original notebook with its real outputs: CV 0.758, leaderboard 0.70.

Download this notebook

Left: what & whyRight: the code and its real output

Lesson 0: the first model (0.70 on Kaggle)

This is the original notebook, exactly as it ran, with its real outputs. It's a strong pipeline: about 350 features, out-of-fold target encodings, two LightGBM models, a blend, and macro-F1 class weights.

Its own CV said 0.758; Kaggle said 0.70. Read it as a good model with one hidden flaw: the validation (step 1).

Step 1: load the data and make folds

StratifiedKFold makes 5 folds with the same tier mix, but rows are assigned at random. One operator's listings land in both training and validation folds.

⚠️ This is the flaw. Kaggle's test set contains operators that never appear in train, so random folds measure an easier task than the real one.

Step 2: look at the target

The tiers are roughly balanced, with tier 2 the largest. Balanced classes make macro F1 a fair metric: each tier counts equally.

train["price_tier"].value_counts().sort_index().plot.bar(title="price_tier distribution");
plot

Step 3: basic features

  • yes/no flags → 1/0; dates → number of days
  • operator_ref, zone, borough → frequency encoding (how common the value is)
  • facility codes → one-hot columns (about 150 of them)
  • coordinates → rotations, distance to metro/zone centres, k-means map clusters
  • remaining text columns → pandas category (LightGBM handles them natively)

All of this is computed on train+test together, which is fine because no labels are used.

def basic_features(train, test):
    raw = pd.concat([train.drop(columns="price_tier"), test], ignore_index=True)
    df = raw.copy()

    for c in ["operator_certified", "operator_has_avatar", "operator_id_verified", "bookable"]:
        df[c] = df[c].map({"t": 1, "f": 0})
    for c in ["snapshot_start_date", "snapshot_end_date", "first_review_date", "last_review_date"]:
        df[c] = (pd.to_datetime(df[c]) - pd.Timestamp("2000-01-01")).dt.days
    for c in ["operator_ref", "zone", "borough"]:
        df[c] = raw[c].map(raw[c].value_counts())
    df["n_facilities"] = df["facilities"].str.count("FCL-")

    codes = raw["facilities"].fillna("[]").map(json.loads).explode().dropna()
    fac = pd.crosstab(codes.index, codes).reindex(raw.index, fill_value=0).clip(upper=1)
    fac.columns = ["f_" + c[4:] for c in fac.columns]

    lat, lon = raw["geo_lat"], raw["geo_lon"]
    geo = pd.DataFrame({"lat_p_lon": lat + lon, "lat_m_lon": lat - lon})
    for a in (15, 30, 60):
        r = np.deg2rad(a)
        geo[f"rot{a}_x"] = lat * np.cos(r) - lon * np.sin(r)
        geo[f"rot{a}_y"] = lat * np.sin(r) + lon * np.cos(r)
    for k in ("metro", "zone"):
        center = raw.groupby(k)[["geo_lat", "geo_lon"]].transform("median")
        geo[f"dist_{k}_center"] = np.hypot(lat - center["geo_lat"], lon - center["geo_lon"])
    km = MiniBatchKMeans(100, random_state=0, n_init=3, batch_size=4096).fit(raw[["geo_lat", "geo_lon"]])
    geo["geo_cluster"] = km.labels_
    geo["geo_cluster_dist"] = km.transform(raw[["geo_lat", "geo_lon"]]).min(1)

    df = df.drop(columns=["facilities", "space_id"])
    for c in df.select_dtypes(exclude="number").columns:
        df[c] = df[c].astype("category")
    X = pd.concat([df, fac, geo], axis=1)
    return X.iloc[: len(train)].reset_index(drop=True), X.iloc[len(train):].reset_index(drop=True)


X_train, X_test = basic_features(train, test)
X_train.shape
(279746, 215)

Step 4: out-of-fold target features

For groups such as zone, zone + seats, operator, operator + zone: what's the tier mix of that group in the other folds? Plus residuals vs. an (access type, seats) baseline, and tiers of the nearest map neighbours.

Out-of-fold is the right idea: no row sees its own label. ⚠️ But groups keyed on operator_ref (op, op_zone, op_sc, op_seats, dup, dup2) mostly identify the operator. With random folds they look brilliant. On the real test set the operator is new, or is a different operator with the same name, so they mislead.

TE_GROUPS = {
    "op": ["operator_ref"], "zone": ["zone"], "boro": ["borough"], "sc": ["space_class"],
    "metro_sc": ["metro", "space_class"], "zone_acc": ["zone", "access_type"],
    "zone_seats": ["zone", "max_seats"], "op_zone": ["operator_ref", "zone"],
    "zone_sc": ["zone", "space_class"], "op_sc": ["operator_ref", "space_class"],
    "op_seats": ["operator_ref", "max_seats"], "geo": ["geo_lat", "geo_lon"],
    "dup": ["operator_ref", "zone", "space_class", "max_seats"],
    "dup2": ["operator_ref", "space_class", "max_seats", "access_type"],
    "metro_acc_seats": ["metro", "access_type", "max_seats"],
}
RES_GROUPS = ["op", "zone", "geo", "op_zone", "zone_sc", "sc", "op_sc"]
GEO_KS = (1, 5, 20, 50)

both = pd.concat([train, test], ignore_index=True)
group_ids = {name: both[cols].astype(str).groupby(cols, sort=False, dropna=False).ngroup().to_numpy()
             for name, cols in TE_GROUPS.items()}
base_ids = both[["access_type", "max_seats"]].astype(str).groupby(["access_type", "max_seats"], dropna=False).ngroup().to_numpy()
coords = both[["geo_lat", "geo_lon"]].to_numpy()
prior = np.bincount(y, minlength=K) / len(y)
tiers = np.arange(1, K + 1)


def target_encoding(fit, apply):
    cols = {}
    for name, g in group_ids.items():
        n = g.max() + 1
        counts = np.zeros((n, K))
        np.add.at(counts, (g[fit], y[fit]), 1)
        total = counts.sum(1)
        p = ((counts + 20 * prior) / (total[:, None] + 20))[g[apply]]
        for c in range(K):
            cols[f"te_{name}_p{c + 1}"] = p[:, c]
        cols[f"te_{name}_mean"] = p @ tiers
        cols[f"te_{name}_n"] = np.log1p(total[g[apply]])
    return cols


def residual_encoding(fit, apply):
    yt = y[fit] + 1.0
    nb = base_ids.max() + 1
    base_mean = (np.bincount(base_ids[fit], yt, nb) + 20 * yt.mean()) / (np.bincount(base_ids[fit], minlength=nb) + 20)
    resid = yt - base_mean[base_ids[fit]]
    cols = {}
    for name in RES_GROUPS:
        g = group_ids[name]
        n = g.max() + 1
        cnt = np.bincount(g[fit], minlength=n)[g[apply]] + 10
        mean = np.bincount(g[fit], resid, n)[g[apply]] / cnt
        cols[f"res_{name}_mean"] = mean
        cols[f"res_{name}_rms"] = np.sqrt(np.bincount(g[fit], resid ** 2, n)[g[apply]] / cnt)
        cols[f"res_{name}_pred"] = base_mean[base_ids[apply]] + mean
    return cols


def geo_neighbours(fit, apply):
    points, inv = np.unique(coords[fit], axis=0, return_inverse=True)
    counts = np.zeros((len(points), K))
    np.add.at(counts, (inv.ravel(), y[fit]), 1)
    dist, idx = NearestNeighbors(n_neighbors=max(GEO_KS)).fit(points).kneighbors(coords[apply])
    cols = {}
    for k in GEO_KS:
        c = counts[idx[:, :k]].sum(1)
        p = (c + 5 * prior) / (c.sum(1)[:, None] + 5)
        for j in range(K):
            cols[f"geo{k}_p{j + 1}"] = p[:, j]
        cols[f"geo{k}_mean"] = p @ tiers
        cols[f"geo{k}_dist"] = dist[:, k - 1]
    return cols


def target_features(fit, apply):
    return pd.DataFrame({**target_encoding(fit, apply), **residual_encoding(fit, apply), **geo_neighbours(fit, apply)})


T_train = pd.concat([target_features(fit, val).set_index(val) for fit, val in folds]).sort_index()
T_test = target_features(np.arange(len(train)), np.arange(len(train), len(both)))

X_train = pd.concat([X_train, T_train], axis=1)
X_test = pd.concat([X_test, T_test], axis=1)
X_train.shape
(279746, 350)

Step 5: two LightGBM models

  • multiclass: 4 probabilities directly.
  • ordinal: 3 yes/no models, is tier > 1, > 2, > 3?, turned into 4 probabilities. This uses the order of the tiers.

Early stopping picks the number of trees on each validation fold. The fold scores (~0.755) are high because of the random folds.

PARAMS = dict(learning_rate=0.05, num_leaves=127, min_child_samples=20, feature_fraction=0.8,
              bagging_fraction=0.8, bagging_freq=1, verbose=-1, seed=SEED)


def fit_predict(params, X_fit, y_fit, X_val, y_val, X_test):
    model = lgb.train(params, lgb.Dataset(X_fit, y_fit), 5000,
                      valid_sets=[lgb.Dataset(X_val, y_val)],
                      callbacks=[lgb.early_stopping(100, verbose=False)])
    return model.predict(X_val, num_iteration=model.best_iteration), model.predict(X_test, num_iteration=model.best_iteration)


def cumulative_to_classes(p_above):
    p_above = np.minimum.accumulate(p_above, axis=1)
    edges = np.column_stack([np.ones(len(p_above)), p_above, np.zeros(len(p_above))])
    return np.clip(edges[:, :-1] - edges[:, 1:], 1e-6, 1)


oof = {m: np.zeros((len(train), K)) for m in ["multiclass", "ordinal"]}
pred = {m: np.zeros((len(test), K)) for m in ["multiclass", "ordinal"]}

for i, (fit, val) in enumerate(folds):
    Xf, Xv = X_train.iloc[fit], X_train.iloc[val]

    p_val, p_test = fit_predict({**PARAMS, "objective": "multiclass", "num_class": K},
                                Xf, y[fit], Xv, y[val], X_test)
    oof["multiclass"][val], pred["multiclass"] = p_val, pred["multiclass"] + p_test / FOLDS

    above_val, above_test = [], []
    for k in range(K - 1):
        v, t = fit_predict({**PARAMS, "objective": "binary"}, Xf, y[fit] > k, Xv, y[val] > k, X_test)
        above_val.append(v)
        above_test.append(t)
    oof["ordinal"][val] = cumulative_to_classes(np.column_stack(above_val))
    pred["ordinal"] += cumulative_to_classes(np.column_stack(above_test)) / FOLDS

    print(f"fold {i}", {m: round(f1_score(y[val], oof[m][val].argmax(1), average="macro"), 4) for m in oof})

{m: round(f1_score(y, p.argmax(1), average="macro"), 4) for m, p in oof.items()}
fold 0 {'multiclass': 0.7523, 'ordinal': 0.7557}
fold 1 {'multiclass': 0.753, 'ordinal': 0.7551}
fold 2 {'multiclass': 0.7515, 'ordinal': 0.7517}
fold 3 {'multiclass': 0.7509, 'ordinal': 0.7541}
fold 4 {'multiclass': 0.7522, 'ordinal': 0.757}
{'multiclass': 0.752, 'ordinal': 0.7547}

Step 6: blend and class weights

Average the two models, then multiply each tier's probability by a tuned weight to maximise macro F1. CV: 0.7577.

In the final notebook these weights are tuned with cross-fitting (tuned on 4 folds, scored on the 5th), so the reported score isn't flattered by the tuning.

def macro_f1(p, w):
    return f1_score(y, (p * w).argmax(1), average="macro")


def tune_class_weights(p, rounds=3):
    w = np.ones(K)
    for _ in range(rounds):
        for k in range(1, K):
            grid = w[k] * np.exp(np.linspace(-0.3, 0.3, 31))
            scores = [macro_f1(p, np.r_[w[:k], g, w[k + 1:]]) for g in grid]
            w[k] = grid[int(np.argmax(scores))]
    return w


blend_oof = (oof["multiclass"] + oof["ordinal"]) / 2
blend_test = (pred["multiclass"] + pred["ordinal"]) / 2
weights = tune_class_weights(blend_oof)

print("blend CV macro F1:", round(macro_f1(blend_oof, np.ones(K)), 4))
print("with class weights:", round(macro_f1(blend_oof, weights), 4), weights.round(3))
blend CV macro F1: 0.7559
with class weights: 0.7577 [1.    0.98  1.15  0.852]

Step 7: submission

Folds' test predictions are averaged, weighted and turned into tiers. Leaderboard: 0.70.

→ Next: the story of how we found the problem, and Lesson 1, the final notebook.

submission = pd.DataFrame({"ID": test["space_id"], "price_tier": (blend_test * weights).argmax(1) + 1})
submission.to_csv("submission.csv", index=False)
submission["price_tier"].value_counts().sort_index()
price_tier
1    25359
2    37553
3    27169
4    24457
Name: count, dtype: int64