LSE ME204 · Data Engineering Principles for the Social Sciences
🖥️ Week 02 Day 04 Lecture
10:00 – 10:45
How to give a name to code you want to run more than once
We will use this data for the first part of the lecture:
A reusable block of code that takes inputs and produces an output.
Key components:
def add(a, b): """Return the sum of a and b.""" result = a + b return result print(add(3, 5))
8
describe(hero)A function that formats a hero dictionary into a readable string.
Aria the Warrior
combat_power(hero)A function whose output feeds the next one.
13
fight(hero, monster)A function that calls the other two inside its body.
def fight(hero, monster):
"""hero needs name, role, strength, magic.
monster needs name, power."""
power = combat_power(hero)
if power > monster["power"]:
return f"{describe(hero)} defeated the {monster['name']}!"
elif power == monster["power"]:
return "A draw. Both withdraw."
else:
return f"The {monster['name']} was too strong!"The same three functions, different input each time.
Aria the Warrior defeated the Giant Slug!
Aria the Warrior defeated the Fire Drake!
The Shadow Wraith was too strong!
The three functions produced different outcomes because the monster changed, not the code.
The same three parts, rearranged into one line.
Before (for loop):
results = [] for monster in monsters: hero = fight(hero, monster) results.append(hero)
After (list comprehension):
results = [ fight(hero, monster) for monster in monsters ]
The expression moves to the front, the variable and iterable stay in the same order, and the result list is built for you.
The same pieces in one line:
results = [fight(hero, monster) for monster in monsters]
lambda: a function without a nameA one-expression function you can write inline.
When a function is a single expression, you can skip the def and write it as a lambda.
When the logic grows beyond one line, use a named def instead.
These lambda functions are very useful inside pandas methods like .agg() and .apply().
The random module can generate data. The functions that process it do not need to change.
fight, describe, and combat_power take a dict with the right keys regardless of where it came from.
This website has a nice and simple tutorial: Mimo.org: Random Module in Python
10:45 – 11:30
How to turn the JSON shapes you have been collecting into tables
I don’t know if you noticed when you were working on your midterm, but pd.DataFrame() accepts either shape.
Dictionary of lists (Open-Meteo):
Both calls produce the same table:
| dt | temp |
|---|---|
| 2025-07-01 | 22.1 |
| 2025-07-02 | 25.8 |
| 2025-07-03 | 19.4 |
Even though pandas handles both, let’s practise writing the conversion ourselves.
Input (list of dicts):
→
Open ME204_W02D04_Lecture.ipynb run the code up until the section titled “🎯 Challenge: Convert between the two shapes” and write a for loop that produces the target. Work in 👥 Pairs.
Solution shown live in the notebook.
Take your working loop and place it inside this skeleton.
records_to_columnsSolution shown live in the notebook.
A list of dictionaries with "name" and "grade" instead of weather fields.
This breaks with the current version of records_to_columns because the keys "dt", "temp", "wind_speed", and "condition" are written into the function body.
Try to update that same function so it would work for both inputs. Here is how to test it:
The question is whether one function can reshape any list of flat dictionaries, regardless of what the keys are called.
Solution shown live in the notebook.
OpenWeather One Call 4.0 wraps the records inside a "data" key and adds lat, lon, and timezone at the top level.
Try to update records_to_columns so it handles this too. Here is how to test it:
Solution shown live in the notebook.
Even then, it won’t work with any type of nested JSON.
Unnesting JSON is common enough that pandas has dedicated functions for it.
The exercises you worked through this morning (finding the list, attaching metadata, looping over keys) are the same steps json_normalize takes internally. That practice will help you debug when something goes wrong with the real function.
Want to see how json_normalize is implemented?
Check out the source code of the latest version on GitHub.
11:30 – 11:45
11:45 – 12:35
What each parameter does and when to use a different tool instead
When every record is a flat dictionary, pd.json_normalize and pd.DataFrame produce the same table.
Both produce:
| dt | temp | wind_speed | condition |
|---|---|---|---|
| 2025-07-01 | 22.1 | 3.2 | Clear |
| 2025-07-02 | 25.8 | 5.1 | Clouds |
| 2025-07-03 | 19.4 | 7.8 | Rain |
json_normalize becomes useful when the JSON is not flat.
record_path: where is the list of rows?record_path tells pandas which key holds the list. Each item in that list becomes one row.
response = {
"lat": 51.5,
"lon": -0.1,
"timezone": "Europe/London",
"data": [
{"dt": 1777452300,
"temp": 287.95,
"humidity": 48},
{"dt": 1777455900,
"temp": 288.10,
"humidity": 46},
]
}
record_path="data" points to the “data” array. Each key inside those dicts becomes a column with the same colour. Notice how the other keys in the outer dictionary (lat, lon, timezone) are not in the output. We’ll see how to include them in the next section.
meta: what to stamp on every row?meta tells pandas which keys from outside the list to copy onto every row.
response = {
"lat": 51.5,
"lon": -0.1,
"timezone": "Europe/London",
"data": [
{"dt": 1777452300,
"temp": 287.95,
"humidity": 48},
{"dt": 1777455900,
"temp": 288.10,
"humidity": 46},
]
}
Each green key must hold a single value, not another list. That value gets pasted onto every row.
Sometimes APIs have even deeper levels of nesting inside each record. json_normalize handles some of these automatically, but not all.
OpenWeather’s daily endpoint nests temp and feels_like as dicts inside each record.
If you had data like this:
response = {
"lat": 51.5,
"lon": -0.1,
"timezone": "Europe/London",
"data": [
{"dt": 1777452300,
"temp": {"day": 22.1,
"min": 15.3,
"max": 24.8},
"feels_like": {"day": 21.5,
"night": 14.8}},
...
]
}
And the table would look like this:
| dt | temp.day | temp.min | temp.max | feels_like.day | feels_like.night | lat | lon |
|---|---|---|---|---|---|---|---|
| 1777452300 | 22.1 | 15.3 | 24.8 | 21.5 | 14.8 | 51.5 | -0.1 |
Don’t like the . between words? Change it with pd.json_normalize(..., sep="_") to get temp_day instead of temp.day.
Some fields inside each record are lists, not single values or dicts.
If you had data like this:
response = {
"lat": 51.5,
"lon": -0.1,
"data": [
{"dt": 1777452300,
"temp": 287.95,
"weather": [
{"id": 800, "main": "Clear"}]},
{"dt": 1777455900,
"temp": 288.10,
"weather": [
{"id": 802, "main": "Clouds"}]},
]
}
And the table would look like this:
| dt | temp | weather | lat | lon |
|---|---|---|---|---|
| 1777452300 | 287.95 | [{‘id’: 800, “main”: “Clear”}] | 51.5 | -0.1 |
| 1777455900 | 288.10 | [{‘id’: 802, “main”: “Clouds”}] | 51.5 | -0.1 |
The trick is to normalize each nested part separately, then join them later.
The records (without weather):
response["data"] [{"dt": 1777452300, "temp": 287.95, "weather": [...]}, {"dt": 1777455900, "temp": 288.10, "weather": [...]}]
The nested list inside each record:
response["data"] with record_path="weather" [{"id": 800, "main": "Clear"}, {"id": 802, "main": "Clouds"}]
Normalize each piece with json_normalize, then stitch the columns together with pd.concat.
Two calls to json_normalize, one per piece.
The main records:
| dt | temp | lat | lon |
|---|---|---|---|
| 1777452300 | 287.95 | 51.5 | -0.1 |
| 1777455900 | 288.10 | 51.5 | -0.1 |
The second call uses response["data"] as input (the list of records) and record_path="weather" to point at the nested list inside each one.
pd.concatBoth tables have the same number of rows in the same order. pd.concat with axis=1 puts the columns side by side.
yielding:
| dt | temp | lat | lon | weather_id | weather_main |
|---|---|---|---|---|---|
| 1777452300 | 287.95 | 51.5 | -0.1 | 800 | Clear |
| 1777455900 | 288.10 | 51.5 | -0.1 | 802 | Clouds |
Here, axis=1 means we want to concatenate columns, not rows.
This only works when each row in weather_df matches exactly one row in df in the same position. If the nested lists have different lengths per record, you need a more robust technique like pd.merge() (next week).
Three shapes, three entry points. Look at the JSON first.
Dict of flat lists The Open-Meteo shape. Keys are column names, values are lists of equal length.
List of flat dicts Each dict is one row. All keys are simple values.
or
Look at the JSON first. The shape tells you which function to call.
12:35 – 12:55
How we write code in ME204 from now on
You proved in the midterm that you can collect, reshape, and analyse data with loops and dictionaries. From now on, we write code that builds on that.
Collection (calling APIs, saving files):
Wrap the work in a named function, then loop over your inputs calling that function.
def collect_weather(city, coords, year, data_folder="./data"):
"""Fetch data per city and year."""
params = {
"lat": coords["lat"],
"lon": coords["lon"],
"start_date": f"{year}-01-01",
"end_date": f"{year}-12-31",
"hourly": "temperature_2m",
}
resp = requests.get(url, params=params)
if resp.status_code != 200:
return f"Error: {resp.status_code} for {city} {year}"Transformation and analysis
For everything after the JSON is on disk, use pandas functions and methods.
No more raw for loops over rows any more!
Am I calling an API or writing files? A for loop with a named function is fine.
Am I building a new derived column? .assign(), a list comprehension, or lambda inside .apply().
The lab notebook picks up where these slides leave off.
You will practise:
pd.read_json(), pd.json_normalize(), and pd.DataFrame() on JSON filespd.concat() for combining DataFrames from different cities or years into one table
LSE Summer School 2026 | ME204 Week 02 Day 04
LSE ME204 (2026)