Workspace Price Tiers the competition, step by step

Lesson 1 of 10

Setup & data

Load the data, rebuild the operator id, make operator-grouped folds.

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

Workspace price-tier prediction: final notebook

Task. Each row is a bookable workspace listing. Predict its price_tier (1–4). Metric: macro F1 (the average F1 of the 4 tiers, so every tier counts equally).

The key finding. Kaggle split train and test by operator: only ~1% of test rows belong to an operator that appears in train. Our first model validated with random K-fold. That puts the same operator in both training and validation, so the model got rewarded for memorising operators. It scored 0.758 in CV but 0.70 on the leaderboard. Validating with folds grouped by operator reproduces the leaderboard (≈0.70), so every decision here is made on grouped CV.

Pipeline

  1. Load data, rebuild an operator id, make operator-grouped folds
  2. EDA: target, metros, size, terms, and the operator split
  3. Preprocessing & features: encodings, ratios, geo, label-free operator profile, out-of-fold target features
  4. Feature-block experiments (measured earlier, summarised)
  5. Models: Logistic Regression (scaled pipeline baseline), XGBoost, XGBoost regression, CatBoost (+ LightGBM optional)
  6. Level-2 stacker: smooth predictions across each operator's listings
  7. Scenarios: blends × decoders, all cross-fitted on grouped CV; the best one is submitted
  8. Submission
  9. Error analysis
  10. What else could be tried

How to run. Colab: Runtime → Change runtime type → T4 GPU, then Run all (the first cell fetches the data). Kaggle: attach the competition data, set Accelerator → GPU T4, then Run all. Every finished fold is checkpointed in ckpt/, and a rerun skips work that's already done.

0. Setup (Colab only; skipped on Kaggle)

Runtime → Change runtime type → T4 GPU first. This cell installs CatBoost and downloads the competition data from the course Drive link.

import sys, subprocess, zipfile
from pathlib import Path as _P
if "google.colab" in sys.modules:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "catboost", "gdown"], check=True)
    if not any(_P("/content").rglob("train.csv")):
        import gdown
        gdown.download(id="1v6Ce6HLFvByXzDdcHQD1NMgA-XnJf5kf", output="/content/data_download", quiet=False)
        z = _P("/content/data_download")
        if zipfile.is_zipfile(z):
            zipfile.ZipFile(z).extractall("/content/data")
            for inner in list(_P("/content/data").rglob("*.zip")):  # zip inside a zip
                zipfile.ZipFile(inner).extractall(inner.parent)
    print("train.csv found at:", [str(p) for p in _P("/content").rglob("train.csv")])
import json
import os
import shutil
import subprocess
import time
import warnings
from pathlib import Path

import numpy as np
import pandas as pd
import lightgbm as lgb
import xgboost as xgb
from catboost import CatBoostClassifier
from scipy import sparse
from scipy.optimize import minimize
from sklearn.cluster import MiniBatchKMeans
from sklearn.metrics import log_loss
from sklearn.model_selection import StratifiedGroupKFold
from sklearn.neighbors import NearestNeighbors

warnings.filterwarnings("ignore")

# ======================= SETTINGS =======================
QUICK_TEST = bool(int(os.environ.get("PT_QUICK", "0")))  # True = 10% of operators + few rounds (pipeline check only)
USE_LIGHTGBM = False                                        # LightGBM runs on CPU only (its GPU mode is slower on Kaggle): ~8 min/fold
RUN_ABLATION = False                                        # test H1/H2 (feature blocks) before the big training
FOLDS, SEED, K = 5, 42, 4
MAX_ROUNDS = 300 if QUICK_TEST else 6000
LOG_EVERY = 100 if QUICK_TEST else 250                     # print training progress every N rounds
# =========================================================

TAG = f"final{'q' if QUICK_TEST else ''}_f{FOLDS}"          # checkpoint names (quick-test checkpoints never mix with full ones)
WORK = Path("/kaggle/working") if Path("/kaggle/working").exists() else Path(".")
CKPT = WORK / "ckpt"
CKPT.mkdir(exist_ok=True)
SEARCH = [CKPT] + (sorted(p for p in Path("/kaggle/input").glob("**/ckpt") if p.is_dir()) if Path("/kaggle/input").exists() else [])


def gpu_available():
    if shutil.which("nvidia-smi") is None:
        return False
    try:
        return subprocess.run(["nvidia-smi"], capture_output=True, timeout=10).returncode == 0
    except Exception:
        return False


GPU = gpu_available()
if GPU:  # all-GPU models; LightGBM only if asked for
    MODELS = ["logreg", "xgb", "xgb_reg", "cat"] + (["lgb_multi", "lgb_reg"] if USE_LIGHTGBM else [])
else:
    MODELS = ["logreg", "lgb_multi", "lgb_reg", "xgb", "xgb_reg"] + (["cat"] if os.environ.get("PT_FORCE_CAT") else [])
    print("No GPU found: running on CPU and skipping CatBoost. Turn on GPU T4 for a much faster run.")
N_THREADS = os.cpu_count()
print(f"GPU: {GPU} | CPU threads: {N_THREADS} | models: {MODELS} | quick test: {QUICK_TEST}")
print("checkpoint folders searched:", [str(p) for p in SEARCH])

Helpers: checkpoints, progress bar, fast macro F1

