My data has text, numbers and gaps in the same table. How do I hold that in Python?
How do I pick out the rows and columns I care about, and compute something per group?
How do I combine two tables, or reshape one from wide to long?
What should I do about missing values, and when is dropping them wrong?
What makes a table ‘tidy’, and why does every later chapter insist on it?
What you will be able to do
Load a CSV file into a DataFrame and inspect it with head, info, describe, shape, columns and dtypes.
Select rows and columns with loc and iloc, and filter rows with boolean masks combined by & and |.
Add derived columns, and compute grouped summaries with groupby and agg over one or two keys.
Combine tables with merge, and reshape them with melt and pivot_table.
Count, locate and handle missing values deliberately, and convert text columns to categoricals.
State the three rules of tidy data and recognise when a table breaks them.
Between 2007 and 2009 a field team on the Palmer Archipelago in Antarctica measured 344 penguins of three species: bill length and depth, flipper length, body mass, sex, island and year. The obvious questions are table questions. Which species is heaviest? Are males heavier than females in every species? Does any species live on more than one island? None of them fits a NumPy array, because the table mixes text ("Gentoo"), numbers (5076.0) and gaps where a measurement could not be taken. pandas exists for exactly this kind of data: a DataFrameDataFramepandas’ table type: labelled rows and columns, each column with its own data type. is a table whose columns have names and their own types, and whose rows have labels, with the arithmetic of episode 1.2 underneath.
The data are the Palmer penguins (Horst, Hill & Gorman 2020), released under CC0 and bundled in data/penguins.csv.
Loading and inspecting
import numpy as npimport pandas as pdpenguins = pd.read_csv("data/penguins.csv")penguins.head()
index
species
island
bill_length_mm
bill_depth_mm
flipper_length_mm
body_mass_g
sex
year
0
Adelie
Torgersen
39.1
18.7
181.0
3750.0
male
2007
1
Adelie
Torgersen
39.5
17.4
186.0
3800.0
female
2007
2
Adelie
Torgersen
40.3
18.0
195.0
3250.0
female
2007
3
Adelie
Torgersen
NaN
NaN
NaN
NaN
NaN
2007
4
Adelie
Torgersen
36.7
19.3
193.0
3450.0
female
2007
read_csv parses the file, guesses a type for each column and turns the string NA into NaN, the marker for a missing value (NaN)missing value (NaN)An absent measurement, shown as NaN; most models cannot handle it without imputation or dropping.. Row 3 is a penguin with no measurements at all; keep an eye on it. Before analysing anything, look at the table from four angles:
species str
island str
bill_length_mm float64
bill_depth_mm float64
flipper_length_mm float64
body_mass_g float64
sex str
year int64
dtype: object
Four float64 columns, one int64, and three text columns with dtype str. (pandas 3 introduced the str dtype; older versions show object for text, and you will still see that in tutorials.) info() adds how many values in each column are not missing, which is the fastest way to find gaps:
342 of 344 measurements, 333 of 344 sexes. describe() summarises every numeric column at once:
penguins.describe().round(1)
index
bill_length_mm
bill_depth_mm
flipper_length_mm
body_mass_g
year
count
342.0
342.0
342.0
342.0
344.0
mean
43.9
17.2
200.9
4201.8
2008.0
std
5.5
2.0
14.1
802.0
0.8
min
32.1
13.1
172.0
2700.0
2007.0
25%
39.2
15.6
190.0
3550.0
2007.0
50%
44.4
17.3
197.0
4050.0
2008.0
75%
48.5
18.7
213.0
4750.0
2009.0
max
59.6
21.5
231.0
6300.0
2009.0
A body mass of 2.7 to 6.3 kg, flippers of 172 to 231 mm. The count row repeats the missing-value story, and year is counted as a number because it is one, even though you will treat it as a label.
Biologist's corner
This is a real study, not a teaching toy: Gorman, Williams & Fraser (2014, PLoS ONE 9(3): e90081) studied sexual size dimorphism and its link to foraging ecology in the three Pygoscelis species near Palmer Station. Read the columns as experimental design: species is the organism, island the site, year the season, sex the factor the study was about, and the four measurements are the responses. Every question you ask of this table in the next hour (mean by species, difference by sex, counts per island) is a question the authors asked too.
Columns are Series; rows have an index
A single column is a SeriesSeriesA single labelled column of data in pandas; one column of a DataFrame.: a 1-D labelled array with a name and a dtype.
The numbers on the left are the index (pandas)index (pandas)The row labels of a DataFrame or Series; by default the integers 0, 1, 2, …, the row labels. By default they are 0, 1, 2, …, but they are labels, not positions: after filtering or sorting the index keeps its original values, so “the row labelled 169” and “the 170th row” are not the same thing. A Series behaves like a NumPy array (mass.mean(), mass / 1000, mass > 5000) and like a dictionary (mass[169] looks up the label). For text columns, value_counts() is the first thing to run:
Two indexers cover every selection, and the difference is the single most important thing in this episode. locloc and ilocloc selects rows/columns by label; iloc selects by integer position. selects by label (row label, column name); iloc selects by integer position. Both take [rows, columns].
penguins.loc[169, ["species", "body_mass_g"]]
species Gentoo
body_mass_g 6300.0
Name: 169, dtype: object
penguins.iloc[0:3, 0:2]
index
species
island
0
Adelie
Torgersen
1
Adelie
Torgersen
2
Adelie
Torgersen
Right now, labels and positions coincide, so loc[169] and iloc[169] return the same penguin. Filter the table and they stop agreeing, which is why it matters to say which one you mean. One more difference: loc slices are inclusive of the end label (loc[0:2] is three rows), because labels have no natural “one past the end”, while iloc slices follow the Python rule.
For programmers
If you have met these structures before: a DataFrame is a dict of equal-length typed columns, not a list of row records, which is why column operations are cheap and row-by-row loops are the wrong tool. A groupby is SELECT ... GROUP BY; merge is a JOIN; a boolean mask is a WHERE. The index is a primary key that is allowed to be non-unique and survives filtering, hence loc (by key) versus iloc (by offset). Column selection df["col"] works like a dict lookup, so df.col attribute access, which you will see in the wild, is just sugar that breaks on column names with spaces.
Filtering rows with masks
A comparison on a column gives a boolean Series, a boolean maskboolean maskAn array of True/False values used to select the elements where it is True. with the same index as the table, and indexing the table with it keeps the rows where it is True:
species
Adelie 108
Chinstrap 68
Name: count, dtype: int64
Notice that gentoo’s index starts at 152, not 0: the labels came along. gentoo.iloc[0] is the first Gentoo; gentoo.loc[0] is a KeyError, because no row is labelled 0 any more. If you want fresh positions, call gentoo.reset_index(drop=True).
Adding columns
Body mass in kilograms and a bill shape ratio are both one array expression away. The two idioms:
df["new"] = ... modifies the table in place; .assign(new=...) returns a new table and chains nicely. Missing inputs give missing outputs: the penguin in row 3 gets NaN in both new columns, silently and correctly.
Common mistake
Chained assignment does nothing
penguins[penguins["species"] == "Gentoo"]["body_mass_kg"] = 0 looks like it sets Gentoo masses to zero. It does not. The filter produces a copy, the assignment lands on the copy, and the copy is thrown away. pandas 3 enforces this (copy-on-write) rather than sometimes working and sometimes warning. Write what you mean in one indexer: penguins.loc[penguins["species"] == "Gentoo", "body_mass_kg"] = 0.
Group and aggregate
“Mean body mass by species” is a split-apply-combine operation, groupbygroupbySplit a table into groups by one or more columns, apply a function to each, and combine the results.: split the rows into groups by a key, apply a function to each group, combine the results into one row per group.
Predict before you run
Which species do you expect to be heaviest, and by how much? Gentoo penguins are the largest of the three; write down a guess for the gap before running the cell.
count counts non-missing values, so Adelie shows 151 rather than 152: the penguin with no measurements. Two keys give one row per combination, and unstack() moves the inner key into columns so the comparison reads across:
Males are heavier than females in all three species, by 400 to 800 g. That table, in one line, is the finding in the title of the paper this dataset comes from.
Common mistake
Missing keys vanish from groupby
Group by sex and the 11 penguins with unknown sex are dropped without a message, because NaN is not a group by default. Their masses are not in the table above. If you need them counted, pass dropna=False to groupby; either way, know that it happened.
Merging two tables
Related information often lives in another table. Here is a two-column lookup from the common name to the scientific name of each species, typed by hand; merge (join)merge (join)Combine two tables side by side by matching values in a shared key column. attaches it to every row by matching the shared species column:
how="left" keeps every row of the left table and fills NaN where the right table has no match; how="inner" keeps only matched rows. The row count is the thing to check every time you merge: 344 in, 344 out means every penguin matched exactly one name. If the lookup table had listed a species twice, the merge would have duplicated those penguins, and nothing would have warned you. Chapter 5 merges thousands of measured compounds with computed descriptors on a molecule identifier; the habit of printing the shape before and after starts here.
Reshaping: melt and pivot_table
The penguin table is wide: four measurements sit in four columns. For some tasks, plotting all four with the same code or grouping by “which measurement”, you want them long, one row per penguin per measurement. That is meltmelt and pivotmelt turns wide tables long (one row per measurement); pivot turns long tables wide.:
344 penguins × 4 measurements = 1376 rows. The id_vars are repeated on every row so nothing is lost; measurement says which column the number came from. Going the other way, pivot_table summarises a long or wide table into a grid, with one key on the rows, one on the columns and an aggregation in the cells.
Predict before you run
A table of mean body mass with species as rows and islands as columns has nine cells. How many will be empty? Think about whether every species was measured on every island.
Four cells are empty: Chinstrap penguins were measured only on Dream, Gentoo only on Biscoe, and only Adelie on all three islands. That is a fact about the study design, not a data error, and it is the kind of thing pivot_table makes visible in one glance. aggfunc="count" gives the sample sizes behind the same grid.
Missing values
Every real dataset has gaps. The first thing to run on any new table:
penguins.isna().sum()
species 0
island 0
bill_length_mm 2
bill_depth_mm 2
flipper_length_mm 2
body_mass_g 2
sex 11
year 0
body_mass_kg 2
bill_ratio 2
dtype: int64
Two rows have no measurements, eleven have no sex (the two unmeasured penguins are among them). Look at the rows before deciding what to do:
There are three responses, and the right one depends on why the value is missing and what you will do next:
Drop the rows when they carry no information (the two unmeasured penguins) or when the missing column is essential and the loss is small. dropna(subset=measurements) drops only rows missing a measurement; bare dropna() drops any row with any gap, which here would also discard nine fully measured penguins just because their sex is unknown.
Keep them when the gap is in a column you are not using. A model of body mass from flipper length does not care that sex is missing.
Impute (fill in an estimate such as the column median) when you need the column and cannot afford the rows. Imputation must be learned from training data only, which is why it waits until episode 2.6 and imputationimputationFilling missing values with a plausible substitute such as the column mean or median. inside a pipeline.
Never fill gaps with 0 or −999: a model cannot tell a placeholder from a measurement, and the mean of a column with a few −999s is nonsense.
Categorical columns
species, island and sex take a handful of values each. Converting them to pandas’ categorical datacategorical dataA column whose values come from a fixed set of categories, such as species or island. type records the allowed categories once and stores each value as a small integer:
The benefits are memory (a few hundred bytes instead of tens of kilobytes for this column), a fixed category order for plots and tables, and an explicit statement that this column is a label, not free text. The cost is one extra keyword: when grouping by a categorical, pass observed=True so that pandas reports only combinations that actually occur rather than every possible one.
penguins.groupby("species", observed=True).size()
species
Adelie 152
Chinstrap 68
Gentoo 124
dtype: int64
Tidy data
The penguin table obeys three rules that make all of the above work without special cases. This is tidy datatidy dataOne row per observation, one column per variable, one table per kind of thing.:
Each row is one observation (one penguin).
Each column is one variable (one measurement or one design factor).
Each table is one kind of thing (penguins here; a separate table for species names).
Untidy tables are everywhere in science: a spreadsheet with one column per year, a plate layout where the position of a cell encodes the sample, a sheet with the sample name in a merged header row. They look fine to a human and are nearly unusable by code. melt is the usual repair for “variables in column headers”; pivot_table is how you produce a wide summary for a reader after the analysis, not before. Keep the long, tidy table as the source of truth and derive everything else from it.
Exercises
Exercise★☆☆
Heaviest penguin per species
Find the heaviest penguin of each species and show its species, island, sex and body mass. Two routes work: groupby with idxmax, or sort by mass and keep the first row of each group with .groupby(...).head(1).
idxmax returns the index label of the maximum within each group, which is exactly what loc wants. The result shows a 6.3 kg Gentoo male on Biscoe, and that all three heaviest birds are male, consistent with the grouped means. The sort-then-head(1) route gives the same rows:
Compute the fraction of penguins with unknown sex in each species, as a percentage with one decimal. Then check whether the gaps cluster in one island or year. Is there any reason to think the missingness is related to the measurements?
A boolean Series has mean equal to the fraction of True, so grouping the mask by species gives the fraction missing per species directly: about 4% of Adelie and Gentoo, none of the Chinstrap.
Five of the eleven are Adelie penguins from Torgersen in 2007, one more is an Adelie from Dream the same year, and the other five are Gentoo on Biscoe spread over all three years; the clusters are small, and the masses of the nine measured birds (3.0 to 4.9 kg) sit well inside the normal range for their species. With such numbers you cannot tell whether sex was simply not recorded or could not be determined, so the safe choice is to keep these rows for any analysis that does not use sex and drop them only for the ones that do.
Exercise★★★
Dimorphism table: melt, group, pivot
Using the long table, compute the mean of every measurement for each species and sex, then pivot to a table with one row per (species, measurement) and one column per sex, and add a column male_minus_female_pct giving the male–female difference as a percentage of the female mean. Which measurement differs most between the sexes, and in which species?
The melt made this a single groupby over three keys instead of four separate calculations, one per measurement column. unstack("sex") is the pivot step: it moves the innermost key into columns so male and female sit side by side. Body mass shows the largest sex difference in every species (12–20%, with Adelie at the top), the two bill measurements come next (8–10%), and flipper length differs least (2–4%). Males are larger on every measurement in every species, but the size of the gap depends on what you measure, which is why the paper’s title says dimorphism rather than just bigger. The .assign(... lambda t: ...) form lets you refer to the table being built inside a chain. The same chain, with a different id_vars list, is how you will compare descriptor distributions across compound classes in Chapter 5.
Where this is going
From here on, every dataset in the course arrives as a DataFrame and leaves as the X and y of episode 1.2: X = df[features] selects the feature columns, y = df["target"] the labels, and scikit-learn accepts both directly. Episode 1.4 plots this same penguin table, long form and all, and episode 1.5 applies the inspect-filter-group-check-for-gaps routine to the ESOL solubility data that Chapter 2 models. In Chapter 5 the rows become molecules and the columns computed descriptors, merged on a compound identifier; in Part III the table is a gene-expression matrix with thousands of columns, and melt, groupby and pivot_table are still the verbs you reach for.
Key points
A DataFrame is a table of labelled columns, each a Series with one dtype; the row labels are the index.
loc selects by label and iloc by position; a boolean mask inside df[…] or df.loc[…] filters rows.
Build conditions with &, | and parentheses; .isin() replaces a chain of == tests.
groupby(keys)[column].agg(…) is split-apply-combine: one row of results per group.
merge joins tables on a shared key; check the row count before and after, because a bad key silently multiplies rows.
melt turns wide tables long, pivot_table turns long tables wide; the long form is what plotting and grouping want.
isna().sum() is the first thing to run on new data; drop missing rows only when you know why they are missing and how many you lose.
Tidy data: one row per observation, one column per variable, one table per kind of thing.