Skip to content

Bamboo Weekly #181: Housing costs (solution)

Get better at: Working with CSV files, dates and times, grouping, pivot tables, cleaning, and regular expressions

Bamboo Weekly #181: Housing costs (solution)

[Administrative note: We'll have Pandas office hours later today! Office hours are for paid BW subscribers, but also for LernerPython+data subscribers, who can get the Zoom info on the (new!) personal calendar at https://lernerpython.com/my-account/my-calendar/. That personal calendar also has dozens of archived members-only lectures on a variety of topics.]

This week, I heard at least two podcasts that discussed the tight housing market in the United States, including Marketplace (https://www.marketplace.org/story/2026/07/28/as-mortgage-rates-stay-high-this-lender-is-seeing-buyers-accept-the-new-normal#as-mortgage-rates-stay-high-this-lender-is-seeing-buyers-accept-the-new-normal) and Slate Money (https://slate.com/podcasts/slate-money/2026/07/business-your-electricity-bill-is-growing-with-a-i). On both, I heard that there is just too little housing stock in the US. Reduced supply and increased demand will, pretty clearly, lead to higher prices.

I thought that it might be interesting to look at the American housing market. But then I remembered hearing that other countries were also faced with expensive housing, and thought that it might be interesting to compare the situation across the world, and see where housing prices have risen most dramatically, and where they haven't.

It's true that mortgage rates also influence the cost of housing – but because mortgages are so different in different countries, I decided to concentrate on the housing prices themselves, and see how much they have changed.

Data and six questions

This week, we'll look at historical housing-price data from the Bank for International Settlements (https://bis.org), an international organization that coordinates communication and policy among central banks. They collect and publish a wide variety of data from countries around the world, including residential property prices (https://data.bis.org/topics/RPP/data).

You can export the data from the BIS site, but we want more rows than it would actually allow us to export. For that reason, I suggest just retrieving the CSV file directly from the site:

https://stats.bis.org/api/v2/data/dataflow/BIS/WS_SPP/1.0/?format=csv&labels=both

Notice several parts of this request:

The analysis comes in four different flavors, in two different axes:

Paid subscribers, both to Bamboo Weekly and to my LernerPython+data membership program (https://LernerPython.com) get all of the questions and answers, as well as downloadable data files, downloadable versions of my notebooks, one-click access to my notebooks, and invitations to monthly office hours.

Learning goals for this week include cleaning data, dates and times, grouping, plotting with Plotly, regular expressions, and built-in window functions.

Here are this week's six questions, as well as my solutions and explanations:

Download the data from BIS, and turn it into a Pandas data frame. Create a date column (with a datetime dtype) from the TIME_PERIOD column, choosing the first day of the first month in the specified quarter. (So Q1 will be January 1st, Q2 will be April 1st, etc.) This means that you'll have four distinct dates for each year, one per quarter.

I started off, as usual, by loading both Pandas and Plotly:

import pandas as pd
from plotly import express as px

I then defined the URL as a variable, and used read_csv to read it directly from that URL into Pandas. I used the usecols keyword argument to indicate which of the file's columns I was actually interested in using:

url = "https://stats.bis.org/api/v2/data/dataflow/BIS/WS_SPP/1.0/?format=csv&labels=both"

df = (
    pd
    .read_csv(url,
             usecols=['VALUE', 'UNIT_MEASURE', 'TIME_PERIOD', 'OBS_VALUE',
                      'REF_AREA', 'Reference area']
             )
)

Why specify usecols? Often, we say that it's a good idea to indicate which columns we want because it'll reduce the amount of memory used by the data frame. And that is true, but it won't reduce the loading time; the entire CSV file needs to be downloaded and loaded into memory before Pandas can do anything with it. Mostly, I decided to pass usecols just to make the data frame easier to read and work with for me, the human writing these queries. I made sure to keep Reference area, the full-name version of each country and international aggregation, so that I could more easily understand and work with them.

This left the question of how to get datetime values from the TIME_PERIOD column in the original CSV file, which came in the format of YYYY-QN, with a four-digit year and a one-digit quarter. I wanted to use pd.to_datetime, a function that creates datetime values from strings, but it doesn't (so far as I know) support quarter numbers.

I decided to create two columns, year and quarter, with the individual parts of this column. Then I can create a month column, translating the quarter numbers into month names. Then I can create a date column with pd.to_datetime , using the year and month names.

I did all of this with the assign method, which returns a new data frame identical to the one on which it was invoked, but with one or more new columns added. assign uses keyword arguments, with the keyword used as the name of the new column and its value as the value – unless it's a callable, in which case the result of the callable is used as the value.

In modern Python, dictionaries are kept in chronological order, and that applies to keyword arguments, too. For that reason, later keywords in assign can refer to earlier ones, saving us from needing multiple assignments. Here is what I did to get the year, quarter, and month columns:

With these in place, I was able to invoke pd.to_datetime on the combination of year and month, passing the format string '%Y-%B', which means, "four-digit year, a hyphen, and the full name of the month."

df = (
    pd
    .read_csv(url,
             usecols=['VALUE', 'UNIT_MEASURE', 'TIME_PERIOD', 'OBS_VALUE',
                      'REF_AREA', 'Reference area']
             )
    .assign(year = pd.col('TIME_PERIOD').str.slice(0, 4),
            quarter = pd.col('TIME_PERIOD').str.get(-1),
            month = pd.col('quarter').map({'1':'January', '2':'April', 
                                           '3':'July', '4':'September'}),
            date = lambda df_: pd.to_datetime(
                            df_['year'] + '-' + df_['month'],
                            format='%Y-%B'))
)

I then finished this off by invoking drop to remove the columns that will no longer come in handy:

df = (
    pd
    .read_csv(url,
             usecols=['VALUE', 'UNIT_MEASURE', 'TIME_PERIOD', 'OBS_VALUE',
                      'REF_AREA', 'Reference area']
             )
    .assign(year = pd.col('TIME_PERIOD').str.slice(0, 4),
            quarter = pd.col('TIME_PERIOD').str.get(-1),
            month = pd.col('quarter').map({'1':'January', '2':'April', 
                                           '3':'July', '4':'September'}),
            date = lambda df_: pd.to_datetime(
                            df_['year'] + '-' + df_['month'],
                            format='%Y-%B'))
    .drop(columns=['year', 'quarter', 'month', 'TIME_PERIOD'])
)

The resulting data frame contains 35,520 rows and 6 columns.

But wait: There's an easier way to turn quarters into timestamps. We can just use PeriodIndex and then to_timestamp, as indicated here:


(
    pd
    .read_csv(url,
             usecols=['VALUE', 'UNIT_MEASURE', 'TIME_PERIOD', 'OBS_VALUE',
                      'REF_AREA', 'Reference area']
             )
    .assign(date = lambda df_: pd.PeriodIndex(df_['TIME_PERIOD'], freq='Q').to_timestamp())
)

Same results, with much less fuss!

Create a line plot showing, over time, the percentage change in nominal housing costs in emerging economies vs. advanced economies. Do we see prices changing more dramatically in one rather than the other? Repeat this for real values; do things change? (Note that aggregates start from about 2008, as opposed to countries, which start earlier.)

To answer this question, we want to see whether prices change more in emerging economies or advanced economies. These are the two aggregated groups for which we have data, and it'll be interesting to see where (and when) their housing prices went up.

In order to perform this analysis, we first need to filter the rows, to keep only the ones that are of interest:

For all of these, I used loc, which allows us to filter rows (and columns, albeit not here) by any criteria we want.

With these in place, we then want to have two columns, one for each reference area (emerging and advanced), with rows for the dates. That'll allow us to easily plot the two values against one another. For that, we can use pivot_table, which organizes it nicely for us.

Finally, we can use pipe along with px.line to get a nice line plot using Plotly:


(
    df
    .loc[pd.col('REF_AREA').str.get(0).str.isdigit()]
    .loc[pd.col("UNIT_MEASURE") == 771]
    .loc[pd.col('VALUE') == 'N']
    .pivot_table(columns='Reference area',
                index='date',
                values='OBS_VALUE')
    .pipe(px.line)
    
)

Here's the resulting plot:

We can even see some times when housing prices dropped, especially just before 2010. Which corresponds to the Great Recession, when housing prices in the United States – and to some degree, elsewhere in the world – dropped like a stone, in part because so many people lost their homes.

What if we look at real prices (i.e., including inflation) rather than nominal ones? Here's the query:


(
    df
    .loc[pd.col('REF_AREA').str.get(0).str.isdigit()]
    .loc[pd.col("UNIT_MEASURE") == 771]
    .loc[pd.col('VALUE') == 'R']
    .pivot_table(columns='Reference area',
                index='date',
                values='OBS_VALUE')
    .pipe(px.line)
    
)

As you can see, it's identical to the previous one, except that we look for R, rather than N. But the plot looks exactly the same:

The plots look the same, but look carefully at the y axis: With nominal prices, the top number is about 12, showing a 12 percent increase in prices. But with real prices, the top number is about 8.

In other words: In 2023, nominal house prices in advanced economies were roughly flat while real prices fell about 5 percent – because inflation was running at 5-9 percent. Housing didn't stop getting more expensive in currency terms; it got materially cheaper relative to everything else people buy.