4. Data surgery - joining DataFrames with pd.concat and pd.merge#

So far, the data has come together neatly in a single DataFrame. This is almost always not how it works in the real world - you will probably have data from multiple sources that need to be collated into a single file (e.g., perhaps each participant has data stored in a separate file).

You can’t work with them separately, so you need to combine them. Pandas has a function, pd.concat, that is made to concatenate DataFrames together, and can combine along rows (stack on top) or along columns (stack next to each other).

# Example
df1 = pd.DataFrame({'ID': [1]*4, 'Scores': [32, 89, 32, 56]})
df2 = pd.DataFrame({'ID': [2]*4, 'Scores': [11, 48, 49, 92]})

# Stack on top to build a longer df
stack = pd.concat([df1, df2], axis=0)
display(stack)
ID Scores
0 1 32
1 1 89
2 1 32
3 1 56
0 2 11
1 2 48
2 2 49
3 2 92
# The other direction works as well, but column names duplicate
joined = pd.concat([df1, df2], axis=1)
display(joined)

# Confusing!
display(joined['ID'])
ID Scores ID Scores
0 1 32 2 11
1 1 89 2 48
2 1 32 2 49
3 1 56 2 92
ID ID
0 1 2
1 1 2
2 1 2
3 1 2

4.1. Mismatched DataFrames#

Behind the scenes, pd.concat will do its best to match on the either the columns by matching their names (and repeating the index values), or by matching on the index values and repeating the columns. A mismatch can have unintended results:

# Change df1 columns to something else
df1.columns = ['PID', 'Result']

odd_concat = pd.concat([df1, df2], axis=0, sort=True)
display(odd_concat)
ID PID Result Scores
0 NaN 1.0 32.0 NaN
1 NaN 1.0 89.0 NaN
2 NaN 1.0 32.0 NaN
3 NaN 1.0 56.0 NaN
0 2.0 NaN NaN 11.0
1 2.0 NaN NaN 48.0
2 2.0 NaN NaN 49.0
3 2.0 NaN NaN 92.0

Note how the index values are repeated, but NaN’s are imputed because the column names don’t match.

# Fix df1 columns back
df1.columns = df2.columns

# Change df1 index
df1.index = list('abcd')

# Now concat along columns
odd_concat2 = pd.concat([df1, df2], axis=1)
display(odd_concat2)
ID Scores ID Scores
a 1.0 32.0 NaN NaN
b 1.0 89.0 NaN NaN
c 1.0 32.0 NaN NaN
d 1.0 56.0 NaN NaN
0 NaN NaN 2.0 11.0
1 NaN NaN 2.0 48.0
2 NaN NaN 2.0 49.0
3 NaN NaN 2.0 92.0

The same thing happens. Make sure things match before concatenation!

Finally, when concatenating row-wise, you can pass to pd.concat the keyword ignore_index=True which will reset the index so it doesn’t repeat.

display(df1.index)
display(df2.index)

fixed = pd.concat([df1, df2], axis=0, ignore_index=True)
display(fixed)
Index(['a', 'b', 'c', 'd'], dtype='object')
RangeIndex(start=0, stop=4, step=1)
ID Scores
0 1 32
1 1 89
2 1 32
3 1 56
4 2 11
5 2 48
6 2 49
7 2 92

4.2. Advanced joins with pd.merge#

Sometimes, you will end up in situations where you need to combine DataFrames in an intelligent way, rather than simply stacking them across either axis.

Consider the following example. You have your participant demographics in a single DataFrame, but their data responses are in another. Worse still, while most participants completed the demographics section, not all of them completed the experiment. Some even completed study, but didn’t log their demographics! So the DataFrames are of different sizes.

How to place everything together into a single DataFrame in this nightmare scenario? A full understanding of pd.merge will solve most of these problems.

# Make a demographics DataFrame
demographics = pd.DataFrame({'ID': ['001', '002', '003', '004', '005'],
                            'Age': [21, 25, 18, 19, 20],
                            'Sex': ['Female', 'Male', 'Male', 'Female', 'Male']})

# Make a dataset
data = pd.DataFrame({'ID': ['001', '002', '004', '006'],
                    'A_Score': [228, 900, 291, 440],
                    'B_Score': [321, 225, 100, 300]})