Checkpoints are plain .npz files (NumPy arrays only, no pickle). They are written to a temp file and then renamed, so a crash during a save never leaves a broken checkpoint.

def ck_load(name):
    for d in SEARCH:
        p = d / f"{name}.npz"
        if p.exists():
            with np.load(p, allow_pickle=False) as z:
                return {k: z[k] for k in z.files}
    return None


def ck_save(name, **arrays):
    tmp = CKPT / f"{name}.tmp.npz"
    np.savez(tmp, **arrays)
    tmp.replace(CKPT / f"{name}.npz")


class Progress:
    """Overall progress bar over all big steps. ETA = remaining steps x the measured time of the same kind of step
    (e.g. each remaining XGBoost fold is estimated from finished XGBoost folds, not from the slower LightGBM ones)."""

    def __init__(self, plan):
        self.plan, self.total, self.done, self.t0, self.kind_times = plan, len(plan), 0, time.time(), {}

    def bar(self, width=30):
        full = int(width * self.done / self.total)
        return "█" * full + "░" * (width - full) + f" {self.done}/{self.total} ({100 * self.done / self.total:.0f}%)"

    def start(self, label):
        self.label, self.ts = label, time.time()
        print(f"\n▶ {label}   [{self.bar()}]", flush=True)

    def finish(self, from_checkpoint=False):
        self.done += 1
        took = time.time() - self.ts
        if not from_checkpoint:
            self.kind_times.setdefault(self.label.split(":")[0], []).append(took)
        left = [s.split(":")[0] for s in self.plan[self.done:]]
        timed = sum(np.mean(self.kind_times[k]) for k in left if k in self.kind_times)
        untimed = sum(k not in self.kind_times for k in left)
        eta = "-" if not left else f"{timed / 60:.0f} min" + (f" + {untimed} steps not timed yet" if untimed else "")
        how = "loaded from checkpoint" if from_checkpoint else f"took {took / 60:.1f} min"
        print(f"✓ {self.label}: {how} | [{self.bar()}] | elapsed {(time.time() - self.t0) / 60:.1f} min | ETA ~{eta}", flush=True)


def conf(yt, yp, w=None):
    return np.bincount(yt * K + yp, weights=w, minlength=K * K).reshape(K, K)


def f1_from_conf(cm):
    tp = np.diag(cm)
    return float(np.mean(2 * tp / np.maximum(cm.sum(0) + cm.sum(1), 1e-12)))


def fast_f1(yt, yp, w=None):
    """macro F1 (optionally sample-weighted), ~50× faster than sklearn; used inside the tuning loops"""
    return f1_from_conf(conf(yt, yp, w))


PLAN = (["load data + grouped folds", "build features", "ablation of feature blocks"]
        + [f"{m}: fold {f + 1}/{FOLDS}" for m in MODELS for f in range(FOLDS)]
        + ["level-2 operator stacker", "scenario table", "write submissions"])
prog = Progress(PLAN)

1. Data, operator id, grouped folds

prog.start("load data + grouped folds")


DATA_DIR = ""  # optional: paste the folder that contains train.csv, e.g. "/kaggle/input/workstation-rental-price-tier-prediction"


def find_data():
    if DATA_DIR:
        return Path(DATA_DIR)
    if "PT_DATA" in os.environ:
        return Path(os.environ["PT_DATA"])
    for root in ["/kaggle/input", "/content", "."]:
        for dirpath, _, files in os.walk(root, followlinks=True):  # followlinks: Kaggle mounts inputs as symlinks
            if "train.csv" in files and "test.csv" in files:
                return Path(dirpath)
    found = [str(q) for q in Path("/kaggle/input").glob("*")] if Path("/kaggle/input").exists() else []
    raise FileNotFoundError(
        "train.csv / test.csv not found. The competition data is not attached to this notebook. "
        "Fix: right panel -> 'Add Input' -> 'Competitions' -> search 'workstation-rental-price-tier-prediction' -> Add. "
        f"Currently in /kaggle/input: {found}")


DATA = find_data()
train = pd.read_csv(DATA / "train.csv")
test = pd.read_csv(DATA / "test.csv")

# operator_ref alone isn't unique (different operators share names); these columns together identify one operator
HOST = ["operator_ref", "operator_platform_age_years", "operator_platform_age_months", "operator_active_years",
        "operator_active_months", "operator_class", "operator_portfolio_size"]
host = pd.concat([train[HOST], test[HOST]], ignore_index=True).groupby(HOST, dropna=False, sort=False).ngroup().to_numpy()
if QUICK_TEST:  # keep 10% of operators (whole operators, so the split logic stays the same)
    keep = (host * 2654435761 % 1000) < 100
    n0 = len(train)
    train, test = train[keep[:n0]].reset_index(drop=True), test[keep[n0:]].reset_index(drop=True)
    host = host[keep]

n_tr = len(train)
y = train["price_tier"].to_numpy() - 1
folds = list(StratifiedGroupKFold(FOLDS, shuffle=True, random_state=SEED).split(np.zeros(n_tr), y, host[:n_tr]))
fold_id = np.zeros(n_tr, int)
for i, (_, v) in enumerate(folds):
    fold_id[v] = i
print(train.shape, test.shape, "| test rows whose operator is in train:", round(np.isin(host[n_tr:], host[:n_tr]).mean(), 4))
prog.finish()