Workspace Price Tiers the competition, step by step

Lesson 2 of 10

Exploratory data analysis

Target, metros, size, terms, and the operator split that explains everything.

Download the final notebook

Outputs will appear here after the full run. The code is final.

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

2. Exploratory data analysis

Goal: understand the target, the strongest drivers, data quality, and how train and test differ before building anything.

import matplotlib.pyplot as plt
import seaborn as sns
pd.set_option("display.width", 160)
print("rows:", len(train), "train /", len(test), "test | columns:", train.shape[1])
print(train.dtypes.astype(str).value_counts().to_dict())
miss = train.isna().mean().sort_values(ascending=False)
display(miss[miss > 0].round(3).to_frame("share missing").head(15))

Missing values are concentrated in review dates/scores (listings never reviewed) and a few size columns. They're informative (no reviews = new or unpopular listing), so tree models keep them as "missing" rather than filling in a guess.

fig, ax = plt.subplots(1, 3, figsize=(18, 4))
train.price_tier.value_counts().sort_index().plot.bar(ax=ax[0], title="price_tier (target)", rot=0)
pd.crosstab(train.access_type, train.price_tier, normalize="index").plot.barh(stacked=True, ax=ax[1], title="tier mix by access type")
sns.boxplot(data=train.assign(seats=train.max_seats.clip(upper=16)), x="price_tier", y="seats", ax=ax[2], showfliers=False)
ax[2].set_title("max_seats by tier"); plt.tight_layout(); plt.show()
  • The 4 tiers are roughly balanced (tier 2 is the largest), so macro F1 treats them all equally and no class can be ignored.
  • Access type (whole space vs private vs shared zone) shifts the tier mix strongly.
  • Size (max_seats) climbs steadily with the tier.
fig, ax = plt.subplots(1, 2, figsize=(16, 5))
pd.crosstab(train.metro, train.price_tier, normalize="index").plot.barh(stacked=True, ax=ax[0], title="tier mix by metro", legend=False)
shift = pd.DataFrame({"train": train.metro.value_counts(normalize=True), "test": test.metro.value_counts(normalize=True)})
shift.sort_values("train").plot.barh(ax=ax[1], title="metro share: train vs test")
plt.tight_layout(); plt.show()
  • Metro matters enormously: some metros are mostly tier 1, others mostly tier 4 (tiers are price bands, and price levels differ by market).
  • The metro mix differs between train and test (e.g. one metro is ~4% of train but ~10% of test), a first hint that the split isn't random.
num = ["max_seats", "zones", "workstations", "utility_rooms", "min_term_days", "booked_load", "review_count",
       "score_overall", "score_value", "operator_portfolio_size", "open_slots_d"]
corr = train[num + ["price_tier"]].corr(method="spearman")["price_tier"].drop("price_tier").sort_values()
corr.plot.barh(figsize=(7, 4), title="Spearman correlation with price_tier"); plt.tight_layout(); plt.show()

Size columns (seats, zones, workstations, utility rooms) correlate most with the tier. Long minimum terms (monthly rentals) go with cheaper tiers. No single column is decisive. The model has to combine size, location, terms and the operator type.

The operator split: the most important finding

Listings are run by operators, and an operator's listings tend to share a tier. How did Kaggle split train/test with respect to operators?

from sklearn.model_selection import StratifiedKFold
g_ = pd.Series(y).groupby(host[:n_tr])
big = g_.size() >= 5
print(f"operators with ≥5 listings: share of their listings in the operator's most common tier = "
      f"{g_.agg(lambda s: s.value_counts(normalize=True).iloc[0])[big].mean():.2f}")
rnd = list(StratifiedKFold(FOLDS, shuffle=True, random_state=SEED).split(np.zeros(n_tr), y))
seen_rnd = np.mean([np.isin(host[v], host[f]).mean() for f, v in rnd])
seen_grp = np.mean([np.isin(host[v], host[f]).mean() for f, v in folds])
seen_test = np.isin(host[n_tr:], host[:n_tr]).mean()
display(pd.DataFrame({"share of rows whose operator was seen in training": [seen_rnd, seen_grp, seen_test]},
                     index=["random K-fold validation", "operator-grouped validation", "real test set"]).round(3))

This explains everything about our first model:

  • Operators are very consistent (about 3 in 4 of their listings share one tier).
  • With random folds, most validation rows have their operator in training, so the model can "recognise" the operator. That's an easy exam.
  • In the real test set, almost no operator was seen in training. That's the hard exam.

So random-fold CV (0.758) was measuring the wrong thing. Grouped folds (an operator is entirely in train or entirely in validation) match the test set, and gave ≈0.70, the same as the leaderboard. From here on, every score is grouped CV.