# Author: Simon-Pierre Boucher — contact@spboucher.ai """ 02_clean_airbnb.py ------------------ Clean and standardize the Airbnb dataset. Steps 1. Standardize city names (merge accented / unaccented variants). 2. Clean price_numeric: drop null / non-positive, winsorize at p1/p99. 3. Create log_price = ln(price_numeric). 4. Clean rating and num_reviews (fill NaN with median for rating, 0 for num_reviews — rationale documented below). 5. Create is_entire_home indicator from property_type. 6. Save to data/processed/airbnb_clean.parquet. """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import numpy as np import pandas as pd from src.config import AIRBNB_RAW, AIRBNB_CLEAN, require RAW_HINT = ("Place the original airbnb.csv in data/raw/ to rebuild " "airbnb_clean.parquet; data/processed/ already contains the " "committed version.") # Mapping for known duplicates / accent variants. CITY_MAP = { "Montréal": "Montreal", "Québec City": "Québec", "Quebec City": "Québec", "Quebec": "Québec", "Levis": "Lévis", "Saint Come": "Saint-Côme", "Sainte Adele": "Sainte-Adèle", "Sainte-Adele": "Sainte-Adèle", "Saint Sauveur": "Saint-Sauveur", "Saint-Sauveur-des-Monts": "Saint-Sauveur", "Ste-Adèle": "Sainte-Adèle", "Ste-Agathe-des-Monts": "Sainte-Agathe-des-Monts", } # Entire-home types: House, Cabin/Chalet, Condo — i.e. standalone units that # are typically rented in their entirety. "Rental unit" and "Apartment" are # often private rooms inside a larger building; "Other" is ambiguous. ENTIRE_HOME_TYPES = {"House", "Cabin/Chalet", "Condo"} def main() -> None: require(AIRBNB_RAW, RAW_HINT) # 1. Load df = pd.read_csv(AIRBNB_RAW) print(f"[load] Raw shape: {df.shape}") # 2. Standardize city names df["city"] = df["city"].str.strip() df["city"] = df["city"].replace(CITY_MAP) print(f"[city] Unique cities after standardisation: {df['city'].nunique()}") print(f"[city] Top 10 cities:\n{df['city'].value_counts().head(10).to_string()}\n") # 3. Clean price_numeric n_before = len(df) n_null_price = df["price_numeric"].isnull().sum() n_nonpos = (df["price_numeric"] <= 0).sum() print(f"[price] Null prices: {n_null_price}") print(f"[price] Non-positive prices: {n_nonpos}") # Drop rows with null or non-positive price df = df.dropna(subset=["price_numeric"]) df = df[df["price_numeric"] > 0].copy() print(f"[price] Rows dropped: {n_before - len(df)} -> remaining: {len(df)}") # Winsorize at 1st and 99th percentile p01 = df["price_numeric"].quantile(0.01) p99 = df["price_numeric"].quantile(0.99) print(f"[price] Winsorize bounds: p1={p01:.2f}, p99={p99:.2f}") df["price_numeric"] = df["price_numeric"].clip(lower=p01, upper=p99) # Log transform df["log_price"] = np.log(df["price_numeric"]) print(f"[price] price_numeric mean={df['price_numeric'].mean():.2f} " f"median={df['price_numeric'].median():.2f}") # 4. Clean rating and num_reviews # Rating: Fill NaN with the *median* of observed ratings. # Rationale: missing ratings typically mean "not yet rated", which may not # be 0. Using the median avoids pulling the distribution toward zero and # is a conservative imputation that preserves the central tendency. rating_median = df["rating"].median() n_rating_null = df["rating"].isnull().sum() df["rating"] = df["rating"].fillna(rating_median) print(f"[rating] Filled {n_rating_null} NaN with median={rating_median:.2f}") # num_reviews: Fill NaN with 0. # Rationale: missing review count almost certainly means zero reviews. n_reviews_null = df["num_reviews"].isnull().sum() df["num_reviews"] = df["num_reviews"].fillna(0) print(f"[num_reviews] Filled {n_reviews_null} NaN with 0") # 5. Create is_entire_home indicator df["is_entire_home"] = df["property_type"].isin(ENTIRE_HOME_TYPES) print(f"[is_entire_home] True: {df['is_entire_home'].sum()}, " f"False: {(~df['is_entire_home']).sum()}") # 6. Save df.to_parquet(AIRBNB_CLEAN, index=False) print(f"\n>>> Saved cleaned Airbnb data ({len(df)} rows) to {AIRBNB_CLEAN}") print(f" Columns: {list(df.columns)}") if __name__ == "__main__": main()