{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"\"\"\"\n============================================\n; Title: DSC680 Project 1 - Classifying High Energy Use Buildings\n; Author: Kat Chu\n; Date: 27 September 2026\n; Description: This notebook uses the ASHRAE Great Energy Predictor III dataset to\n;              calculate how much electricity each building uses for its size, labels\n;              buildings as high use or normal use, and trains logistic regression and\n;              random forest models to predict which buildings are high use based on\n;              building type, age, size, and weather.\n;===========================================\n\"\"\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:22.297482Z","iopub.execute_input":"2026-09-27T21:43:22.297785Z","iopub.status.idle":"2026-09-27T21:43:22.30415Z","shell.execute_reply.started":"2026-09-27T21:43:22.297758Z","shell.execute_reply":"2026-09-27T21:43:22.303184Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Introduction\nBuilding owners and facility managers usually only find out that a building is wasting energy when\nthey get a high utility bill. For this project I want to find out if I can predict which buildings use\nmore electricity than they should **for their size**, using basic information about the building and\nits weather.\n\nBigger buildings obviously use more energy in total, so instead of total energy I use\n**energy use per square foot** (kWh per square foot per year). Buildings in the top half are labeled\n**high use** and the bottom half **normal use**.\n\n**Research questions**\n1. Can I predict whether a building is a high energy user for its size using building type, age, size,\n   and weather?\n2. Which model works better for this problem, logistic regression or random forest?\n3. Which building features matter most for whether a building is high use?\n\n**How to run:** run this notebook on Kaggle with the ASHRAE Great Energy Predictor III competition data\nattached. It finds the data on its own and takes about 5 minutes. The last cell prints a summary of all\nthe results.","metadata":{}},{"cell_type":"code","source":"import os, glob, json\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import train_test_split, cross_val_score\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.ensemble import RandomForestClassifier\nfrom sklearn.metrics import accuracy_score, confusion_matrix, classification_report, roc_curve, auc\n\n#colors I use for all the figures\nBLUE, ORANGE, GRAY = '#2a78d6', '#eb6834', '#8a8984'","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:22.305687Z","iopub.execute_input":"2026-09-27T21:43:22.306028Z","iopub.status.idle":"2026-09-27T21:43:22.326071Z","shell.execute_reply.started":"2026-09-27T21:43:22.305992Z","shell.execute_reply":"2026-09-27T21:43:22.325167Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Finding the competition data folder on Kaggle\nfound = glob.glob('/kaggle/input/**/building_metadata.csv', recursive=True)\nif os.environ.get('ASHRAE_DATA'):\n    DATA_DIR = os.environ['ASHRAE_DATA']\nelif found:\n    DATA_DIR = os.path.dirname(found[0])\nelse:\n    DATA_DIR = '.'\nprint('Data folder:', DATA_DIR)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:22.327642Z","iopub.execute_input":"2026-09-27T21:43:22.328001Z","iopub.status.idle":"2026-09-27T21:43:22.344812Z","shell.execute_reply.started":"2026-09-27T21:43:22.327972Z","shell.execute_reply":"2026-09-27T21:43:22.344049Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Loading the three files\n#train.csv has about 20 million rows so I set the data types to save memory\nmeters = pd.read_csv(os.path.join(DATA_DIR, 'train.csv'),\n                     dtype={'building_id': 'int16', 'meter': 'int8', 'meter_reading': 'float32'})\nmeters['timestamp'] = pd.to_datetime(meters['timestamp'])\nbuildings = pd.read_csv(os.path.join(DATA_DIR, 'building_metadata.csv'))\nweather = pd.read_csv(os.path.join(DATA_DIR, 'weather_train.csv'))\n\nprint('train.csv:', meters.shape)\nprint('building_metadata.csv:', buildings.shape)\nprint('weather_train.csv:', weather.shape)\nbuildings.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:22.347069Z","iopub.execute_input":"2026-09-27T21:43:22.347452Z","iopub.status.idle":"2026-09-27T21:43:33.278623Z","shell.execute_reply.started":"2026-09-27T21:43:22.347424Z","shell.execute_reply":"2026-09-27T21:43:33.277704Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Data Dictionary (key fields)\n\n| Field | Description |\n|---|---|\n| building_id | Unique ID for each building |\n| site_id | Which site (campus) the building is on, used to join the weather |\n| meter_reading | Energy used in one hour (kWh for electricity) |\n| primary_use | Main use of the building (Education, Office, Lodging, etc.) |\n| square_feet | Floor area of the building |\n| year_built | Year the building was built (missing for about half) |\n| floor_count | Number of floors (mostly missing) |\n| air_temperature | Outdoor temperature in °C |\n| **kwh_per_sqft** | Created: electricity used per square foot per year |\n| **high_use** | Created: 1 if kwh_per_sqft is above the median, 0 if not (the target) |","metadata":{}},{"cell_type":"markdown","source":"---\n# Data Preparation\n---","metadata":{}},{"cell_type":"code","source":"#Keeping a count of rows and buildings after each step for the paper\ncleaning_log = []\ndef log_step(step, df):\n    cleaning_log.append({'step': step, 'rows': len(df), 'buildings': df['building_id'].nunique()})\n    print(f'{step}: {len(df):,} rows, {df[\"building_id\"].nunique():,} buildings')\n\nlog_step('Raw train.csv (all meter types)', meters)\n\n#Step 1: keeping only electricity (meter 0), since every building has an electricity meter\nelec = meters[meters['meter'] == 0].drop(columns='meter').copy()\ndel meters\nlog_step('Electricity only', elec)\n\n#Step 2: site 0 electricity was recorded in kBTU instead of kWh, so I convert it (1 kBTU = 0.2931 kWh)\nelec = elec.merge(buildings[['building_id', 'site_id']], on='building_id', how='left')\nelec.loc[elec['site_id'] == 0, 'meter_reading'] *= 0.2931\n\n#Step 3: checking for duplicate readings\nprint('Duplicate rows:', elec.duplicated(subset=['building_id', 'timestamp']).sum())\nelec = elec.drop_duplicates(subset=['building_id', 'timestamp'])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:33.279764Z","iopub.execute_input":"2026-09-27T21:43:33.280113Z","iopub.status.idle":"2026-09-27T21:43:38.732025Z","shell.execute_reply.started":"2026-09-27T21:43:33.280077Z","shell.execute_reply":"2026-09-27T21:43:38.731333Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Step 4: removing long stretches of zeros\n#A building can't really use zero electricity for 2 days straight, so this means the meter was off or broken\nelec = elec.sort_values(['building_id', 'timestamp']).reset_index(drop=True)\nis_zero = elec['meter_reading'] == 0\n#giving each stretch of zeros / non-zeros its own number so I can count how long it lasts\nrun_id = ((is_zero != is_zero.shift()) | (elec['building_id'] != elec['building_id'].shift())).cumsum()\nrun_length = is_zero.groupby(run_id).transform('size')\nremove = is_zero & (run_length >= 48)\nprint(f'Zero readings removed: {remove.sum():,} ({remove.mean()*100:.1f}%)')\nelec = elec[~remove]\nlog_step('After removing 48+ hour zero stretches', elec)\n\n#Step 5: removing single hours that are more than 10x the building's normal (median) reading\nmedian_reading = elec.groupby('building_id')['meter_reading'].transform('median')\nspike = (median_reading > 0) & (elec['meter_reading'] > 10 * median_reading)\nprint(f'Spike readings removed: {spike.sum():,}')\nelec = elec[~spike]\nlog_step('After removing spikes', elec)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:38.732975Z","iopub.execute_input":"2026-09-27T21:43:38.733208Z","iopub.status.idle":"2026-09-27T21:43:42.259348Z","shell.execute_reply.started":"2026-09-27T21:43:38.733186Z","shell.execute_reply":"2026-09-27T21:43:42.258398Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Step 6: turning the hourly readings into one row per building\n#I use the average hourly reading x 8,760 hours in a year, so buildings with a few missing hours\n#are not unfairly counted as using less energy\nper_building = elec.groupby('building_id')['meter_reading'].agg(avg_hourly_kwh='mean', hours='count').reset_index()\nper_building['yearly_kwh'] = per_building['avg_hourly_kwh'] * 8760\n\n#Only keeping buildings with at least half a year of good readings (4,380 hours)\nper_building = per_building[per_building['hours'] >= 4380]\nlog_step('Buildings with at least half a year of readings', per_building)\n\n#Step 7: adding the weather - the average temperature at each site for the year\nsite_temp = weather.groupby('site_id')['air_temperature'].mean().rename('avg_temp').reset_index()\n\n#Step 8: joining everything into one table\ndf = per_building.merge(buildings, on='building_id', how='left').merge(site_temp, on='site_id', how='left')\n\n#Step 9: energy use per square foot, and the high use label\ndf['kwh_per_sqft'] = df['yearly_kwh'] / df['square_feet']\n#dropping buildings that used no electricity at all\ndf = df[df['kwh_per_sqft'] > 0]\nmedian_use = df['kwh_per_sqft'].median()\ndf['high_use'] = (df['kwh_per_sqft'] > median_use).astype(int)\nlog_step('Final building table', df)\n\nprint(f'\\nMedian energy use: {median_use:.1f} kWh per square foot per year')\nprint(df['high_use'].value_counts())\ndf[['building_id', 'primary_use', 'square_feet', 'year_built', 'avg_temp', 'kwh_per_sqft', 'high_use']].head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:42.26066Z","iopub.execute_input":"2026-09-27T21:43:42.260978Z","iopub.status.idle":"2026-09-27T21:43:42.566361Z","shell.execute_reply.started":"2026-09-27T21:43:42.260951Z","shell.execute_reply":"2026-09-27T21:43:42.565504Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Checking for missing values in the features I plan to use\nprint(df[['primary_use', 'square_feet', 'year_built', 'floor_count', 'avg_temp']].isnull().sum())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:42.567358Z","iopub.execute_input":"2026-09-27T21:43:42.567602Z","iopub.status.idle":"2026-09-27T21:43:42.575483Z","shell.execute_reply.started":"2026-09-27T21:43:42.56758Z","shell.execute_reply":"2026-09-27T21:43:42.574605Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Exploring the data","metadata":{}},{"cell_type":"code","source":"#Figure 1: distribution of energy use per square foot\nplt.figure(figsize=(8, 5))\nplt.hist(np.log10(df['kwh_per_sqft']), bins=40, color=BLUE)\nplt.axvline(np.log10(median_use), color=ORANGE, linestyle='--', linewidth=2, label=f'Median ({median_use:.1f} kWh/sq ft)')\nplt.title('Figure 1. Electricity Use per Square Foot', fontsize=14, fontweight='bold')\nplt.xlabel('kWh per square foot per year (log10 scale)', fontsize=12)\nplt.ylabel('Number of buildings', fontsize=12)\nplt.legend()\nplt.tight_layout()\nplt.savefig('fig1_kwh_per_sqft.png', dpi=200)\nplt.show()\n\n#Energy use per square foot is very spread out, so I show it on a log scale\n#Buildings to the right of the orange line are labeled high use","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:42.576638Z","iopub.execute_input":"2026-09-27T21:43:42.577094Z","iopub.status.idle":"2026-09-27T21:43:43.042077Z","shell.execute_reply.started":"2026-09-27T21:43:42.577057Z","shell.execute_reply":"2026-09-27T21:43:43.041273Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Figure 2: which building types are most often high use\n#Building types with fewer than 10 buildings are grouped into Other\ncounts = df['primary_use'].value_counts()\ndf['use_group'] = df['primary_use'].where(df['primary_use'].map(counts) >= 10, 'Other')\npct_high = (df.groupby('use_group')['high_use'].mean() * 100).sort_values()\n\nplt.figure(figsize=(9, 6))\nplt.barh(pct_high.index, pct_high.values, color=BLUE)\nplt.axvline(50, color=GRAY, linestyle='--')\nplt.title('Figure 2. Percent of Buildings Labeled High Use by Type', fontsize=14, fontweight='bold')\nplt.xlabel('% of buildings labeled high use', fontsize=12)\nplt.tight_layout()\nplt.savefig('fig2_high_use_by_type.png', dpi=200)\nplt.show()\nprint(pct_high.round(1))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:43.04505Z","iopub.execute_input":"2026-09-27T21:43:43.04534Z","iopub.status.idle":"2026-09-27T21:43:43.514998Z","shell.execute_reply.started":"2026-09-27T21:43:43.045311Z","shell.execute_reply":"2026-09-27T21:43:43.513985Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Preparing the features\n- **primary_use** is turned into dummy (0/1) columns.\n- **square_feet** is log transformed since a few buildings are huge.\n- **year_built** and **floor_count** are missing for many buildings, so I fill the missing values with\n  the median and add a 0/1 column that says the value was missing. That way I don't lose half the buildings.\n- **avg_temp** is the average outdoor temperature at the building's site.","metadata":{}},{"cell_type":"code","source":"X = pd.DataFrame({\n    'log_square_feet': np.log10(df['square_feet']),\n    'year_built': df['year_built'].fillna(df['year_built'].median()),\n    'year_built_missing': df['year_built'].isnull().astype(int),\n    'floor_count': df['floor_count'].fillna(df['floor_count'].median()).fillna(0),\n    'floor_count_missing': df['floor_count'].isnull().astype(int),\n    'avg_temp': df['avg_temp'].fillna(df['avg_temp'].median()),\n})\nX = pd.concat([X, pd.get_dummies(df['use_group'], prefix='type').astype(int)], axis=1)\ny = df['high_use']\n\n#Splitting into 80% training and 20% testing, keeping the same high/normal balance in both\nX_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)\nprint('Training set:', X_train.shape, ' Test set:', X_test.shape)\nprint('Features:', X.columns.tolist())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:43.516099Z","iopub.execute_input":"2026-09-27T21:43:43.516474Z","iopub.status.idle":"2026-09-27T21:43:43.532827Z","shell.execute_reply.started":"2026-09-27T21:43:43.516445Z","shell.execute_reply":"2026-09-27T21:43:43.531893Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# Methods\n---\nThis is a binary classification problem (high use vs. normal use), so I used two classification models:\n\n- **Logistic regression** is simple and easy to explain. Each feature gets a coefficient that shows\n  whether it makes a building more or less likely to be high use. I used it in DSC550 and it worked well.\n- **Random forest** builds many decision trees and combines them. It can pick up non-linear patterns\n  (for example, a building type that is only high use in hot climates) and gives a feature importance score.\n\nI also thought about the time series approach from my proposal (Prophet), but predicting every building's\ndaily use turned out to be much more complicated than needed to answer the main question of which buildings\nuse too much energy.\n\n**Validation:** 80/20 train/test split, 5-fold cross-validation on the training set, and accuracy,\nprecision, recall, confusion matrix, and ROC/AUC on the test set. The classes are balanced (split at the\nmedian), so accuracy is a fair measure here.","metadata":{}},{"cell_type":"code","source":"#Logistic regression (the features are scaled first so the coefficients can be compared)\nlog_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))\n#Random forest\nrf_model = RandomForestClassifier(n_estimators=300, random_state=42)\n\nresults = {}\nfor name, model in [('Logistic Regression', log_model), ('Random Forest', rf_model)]:\n    #5-fold cross-validation on the training data\n    cv_scores = cross_val_score(model, X_train, y_train, cv=5, scoring='accuracy')\n    model.fit(X_train, y_train)\n    y_pred = model.predict(X_test)\n    y_prob = model.predict_proba(X_test)[:, 1]\n    fpr, tpr, _ = roc_curve(y_test, y_prob)\n    results[name] = {'cv_mean': cv_scores.mean(), 'cv_std': cv_scores.std(),\n                     'test_accuracy': accuracy_score(y_test, y_pred), 'auc': auc(fpr, tpr),\n                     'y_pred': y_pred, 'fpr': fpr, 'tpr': tpr,\n                     'report': classification_report(y_test, y_pred, target_names=['Normal', 'High use'], output_dict=True)}\n    print(f'{name}')\n    print(f'  5-fold CV accuracy: {cv_scores.mean():.3f} (+/- {cv_scores.std():.3f})')\n    print(f'  Test accuracy: {results[name][\"test_accuracy\"]:.3f}   AUC: {results[name][\"auc\"]:.3f}')\n    print(classification_report(y_test, y_pred, target_names=['Normal', 'High use']))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:43.533775Z","iopub.execute_input":"2026-09-27T21:43:43.534119Z","iopub.status.idle":"2026-09-27T21:43:48.20656Z","shell.execute_reply.started":"2026-09-27T21:43:43.534088Z","shell.execute_reply":"2026-09-27T21:43:48.205645Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# Analysis\n---","metadata":{}},{"cell_type":"code","source":"#Figure 3: confusion matrices for both models\nfig, axes = plt.subplots(1, 2, figsize=(11, 4.5))\nfor ax, name in zip(axes, results):\n    cm = confusion_matrix(y_test, results[name]['y_pred'])\n    ax.imshow(cm, cmap='Blues')\n    ax.set_xticks([0, 1]); ax.set_yticks([0, 1])\n    ax.set_xticklabels(['Predicted Normal', 'Predicted High'])\n    ax.set_yticklabels(['Actual Normal', 'Actual High'])\n    #writing the count in each box\n    for i in range(2):\n        for j in range(2):\n            ax.text(j, i, cm[i, j], ha='center', va='center', fontsize=14,\n                    color='white' if cm[i, j] > cm.max() * 0.6 else 'black')\n    ax.set_title(name, fontsize=12, fontweight='bold')\nfig.suptitle('Figure 3. Confusion Matrices on the Test Set', fontsize=14, fontweight='bold')\nplt.tight_layout()\nplt.savefig('fig3_confusion_matrices.png', dpi=200)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:48.207729Z","iopub.execute_input":"2026-09-27T21:43:48.20829Z","iopub.status.idle":"2026-09-27T21:43:48.726187Z","shell.execute_reply.started":"2026-09-27T21:43:48.208251Z","shell.execute_reply":"2026-09-27T21:43:48.725217Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Figure 4: ROC curves for both models\nplt.figure(figsize=(7, 6))\nfor name, color in [('Logistic Regression', GRAY), ('Random Forest', BLUE)]:\n    plt.plot(results[name]['fpr'], results[name]['tpr'], color=color, linewidth=2,\n             label=f'{name} (AUC = {results[name][\"auc\"]:.3f})')\nplt.plot([0, 1], [0, 1], color='black', linestyle='--', linewidth=1, label='Random guessing')\nplt.title('Figure 4. ROC Curves', fontsize=14, fontweight='bold')\nplt.xlabel('False Positive Rate', fontsize=12)\nplt.ylabel('True Positive Rate', fontsize=12)\nplt.legend()\nplt.tight_layout()\nplt.savefig('fig4_roc_curves.png', dpi=200)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:48.727353Z","iopub.execute_input":"2026-09-27T21:43:48.727777Z","iopub.status.idle":"2026-09-27T21:43:49.160741Z","shell.execute_reply.started":"2026-09-27T21:43:48.727739Z","shell.execute_reply":"2026-09-27T21:43:49.159919Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Figure 5: which features matter most (random forest feature importance)\nimportance = pd.Series(rf_model.feature_importances_, index=X.columns).sort_values()\ntop = importance.tail(10)\n\nplt.figure(figsize=(9, 6))\nplt.barh(top.index, top.values, color=BLUE)\nplt.title('Figure 5. Top 10 Features (Random Forest)', fontsize=14, fontweight='bold')\nplt.xlabel('Feature importance', fontsize=12)\nplt.tight_layout()\nplt.savefig('fig5_feature_importance.png', dpi=200)\nplt.show()\n\n#Logistic regression coefficients: positive = more likely high use, negative = less likely\ncoefs = pd.Series(log_model.named_steps['logisticregression'].coef_[0], index=X.columns).sort_values()\nprint('Logistic regression coefficients:')\nprint(coefs.round(3))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:49.16216Z","iopub.execute_input":"2026-09-27T21:43:49.162821Z","iopub.status.idle":"2026-09-27T21:43:49.607688Z","shell.execute_reply.started":"2026-09-27T21:43:49.162783Z","shell.execute_reply":"2026-09-27T21:43:49.606825Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# Results Summary\n---\nThe next cell prints all the numbers used in the white paper.","metadata":{}},{"cell_type":"code","source":"summary = {\n    'cleaning_log': cleaning_log,\n    'buildings': int(len(df)),\n    'median_kwh_per_sqft': round(float(median_use), 2),\n    'kwh_per_sqft_quartiles': df['kwh_per_sqft'].quantile([0.25, 0.5, 0.75]).round(2).tolist(),\n    'pct_high_use_by_type': pct_high.round(1).to_dict(),\n    'buildings_by_type': df['use_group'].value_counts().to_dict(),\n    'train_size': int(len(X_train)), 'test_size': int(len(X_test)),\n    'models': {name: {'cv_mean': round(r['cv_mean'], 3), 'cv_std': round(r['cv_std'], 3),\n                      'test_accuracy': round(r['test_accuracy'], 3), 'auc': round(r['auc'], 3),\n                      'precision_high': round(r['report']['High use']['precision'], 3),\n                      'recall_high': round(r['report']['High use']['recall'], 3),\n                      'precision_normal': round(r['report']['Normal']['precision'], 3),\n                      'recall_normal': round(r['report']['Normal']['recall'], 3),\n                      'confusion_matrix': confusion_matrix(y_test, r['y_pred']).tolist()}\n               for name, r in results.items()},\n    'rf_feature_importance': importance.sort_values(ascending=False).round(3).to_dict(),\n    'logistic_coefficients': coefs.round(3).to_dict(),\n    'year_built_missing_pct': round(float(df['year_built'].isnull().mean() * 100), 1),\n}\nprint(json.dumps(summary, indent=1, default=str))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-27T21:43:49.609197Z","iopub.execute_input":"2026-09-27T21:43:49.609472Z","iopub.status.idle":"2026-09-27T21:43:49.623883Z","shell.execute_reply.started":"2026-09-27T21:43:49.609446Z","shell.execute_reply":"2026-09-27T21:43:49.622904Z"}},"outputs":[],"execution_count":null}]}