Workspace Price Tiers the competition, step by step

v3 · Colab run · part 1 of 7

Setup & data

The notebook exactly as it ran on Colab's T4 GPU, with its real outputs.

Download the executed notebook

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

Workspace price tier prediction: v3

v1 → v2 recap. Kaggle split train/test by operator (only ~1% of test rows come from an operator seen in train). v1's random folds let the model see each operator's other listings, so its CV (0.758) was inflated. Operator-grouped CV gives 0.697, which matches the leaderboard (0.70). From v2 on, every decision is made on the grouped CV.

v3 = an experiment notebook. Everything below is a hypothesis that gets measured; the notebook keeps only what wins.

# hypothesis why it might help
H1 demand relative to comparable listings (booked less / more open days / fewer reviews than similar spaces nearby) for the same kind of space in the same zone, the pricier ones get booked less. Data check: corr −0.085 within comparables. Trees can't compute "vs. the group average" on their own
H2 facility price effects (each facility's average tier, after removing the metro effect) 150+ one-hot columns are hard to use; one "how premium are these amenities" score is easier
H3 more model families (LightGBM, XGBoost, CatBoost) + blending different algorithms make different mistakes
H4 ordinal decoding (expected-tier score + tuned cut-points) tiers are ordered; cut-points can be tuned directly for macro F1
H5 operator smoothing (stacker using the operator's other listings' predictions) an operator's listings are usually in the same tier, and all of a test operator's listings are in test
H6 test-distribution weighting (metro mix differs: e.g. MTR-15 is 4% of train, 10% of test) macro F1 depends on the class mix; tune decisions for the test's metro mix
H7 per-metro cut-points tiers mean very different things in different metros

Decoders are evaluated with cross-fitting (tuned on 4 folds' predictions, scored on the 5th), so the scenario table isn't fooled by tuning on the same rows.

How to run

  1. Settings → Accelerator → GPU T4 (XGBoost and CatBoost are ~10× faster). It also works on CPU, but CatBoost is then skipped.
  2. Optional: set QUICK_TEST = True and Run All (~10 min) to check that everything works, then set it back to False.
  3. Save Version → "Save & Run All (Commit)". This runs on Kaggle's servers: closing the browser or losing internet doesn't stop it.
  4. If a run is ever cut off: Add Input → Notebook Output → this notebook's last version, then run again. It finds the old ckpt/ folder and continues where it stopped. Every finished fold is saved.

0. Colab setup (skipped automatically 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")])
Downloading...
From (original): https://drive.google.com/uc?id=1v6Ce6HLFvByXzDdcHQD1NMgA-XnJf5kf
From (redirected): https://drive.google.com/uc?id=1v6Ce6HLFvByXzDdcHQD1NMgA-XnJf5kf&confirm=t&uuid=a761f33a-75fa-4c7c-8029-abe08af30049
To: /content/data_download
100%|██████████| 53.7M/53.7M [00:01<00:00, 33.5MB/s]
train.csv found at: ['/content/data/kaggle_ready_dataset/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"v3b{'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 = ["xgb", "xgb_reg", "cat"] + (["lgb_multi", "lgb_reg"] if USE_LIGHTGBM else [])
else:
    MODELS = ["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])
GPU: True | CPU threads: 2 | models: ['xgb', 'xgb_reg', 'cat'] | quick test: False
checkpoint folders searched: ['ckpt']

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()
▶ load data + grouped folds   [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/21 (0%)]
(279746, 60) (114538, 59) | test rows whose operator is in train: 0.0098
✓ load data + grouped folds: took 1.0 min | [█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1/21 (5%)] | elapsed 1.0 min | ETA ~0 min + 20 steps not timed yet