{ "cells": [ { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# Setup: install everything this notebook needs, regardless of environment.\n", "# (pandas/numpy/matplotlib often come preinstalled in Colab, but this makes the\n", "# notebook self-contained even in a bare Python environment.)\n", "!pip install -q pandas numpy matplotlib altair geopandas folium contextily shapely\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# Self-healing data fetch: if HouseholdIncome.csv / FHSZSRA_23_3.gdb aren't already\n", "# sitting next to this notebook (e.g. you opened just the .ipynb from GitHub/Colab\n", "# rather than downloading the whole repo), pull them from the project repo instead.\n", "import os\n", "\n", "if not os.path.exists('HouseholdIncome.csv') or not os.path.exists('FHSZSRA_23_3.gdb'):\n", " print('Local data files not found -- cloning them from the project repo...')\n", " !rm -rf /tmp/_lafires_data\n", " !git clone --depth 1 https://github.com/kalafejzo1/lafiresfinalproject.git /tmp/_lafires_data\n", " !cp /tmp/_lafires_data/HouseholdIncome.csv .\n", " !cp -r /tmp/_lafires_data/FHSZSRA_23_3.gdb .\n", " print('Done.')\n", "else:\n", " print('Local data files found -- using those.')\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "Gi8ZeogNWrSy" }, "outputs": [], "source": "import pandas as pd\nimport geopandas as gpd\nimport altair as alt" }, { "cell_type": "markdown", "metadata": { "id": "VXBGcFcDmBdP" }, "source": "Loading data (running locally in VS Code, not Colab)\n\nThis notebook originally used Colab's `files.upload()` picker. Running locally instead, so we point directly at the files already on disk / their public source URLs." }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "#Pull Zillow ZIP-level data directly from their public research URL (same source used later for Butte County)\n", "zillow_url = \"https://files.zillowstatic.com/research/public_csvs/zhvi/Zip_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv\"\n", "zillow = pd.read_csv(zillow_url)\n", "\n", "income = pd.read_csv(\"HouseholdIncome.csv\")" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "hazard = gpd.read_file(\n", " \"FHSZSRA_23_3.gdb\"\n", ")\n", "\n", "zip_shapes = gpd.read_file(\n", " \"https://www2.census.gov/geo/tiger/TIGER2024/ZCTA520/tl_2024_us_zcta520.zip\"\n", ")\n", "\n", "zillow['zip'] = zillow['RegionName'].astype(str).str.zfill(5)\n", "\n", "print(zip_shapes.head())\n", "print(zip_shapes.columns)\n", "\n", "hazard.head()" ] }, { "cell_type": "markdown", "metadata": { "id": "qK3qthU1lmWG" }, "source": [ "The Fire Hazard Severity Zone dataset was provided as a polygon shapefile, while the Zillow and Census datasets were organized by ZIP code. To make these datasets comparable, we created a ZIP-level wildfire exposure metric.\n", "\n", "First, Zillow data was filtered to Los Angeles County ZIP codes, which defined the geographic scope of our analysis. ZIP code boundary polygons were then obtained from the U.S. Census and filtered to match the ZIP codes present in the Zillow dataset.\n", "\n", "Next, both the ZIP code boundaries and Fire Hazard Severity Zone polygons were projected into the California Albers coordinate system (EPSG:3310) to ensure accurate area calculations. We then performed a spatial intersection between ZIP code polygons and fire hazard polygons. This allowed us to determine how much land area within each ZIP code fell into each fire hazard category (Moderate, High, and Very High).\n", "\n", "For each ZIP code, we calculated the total area classified as either High or Very High fire hazard and divided that value by the total land area of the ZIP code. This produced a wildfire exposure metric:\n", "\n", "Wildfire Exposure=\n", "Total ZIP Code Area /Area in High or Very High Hazard Zones\n", "\t\u200b\n", "\n", "\n", "The resulting variable, pct_high_very_high_hazard, ranges from 0 to 1 and represents the proportion of each ZIP code's land area that is exposed to elevated wildfire risk.\n", "\n", "This preprocessing step was necessary because the original fire hazard data was spatial (polygon-based), whereas our housing and demographic datasets were ZIP-level tabular datasets. Creating a ZIP-level wildfire exposure measure allowed us to merge wildfire risk, housing values, and income data into a single dataset and directly test our hypotheses regarding the relationship between income, housing markets, and wildfire risk." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "tXD38nDReUxT" }, "outputs": [], "source": [ "hazard_col = \"FHSZ_Description\"\n", "\n", "# 1. Clean Zillow ZIPs and filter to Los Angeles County\n", "zillow['zip'] = zillow['RegionName'].astype(str).str.zfill(5)\n", "\n", "zillow_la = zillow[\n", " zillow['CountyName'].str.contains(\"Los Angeles\", case=False, na=False)\n", "].copy()\n", "\n", "la_zips = set(zillow_la['zip'])\n", "\n", "# 2. Clean ZIP boundary ZIPs\n", "zip_shapes['zip'] = zip_shapes['ZCTA5CE20'].astype(str).str.zfill(5)\n", "\n", "zip_shapes_la = zip_shapes[\n", " zip_shapes['zip'].isin(la_zips)\n", "].copy()\n", "\n", "# 3. Project both layers into California Albers for accurate area calculations\n", "hazard_3310 = hazard.to_crs(\"EPSG:3310\")\n", "zip_shapes_la_3310 = zip_shapes_la.to_crs(\"EPSG:3310\")\n", "\n", "# 4. Calculate total area of each ZIP code\n", "zip_shapes_la_3310['zip_total_area'] = zip_shapes_la_3310.geometry.area\n", "\n", "zip_area = zip_shapes_la_3310[['zip', 'zip_total_area']].copy()\n", "\n", "# 5. Intersect ZIP polygons with fire hazard polygons\n", "hazard_zip = gpd.overlay(\n", " zip_shapes_la_3310[['zip', 'geometry']],\n", " hazard_3310[[hazard_col, 'geometry']],\n", " how='intersection'\n", ")\n", "\n", "# 6. Calculate area of each hazard category within each ZIP\n", "hazard_zip['hazard_area'] = hazard_zip.geometry.area\n", "\n", "hazard_summary = (\n", " hazard_zip\n", " .groupby(['zip', hazard_col])['hazard_area']\n", " .sum()\n", " .reset_index()\n", ")\n", "\n", "# 7. Pivot hazard categories into columns\n", "hazard_pivot = hazard_summary.pivot(\n", " index='zip',\n", " columns=hazard_col,\n", " values='hazard_area'\n", ").fillna(0).reset_index()\n", "\n", "# 8. Add High + Very High area\n", "hazard_pivot['high_very_high_area'] = 0\n", "\n", "for col in hazard_pivot.columns:\n", " if col in ['High', 'Very High']:\n", " hazard_pivot['high_very_high_area'] += hazard_pivot[col]\n", "\n", "# 9. Merge in total ZIP area\n", "hazard_pivot = hazard_pivot.merge(\n", " zip_area,\n", " on='zip',\n", " how='left'\n", ")\n", "\n", "# 10. Correct metric: % of ENTIRE ZIP area in High / Very High hazard zones\n", "hazard_pivot['pct_high_very_high_hazard'] = (\n", " hazard_pivot['high_very_high_area'] / hazard_pivot['zip_total_area']\n", ")\n", "\n", "# 11. Keep final clean hazard table\n", "hazard_clean = hazard_pivot[['zip', 'pct_high_very_high_hazard']].copy()\n", "\n", "# 12. Sanity checks\n", "print(hazard_clean.shape)\n", "print(hazard_clean['pct_high_very_high_hazard'].describe())\n", "\n", "hazard_clean.sort_values(\n", " 'pct_high_very_high_hazard',\n", " ascending=False\n", ").head(20)" ] }, { "cell_type": "markdown", "metadata": { "id": "9wiap0RGkLnO" }, "source": [ "Clean Zillow and create home value variables" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "QF-fXJ8Sjpmb" }, "outputs": [], "source": [ "date_cols = [c for c in zillow_la.columns if c[:4].isdigit()]\n", "\n", "latest_col = date_cols[-1] # latest Zillow date\n", "old_col = \"2015-05-31\" # 10 years before 2025-05\n", "\n", "zillow_clean = zillow_la[['zip', 'City', 'CountyName', latest_col, old_col]].copy()\n", "\n", "zillow_clean = zillow_clean.rename(columns={\n", " latest_col: 'current_home_value',\n", " old_col: 'home_value_2015'\n", "})\n", "\n", "zillow_clean['appreciation_10yr'] = (\n", " zillow_clean['current_home_value'] - zillow_clean['home_value_2015']\n", ") / zillow_clean['home_value_2015']" ] }, { "cell_type": "markdown", "metadata": { "id": "lKlgu-JGkNpx" }, "source": [ "Clean Census income" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "fnXW7FGQjqrq" }, "outputs": [], "source": [ "income_clean = income.copy()\n", "\n", "income_clean['zip'] = income_clean['NAME'].str.extract(r'(\\d{5})')\n", "\n", "income_clean = income_clean.rename(columns={\n", " 'B19013_001E': 'median_household_income'\n", "})\n", "\n", "income_clean['median_household_income'] = pd.to_numeric(\n", " income_clean['median_household_income'],\n", " errors='coerce'\n", ")\n", "\n", "income_clean = income_clean[['zip', 'median_household_income']]" ] }, { "cell_type": "markdown", "metadata": { "id": "Der1svQHkRnF" }, "source": [ "Merge everything" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "tzYONbaRjt56" }, "outputs": [], "source": "final_df = (\n zillow_clean\n .merge(income_clean, on='zip', how='left')\n .merge(hazard_clean, on='zip', how='left')\n)\n\n# ZIPs with no spatial overlap with any fire hazard polygon never get a row from the\n# intersection step, so they come back as NaN here. Treat \"no overlap\" as 0% exposure.\nfinal_df['pct_high_very_high_hazard'] = final_df['pct_high_very_high_hazard'].fillna(0)\n\nfinal_df.head()" }, { "cell_type": "markdown", "metadata": { "id": "8doITl7WkUD4" }, "source": [ "Inspect data before visualizing" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "HkHKunL2kGWx" }, "outputs": [], "source": [ "final_df[['zip',\n", " 'median_household_income',\n", " 'current_home_value',\n", " 'appreciation_10yr',\n", " 'pct_high_very_high_hazard']].head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "RChQGU3GkYOy" }, "outputs": [], "source": [ "final_df.isna().sum()" ] }, { "cell_type": "markdown", "metadata": { "id": "6SFTPwSUkl2-" }, "source": [ "Hypothesis 1:\n", "Higher-income ZIP codes experience lower wildfire exposure than lower-income ZIP codes.\n", "\n", "Test hypothesis numerically" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "hNHidVOPkfcx" }, "outputs": [], "source": [ "eda_df = final_df.dropna(\n", " subset=['median_household_income', 'pct_high_very_high_hazard']\n", ").copy()\n", "\n", "eda_df[['median_household_income',\n", " 'pct_high_very_high_hazard']].corr()" ] }, { "cell_type": "markdown", "metadata": { "id": "WW20_6SNnlbw" }, "source": [ "Visualize with scatter and linear regression" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "9kP8m0IDnnQ-" }, "outputs": [], "source": [ "scatter1 = alt.Chart(eda_df).mark_circle(\n", " size=60,\n", " opacity=0.5\n", ").encode(\n", " x=alt.X(\n", " 'median_household_income:Q',\n", " title='Median Household Income ($)'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High/Very High Fire Hazard Zone'\n", " ),\n", " tooltip=[\n", " 'zip',\n", " 'City',\n", " 'median_household_income',\n", " 'pct_high_very_high_hazard'\n", " ]\n", ").properties(\n", " title='Income vs Wildfire Exposure',\n", " width=700,\n", " height=450\n", ")\n", "\n", "scatter1" ] }, { "cell_type": "markdown", "metadata": { "id": "_I0pOa2cosNL" }, "source": [ "The initial numerical analysis and scatterplot do not support our hypothesis. Instead of observing lower wildfire exposure among higher-income ZIP codes, we found a weak positive relationship between income and wildfire exposure (r = 0.212). The scatterplot suggests that several of the highest-income ZIP codes also experience some of the highest levels of wildfire risk. This unexpected result motivated further exploration." ] }, { "cell_type": "markdown", "metadata": { "id": "u3Dvy5PQo8wP" }, "source": [ "Creating income quartiles" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "hjzC5h7aow44" }, "outputs": [], "source": [ "eda_df['income_group'] = pd.qcut(\n", " eda_df['median_household_income'],\n", " q=4,\n", " labels=[\n", " 'Lowest Income',\n", " 'Lower-Middle Income',\n", " 'Upper-Middle Income',\n", " 'Highest Income'\n", " ]\n", ")\n", "\n", "eda_df[['median_household_income', 'income_group']].head()" ] }, { "cell_type": "markdown", "metadata": { "id": "N7vHnTa1o-Ng" }, "source": [ "Creating boxplot" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "Hve0h5_lpA8y" }, "outputs": [], "source": [ "income_order = [\n", " 'Lowest Income',\n", " 'Lower-Middle Income',\n", " 'Upper-Middle Income',\n", " 'Highest Income'\n", "]\n", "\n", "boxplot = alt.Chart(eda_df).mark_boxplot(size=50).encode(\n", " x=alt.X(\n", " 'income_group:N',\n", " sort=income_order,\n", " title='Income Group'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High / Very High Fire Hazard Zones'\n", " ),\n", " tooltip=[\n", " 'income_group',\n", " alt.Tooltip('pct_high_very_high_hazard:Q', format='.2f')\n", " ]\n", ").properties(\n", " title='Wildfire Exposure Across Income Groups',\n", " width=700,\n", " height=450\n", ")\n", "\n", "boxplot" ] }, { "cell_type": "markdown", "metadata": { "id": "gOAR7O32pUa2" }, "source": [ "Your highest-income group has the widest spread and reaches the highest wildfire exposure values. That does not support the hypothesis that higher-income ZIPs are less exposed to wildfire risk. It suggests some wealthy ZIPs are highly exposed, likely hillside/coastal areas like Malibu, Topanga, and Calabasas." ] }, { "cell_type": "markdown", "metadata": { "id": "q5MRyKxSpe9F" }, "source": [ "Colored Scatterplot" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "GcNDKeoVpjrq" }, "outputs": [], "source": [ "alt.Chart(eda_df).mark_circle(\n", " size=150,\n", " opacity=0.75\n", ").encode(\n", " x=alt.X(\n", " 'median_household_income:Q',\n", " title='Median Household Income ($)'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High / Very High Fire Hazard Zones'\n", " ),\n", " color=alt.Color(\n", " 'current_home_value:Q',\n", " title='Current Home Value ($)',\n", " scale=alt.Scale(scheme='viridis')\n", " ),\n", " tooltip=[\n", " 'zip',\n", " 'City',\n", " alt.Tooltip('median_household_income:Q', format=',.0f'),\n", " alt.Tooltip('current_home_value:Q', format='$,.0f'),\n", " alt.Tooltip('pct_high_very_high_hazard:Q', format='.2f')\n", " ]\n", ").properties(\n", " title='Income, Home Values, and Wildfire Exposure',\n", " width=700,\n", " height=500\n", ")" ] }, { "cell_type": "markdown", "metadata": { "id": "VvVLDimgp-cu" }, "source": [ "This visualization incorporates a third variable, current home value, into the relationship between income and wildfire exposure. Color allows us to identify whether high-risk ZIP codes are also high-value real estate markets. The chart reveals that several ZIP codes with both high incomes and substantial wildfire exposure also have some of the highest housing values in Los Angeles County." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 535 }, "id": "VMM_Yb5TsOek", "outputId": "44bf767a-85ab-4425-fc39-a3d792fe2e26" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "
\n", "" ], "text/plain": [ "alt.LayerChart(...)" ] }, "execution_count": 1, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import pandas as pd\n", "import altair as alt\n", "\n", "#Pull Zillow County-level Data directly from their public URL\n", "#This bypasses the need for any local CSV uploads\n", "zillow_url = \"https://files.zillowstatic.com/research/public_csvs/zhvi/County_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv\"\n", "df = pd.read_csv(zillow_url)\n", "\n", "#Filter for California\n", "ca_df = df[df['State'] == 'CA'].copy()\n", "\n", "#Focus on Butte County (Camp Fire, Nov 2018) vs. State Average\n", "#We will look at Jan 2017 through early 2024 to see the before and after\n", "date_cols = [c for c in ca_df.columns if c.startswith('2017') or c.startswith('2018') or\n", " c.startswith('2019') or c.startswith('2020') or c.startswith('2021') or\n", " c.startswith('2022') or c.startswith('2023') or c.startswith('2024')]\n", "\n", "#Calculate CA average across all counties\n", "ca_avg = ca_df[date_cols].mean()\n", "\n", "#Get Butte County specifically\n", "butte = ca_df[ca_df['RegionName'] == 'Butte County'][date_cols].mean()\n", "\n", "#Combine for Altair plotting\n", "plot_df = pd.DataFrame({\n", " 'Date': pd.to_datetime(date_cols),\n", " 'Butte County': butte.values,\n", " 'California Average': ca_avg.values\n", "}).melt(id_vars=['Date'], var_name='Region', value_name='Typical Home Value')\n", "\n", "#Visualize the trend\n", "line_chart = alt.Chart(plot_df).mark_line(size=3).encode(\n", " x=alt.X('Date:T', title='Year'),\n", " y=alt.Y('Typical Home Value:Q', scale=alt.Scale(zero=False), title='Zillow Home Value Index ($)'),\n", " color=alt.Color('Region:N', title='Area', scale=alt.Scale(scheme='set1')),\n", " tooltip=[alt.Tooltip('Date:T', format='%b %Y'), 'Region:N', alt.Tooltip('Typical Home Value:Q', format='$,.0f')]\n", ").properties(\n", " title='Housing Value Recovery Post-2018 Camp Fire (Butte County vs. CA Average)',\n", " width=700,\n", " height=450\n", ")\n", "\n", "#Add a vertical line for the Camp Fire (November 8, 2018)\n", "fire_date = pd.DataFrame({'Date': [pd.to_datetime('2018-11-08')]})\n", "rule = alt.Chart(fire_date).mark_rule(color='red', strokeDash=[5, 5], size=2).encode(x='Date:T')\n", "\n", "#Combine and show\n", "(line_chart + rule)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 535 }, "id": "kn49Pxt2OuVt", "outputId": "b883f88d-ac5f-4a51-9178-ce276da0c692" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "" ], "text/plain": [ "alt.LayerChart(...)" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "#Identify the Zillow date column corresponding to the fire (Nov 2018)\n", "fire_month_col = [c for c in date_cols if '2018-11' in c][0]\n", "\n", "#Baseline both series to 100 at the time of the fire\n", "butte_indexed = (butte / butte[fire_month_col]) * 100\n", "ca_avg_indexed = (ca_avg / ca_avg[fire_month_col]) * 100\n", "\n", "#Prepare data for plotting\n", "plot_idx_df = pd.DataFrame({\n", " 'Date': pd.to_datetime(date_cols),\n", " 'Butte County': butte_indexed.values,\n", " 'California Average': ca_avg_indexed.values\n", "}).melt(id_vars=['Date'], var_name='Region', value_name='Index Value')\n", "\n", "#Filter to only show the timeline from the fire onward (Nov 2018 to early 2024)\n", "plot_idx_df = plot_idx_df[plot_idx_df['Date'] >= '2018-10-01']\n", "\n", "#Visualize the Indexed Trend\n", "indexed_chart = alt.Chart(plot_idx_df).mark_line(size=3).encode(\n", " x=alt.X('Date:T', title='Year'),\n", " y=alt.Y('Index Value:Q', scale=alt.Scale(domain=[90, 160]), title='Indexed Home Value (Nov 2018 = 100)'),\n", " color=alt.Color('Region:N', title='Area', scale=alt.Scale(scheme='set1')),\n", " tooltip=[alt.Tooltip('Date:T', format='%b %Y'), 'Region:N', alt.Tooltip('Index Value:Q', format='.1f')]\n", ").properties(\n", " title='Indexed Property Value Recovery Post-Camp Fire (Base 100)',\n", " width=700,\n", " height=450\n", ")\n", "\n", "#Combine with the fire date rule\n", "(indexed_chart + rule)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 380 }, "id": "mtvHryd8PguT", "outputId": "c46ffac7-c38b-4393-c870-ae6998915ef3" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "" ], "text/plain": [ "alt.Chart(...)" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "#Identify 1-year, 3-year, and 5-year post-fire date columns\n", "yr1_col = [c for c in date_cols if '2019-11' in c][0]\n", "yr3_col = [c for c in date_cols if '2021-11' in c][0]\n", "yr5_col = [c for c in date_cols if '2023-11' in c][0]\n", "\n", "#Calculate percentage changes for Butte and CA Average\n", "def calc_appreciation(series, start_col, end_col):\n", " return ((series[end_col] - series[start_col]) / series[start_col]) * 100\n", "\n", "butte_appr = {\n", " '1-Year': calc_appreciation(butte, fire_month_col, yr1_col),\n", " '3-Year': calc_appreciation(butte, fire_month_col, yr3_col),\n", " '5-Year': calc_appreciation(butte, fire_month_col, yr5_col)\n", "}\n", "\n", "ca_appr = {\n", " '1-Year': calc_appreciation(ca_avg, fire_month_col, yr1_col),\n", " '3-Year': calc_appreciation(ca_avg, fire_month_col, yr3_col),\n", " '5-Year': calc_appreciation(ca_avg, fire_month_col, yr5_col)\n", "}\n", "\n", "#Calculate Relative Performance (Butte - CA Average)\n", "rel_perf = {\n", " '1-Year': butte_appr['1-Year'] - ca_appr['1-Year'],\n", " '3-Year': butte_appr['3-Year'] - ca_appr['3-Year'],\n", " '5-Year': butte_appr['5-Year'] - ca_appr['5-Year']\n", "}\n", "\n", "perf_df = pd.DataFrame(list(rel_perf.items()), columns=['Timeframe', 'Relative Appreciation (%)'])\n", "perf_df['Performance'] = perf_df['Relative Appreciation (%)'].apply(lambda x: 'Outperform' if x > 0 else 'Underperform')\n", "\n", "#Visualize Relative Performance\n", "bar_chart = alt.Chart(perf_df).mark_bar().encode(\n", " y=alt.Y('Timeframe:N', sort=['1-Year', '3-Year', '5-Year'], title='Time Since Fire'),\n", " x=alt.X('Relative Appreciation (%):Q', title='Appreciation Relative to CA Average (%)'),\n", " color=alt.Color('Performance:N', scale=alt.Scale(domain=['Outperform', 'Underperform'], range=['#4c78a8', '#f58518'])),\n", " tooltip=['Timeframe', alt.Tooltip('Relative Appreciation (%):Q', format='.2f')]\n", ").properties(\n", " title='Butte County Home Value Performance Relative to CA Average',\n", " width=700,\n", " height=300\n", ")\n", "\n", "bar_chart" ] }, { "cell_type": "markdown", "metadata": { "id": "bhs9BwDLPipW" }, "source": [ "Interactive Map: Putting the ZIP Codes on the Map\n", "\n", "Every visualization so far (scatterplots, boxplot, colored scatter) treats ZIP codes as abstract points on an income/wildfire axis, with no sense of where they actually are in Los Angeles County. Since we already have ZIP boundary polygons (`zip_shapes_la`) from the spatial join used to build our wildfire exposure metric, we can reuse those same geometries to build an interactive choropleth map. This lets a user toggle between wildfire exposure, income, home value, and appreciation as the fill color, and hover over any ZIP to see all four metrics at once \u2014 giving geographic context that a scatterplot can't." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import folium\n", "from folium.features import GeoJsonTooltip\n", "\n", "# --- Build a single GeoDataFrame combining ZIP boundaries with all ZIP-level metrics ---\n", "map_df = zip_shapes_la[['zip', 'geometry']].merge(final_df, on='zip', how='inner')\n", "map_df = map_df.set_geometry('geometry')\n", "\n", "if map_df.crs is None or map_df.crs.to_epsg() != 4326:\n", " map_df = map_df.to_crs(epsg=4326)\n", "\n", "map_df['wildfire_pct'] = map_df['pct_high_very_high_hazard'] * 100\n", "\n", "map_df = map_df.dropna(subset=[\n", " 'median_household_income', 'current_home_value',\n", " 'appreciation_10yr', 'wildfire_pct'\n", "])\n", "\n", "# --- Base map centered on LA County ---\n", "m = folium.Map(location=[34.05, -118.25], zoom_start=9, tiles='cartodbpositron')\n", "\n", "metrics = [\n", " ('wildfire_pct', 'Wildfire Exposure (%)', 'YlOrRd'),\n", " ('median_household_income', 'Median Household Income ($)', 'BuGn'),\n", " ('current_home_value', 'Current Home Value ($)', 'PuBu'),\n", " ('appreciation_10yr', '10-Year Appreciation (fraction)', 'YlGnBu'),\n", "]\n", "\n", "choropleth_names = []\n", "for i, (col, label, palette) in enumerate(metrics):\n", " choropleth = folium.Choropleth(\n", " geo_data=map_df,\n", " data=map_df,\n", " columns=['zip', col],\n", " key_on='feature.properties.zip',\n", " fill_color=palette,\n", " fill_opacity=0.75,\n", " line_opacity=0.2,\n", " legend_name=label,\n", " name=label,\n", " show=(i == 0), # only the first metric is visible on load\n", " overlay=True, # a real overlay, not a \"base layer\" -- keeps it from fighting the tile layer\n", " ).add_to(m)\n", " choropleth_names.append(choropleth.get_name())\n", "\n", " # Only keep one legend rendered at a time so they don't stack on screen\n", " if i != 0:\n", " for key in list(choropleth._children):\n", " if key.startswith('color_map'):\n", " del choropleth._children[key]\n", "\n", "# --- Hover tooltip overlay: shows all metrics regardless of which layer is toggled on ---\n", "tooltip_layer = folium.GeoJson(\n", " map_df,\n", " style_function=lambda x: {'fillOpacity': 0, 'color': 'transparent'},\n", " tooltip=GeoJsonTooltip(\n", " fields=['zip', 'City', 'median_household_income',\n", " 'current_home_value', 'appreciation_10yr', 'wildfire_pct'],\n", " aliases=['ZIP:', 'City:', 'Median Income ($):', 'Home Value ($):',\n", " '10-Yr Appreciation:', 'Wildfire Exposure (%):'],\n", " localize=True,\n", " ),\n", " name='ZIP details (hover)',\n", " overlay=True,\n", " control=True,\n", ").add_to(m)\n", "\n", "folium.LayerControl(collapsed=False).add_to(m)\n", "\n", "# Enforce \"only one metric visible at a time\" via JS instead of Folium's base-layer mechanism\n", "# (base layers are mutually exclusive with EACH OTHER *and* the tile layer, which was silently\n", "# hiding the street basemap every time a different metric was picked).\n", "exclusive_js = f\"\"\"\n", "\n", "\"\"\"\n", "m.get_root().html.add_child(folium.Element(exclusive_js))\n", "\n", "m" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "What's informative about this view: The map makes the earlier finding concrete \u2014 the highest-income, highest-wildfire-exposure ZIP codes (Malibu, Topanga, Calabasas) are visibly clustered along the coastal and foothill edges of the county, not scattered randomly. Toggling between layers shows that wildfire exposure and home value follow a similar geographic footprint, while median income spreads out differently, which is exactly the mismatch our correlation analysis picked up on. This is also the closest thing to an actual \"prototype interaction\" for the mid-term demo: it has a layer toggle and hover tooltips a user can click through live during the presentation.\n", "\n", "What could be improved: The four layers use different color scales and bin breaks, so \"toggle and compare\" still relies on the viewer remembering the previous layer's colors rather than seeing both at once. A future iteration could add a side-by-side small-multiples view, or a bivariate color scheme (e.g., income x wildfire exposure in one legend) to show the mismatch directly instead of requiring the user to switch layers back and forth. We could also overlay the Woolsey and Palisades fire perimeters on this same map to connect it to Hypotheses 2 and 3." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Numbered Highlight Pins: Top ZIP Codes to Watch\n", "\n", "To make the map easier to read at a glance during the presentation, we add numbered pins (styled after city-guide \"top spots\" maps) marking the 6 ZIP codes that best capture our central finding from Hypothesis 1: high home values sitting in areas of high wildfire exposure. These are the ZIP codes an investor or homebuyer would most want to interrogate further \u2014 high current value, but also high fire risk." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import warnings\n", "warnings.filterwarnings('ignore', category=UserWarning)\n", "\n", "# --- Rank ZIPs by wildfire exposure, restricted to above-median home value ---\n", "# This surfaces the \"wealthy but exposed\" ZIP codes our Hypothesis 1 analysis found\n", "highlight_df = (\n", " map_df[map_df['current_home_value'] > map_df['current_home_value'].median()]\n", " .sort_values('wildfire_pct', ascending=False)\n", " .head(6)\n", " .reset_index(drop=True)\n", ")\n", "highlight_df['rank'] = highlight_df.index + 1\n", "\n", "for _, row in highlight_df.iterrows():\n", " centroid = row.geometry.centroid\n", "\n", " pin_html = f\"\"\"\n", "