# First try pd.concat
try1 = pd.concat([demographics, data], axis=1)
display(try1)
ID Age Sex ID A_Score B_Score
0 001 21 Female 001 228.0 321.0
1 002 25 Male 002 900.0 225.0
2 003 18 Male 004 291.0 100.0
3 004 19 Female 006 440.0 300.0
4 005 20 Male NaN NaN NaN

pd.concat gives us a total mess. Let’s use pd.merge and provide it with three arguments:

  • left: The DataFrame on the left of the join.

  • right: The DataFrame on the right of the join.

  • on: A specific column header shared between both DataFrames.

# Try joining on 'ID'
initial_merge = pd.merge(left=demographics, right=data, on='ID')
display(initial_merge)
ID Age Sex A_Score B_Score
0 001 21 Female 228 321
1 002 25 Male 900 225
2 004 19 Female 291 100

What’s happened? pd.merge has looked at both DataFrames and seen that IDs 001, 002, and 004 are the only values that appear in both DataFrames, removed the values from demographics that are not in data, and joined the results.

Sometimes the value you might want to join on is in the index of one of the DataFrames. No problem - pd.merge has the optional keyword arguments left_index and right_index, and setting them to True will tell Pandas to look at the index of the DataFrame on the left/right. However, because we have told Pandas to look at the index of one of the DataFrames, we need to tell it what column to look at in the other - the keyword arguments left_on and right_on will take care of that.

# Set the index of data to be the ID column
new_data = data.set_index('ID')
display(new_data)
A_Score B_Score
ID
001 228 321
002 900 225
004 291 100
006 440 300
# Merge with extra keywords
index_merge = pd.merge(left=demographics, right=new_data, left_on='ID', right_index=True)
display(index_merge)
ID Age Sex A_Score B_Score
0 001 21 Female 228 321
1 002 25 Male 900 225
3 004 19 Female 291 100

4.2.1. How to join - inner, outer, left, and right#

The default behaviour of pd.merge is to conduct what is known as an inner join - find elements of the specified columns that are in both DataFrames, discard the rest, and join together. However, sometimes you need other kinds of joins. Pandas provides these other options:

  • The outer join: Takes items that are present in the specified column of either DataFrame and cleanly stacks the results together.

  • The left join: Takes items present in the left DataFrame specified column, and will append matches from the right DataFrame or force them.

  • The right join: Takes items present in the right DataFrame specified column, and will append matches from the left DataFrame or force them.

# What happens with outer?
outer_join = pd.merge(left=demographics, right=data, on='ID', how='outer')
display(outer_join)
ID Age Sex A_Score B_Score
0 001 21.0 Female 228.0 321.0
1 002 25.0 Male 900.0 225.0
2 003 18.0 Male NaN NaN
3 004 19.0 Female 291.0 100.0
4 005 20.0 Male NaN NaN
5 006 NaN NaN 440.0 300.0

With outer, the values that were present in the ID column of both DataFrames were put together, and any missing values were replaced with NaN.

# What happens with left?
left_join = pd.merge(left=demographics, right=data, on='ID', how='left')
display(left_join)
ID Age Sex A_Score B_Score
0 001 21 Female 228.0 321.0
1 002 25 Male 900.0 225.0
2 003 18 Male NaN NaN
3 004 19 Female 291.0 100.0
4 005 20 Male NaN NaN

With left, all the elements in ID were retained, and matches from the right DataFrame were pulled in. If there were no matches, they were filled with NaN. Notice how participant 006 is missing here - it was only in the right DataFrame, so wasn’t filled.

This is a particularly useful approach if you have a set of demographics data and want the data for all participants who logged their demographics, even if they didn’t complete their data.

# What happens with right? A reverse of left
right_join = pd.merge(left=demographics, right=data, on='ID', how='right')
display(right_join)
ID Age Sex A_Score B_Score
0 001 21.0 Female 228 321
1 002 25.0 Male 900 225
2 004 19.0 Female 291 100
3 006 NaN NaN 440 300

The same result could be achieved by swapping the DataFrames that were specified as left and right, and setting join to left. All entries from the right DataFrame are retained, and matches are pulled from the left DataFrame or are forced if they don’t exist - note how participant 005 is missing here - they were only present in the left DataFrame, and not the right.

4.2.2. pandas close#

With the concepts from this chapter and the previous ones, you have all the tools you might need for serious data wrangling. pandas is versatile and can handle computation on even very large datasets. At first, it will feel awkward and difficult; but keep practicing.

If you want to dive deeper, try Effective Pandas by Matt Harrison.