| title | [Workshop] Python intensive - data validation | |
|---|---|---|
| description | A supplemental resource for the Python intensive workshop. Basic data validation checks with pandas: headers, file size, row counts, duplicated rows, unique IDs, and missing data. | |
| authors |
|
This is a supplemental notebook for the FAS Informatics Python Intensive. It builds on the file reading and pandas material from Part 4.
While much of analytical coding could now be handed off to an LLM, it is still important to build your intuition about data (and to check the LLMs work). Here we suggest mastering a few very basic data validation techniques. These are the checks you will use to make sure your data, and any code that touches it (whether written by you or by an LLM), are doing what you expect. In this notebook, we introduce some of the most basic validation skills that are important to honing your intuition and technical skills. This is not an exhaustive list! You may want to check other things depending on your data.
For simplicity, we assume the data here is a generic delimited text file (e.g. comma-separated or tab-separated values), except where otherwise noted. Many data files, including many bioinformatics file formats, are indeed this. For other formats, notably sequence files (FASTA, FASTQ, etc.), there are other specialized libraries to read them (e.g. biopython).
Most of the code in this notebook runs on real example datasets. A few examples are labeled (not run) because our example datasets don't contain the situations they check for, but you can use them on your own data.
We'll use three datasets that you may have seen in other parts of the workshop:
bird_names.csv: a list of bird species with their scientific names, common names, and taxon IDs.bird_observations.csv: observations of birds, each with an observation ID, the time of the observation, and the taxon ID of the bird observed.indiana_storms_full.csv: storm events recorded in Indiana in 2015.
Run the following cells to download the data and load the libraries we'll use.
# These lines download the files locally to the same folder as your notebook
!wget https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/bird_names.csv
!wget https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/bird_observations.csv
!wget https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/indiana_storms_full.csv--2026-10-15 03:47:13-- https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/bird_names.csv Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.108.133, 185.199.110.133, 185.199.111.133, ... Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.108.133|:443... connected. HTTP request sent, awaiting response...
200 OK Length: 4383 (4.3K) [text/plain] Saving to: ‘bird_names.csv.1’ bird_names.csv.1 0%[ ] 0 --.-KB/s bird_names.csv.1 100%[===================>] 4.28K --.-KB/s in 0s 2026-10-15 03:47:13 (74.3 MB/s) - ‘bird_names.csv.1’ saved [4383/4383]
--2026-10-15 03:47:13-- https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/bird_observations.csv Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.110.133, 185.199.108.133, 185.199.109.133, ... Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.110.133|:443... connected. HTTP request sent, awaiting response...
200 OK Length: 50448 (49K) [text/plain] Saving to: ‘bird_observations.csv.1’ bird_observations.c 0%[ ] 0 --.-KB/s bird_observations.c 100%[===================>] 49.27K --.-KB/s in 0.001s 2026-10-15 03:47:14 (34.0 MB/s) - ‘bird_observations.csv.1’ saved [50448/50448]
--2026-10-15 03:47:14-- https://raw.githubusercontent.com/harvardinformatics/python-intensive/refs/heads/main/data/indiana_storms_full.csv Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.109.133, 185.199.111.133, 185.199.110.133, ... Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.109.133|:443... connected. HTTP request sent, awaiting response...
200 OK
Length: 952491 (930K) [text/plain]
Saving to: ‘indiana_storms_full.csv.1’
indiana_s 0%[ ] 0 --.-KB/s
indiana_storms_full 100%[===================>] 930.17K --.-KB/s in 0.05s 2026-10-15 03:47:14 (19.2 MB/s) - ‘indiana_storms_full.csv.1’ saved [952491/952491]
import os
import pandas as pdIn a basic table, the first row is often headers: names that describe each column. However, for standardized file formats headers may not be included since they should be the same across all files. Additionally, many data files (especially in bioinformatics) include metadata, provenance, and other information in the first few rows of a file. These are often denoted with a # at the start of the line. It is important to know if your file has headers, because they will affect how you read the file and should be skipped for any validation or analysis of the data.
Here is a simple way to check if a file has headers, by printing its first few lines with Python:
with open("bird_names.csv", "r") as bird_file:
for i in range(5):
print(bird_file.readline(), end="")Anas rubripes,American Black Duck,6924 Fulica americana,American Coot,473 Spinus tristis,American Goldfinch,145310 Falco sparverius,American Kestrel,4665 Turdus migratorius,American Robin,12727
This prints the first 5 lines of the file. If all you see are data values, then the file does not have headers (and you should hopefully be able to look up what the columns are). If the first row has text describing the columns, that is a header.
In this case, there is no header: every line is a bird species, with its scientific name, common name, and taxon ID. Let's also count the number of lines in the file:
num_lines = 0
with open("bird_names.csv", "r") as bird_file:
for line in bird_file:
num_lines += 1
print(num_lines)105
Knowing whether a file has a header matters because, by default, pd.read_csv() assumes that the first line of the file is a header. Let's see what happens if we read this file without telling pandas otherwise:
bird_names_wrong = pd.read_csv("bird_names.csv")
print(len(bird_names_wrong))
bird_names_wrong.head()104
Anas rubripes American Black Duck 6924 0 Fulica americana American Coot 473 1 Spinus tristis American Goldfinch 145310 2 Falco sparverius American Kestrel 4665 3 Turdus migratorius American Robin 12727 4 Spizelloides arborea American Tree Sparrow 474210
pandas used the first bird in the file (the American Black Duck) as the column names, so that bird is no longer one of the rows of our DataFrame. The file has 105 lines, but the DataFrame only has 104 rows! pandas did not give us any warning or error about this, so it is up to us to notice.
To read a file that has no header, we set header=None, and we can provide our own column names with the names argument:
bird_names = pd.read_csv("bird_names.csv", header=None, names=["scientific_name", "common_name", "taxon_id"])
print(len(bird_names))
bird_names.head()105
scientific_name common_name taxon_id 0 Anas rubripes American Black Duck 6924 1 Fulica americana American Coot 473 2 Spinus tristis American Goldfinch 145310 3 Falco sparverius American Kestrel 4665 4 Turdus migratorius American Robin 12727
Now the number of rows in the DataFrame matches the number of lines in the file, and the American Black Duck is back as the first row. We'll keep both versions of this DataFrame around for an exercise later.
If there are multiple metadata lines beginning with #, you will want to count them. None of our example files have metadata lines, so the following code is not run, but you could count them in a file called my-data-file.tsv like this:
# (not run)
num_metadata_lines = 0
with open("my-data-file.tsv", "r") as data_file:
for line in data_file:
if line.startswith("#"):
num_metadata_lines += 1
print(num_metadata_lines)This will count the number of lines that start with a hash symbol, which are typically metadata lines in data files. You will have to remember or account for how many header lines are in your file going forward.
For example, if all of the metadata lines are at the top of the file, you can tell pandas to skip that many lines when reading the file with the skiprows argument:
# (not run)
my_data = pd.read_csv("my-data-file.tsv", sep="\t", skiprows=num_metadata_lines)Alternatively, you can tell pandas that # marks a comment with the comment argument:
# (not run)
my_data = pd.read_csv("my-data-file.tsv", sep="\t", comment="#")Be careful with comment="#", though: pandas ignores everything after a # on any line, not just on lines that start with one. If a # appears in the middle of a data line, the rest of that line will be cut off.
Exercise: What is one problem you could encounter when counting metadata headers in this way?
Solution
There are several possible problems, for example:
- In some file formats, the line with the column names also starts with a
#. For example, in VCF files the column header line starts with#CHROM. This line would be counted as a metadata line, and if you skip every line that starts with#you would lose your column names. - A data line could start with
#, for example if a value in the first column happens to begin with#. This line would be counted as metadata even though it is data. - Not all files use
#to mark metadata lines. Some use other characters, or no special character at all. - The loop counts every line that starts with
#anywhere in the file, not just at the top. If there are#lines further down in the file, using this count withskiprowswould skip the wrong lines.
If you have an understanding of the data files and their contents, looking at the size of a file can give you an idea if something is wrong. For example, let's say you run your big experiment and send the samples off to the sequencing facility. They send you a link to download the files which you click and let run on your computer. You return to a completed download dialogue. However, you think you should make sure it was successful, so you check some of the file sizes.
In Python, you can get the size of a file in bytes with the os.path.getsize() function. Let's check the sizes of the files we downloaded at the start of this notebook:
for filename in ["bird_names.csv", "bird_observations.csv", "indiana_storms_full.csv"]:
size_bytes = os.path.getsize(filename)
size_mb = size_bytes / 1024 / 1024
print(filename, size_bytes, "bytes", round(size_mb, 3), "MB")bird_names.csv 4383 bytes 0.004 MB bird_observations.csv 50448 bytes 0.048 MB indiana_storms_full.csv 952491 bytes 0.908 MB
Back to the sequencing example: for 9 out of 10 of your samples, the sequencing read files are between 60GB and 70GB, but for one it's only 35GB. This should be a red flag to you to stop and check the file more thoroughly. Is the genome of this sample expected to be smaller? How many reads are in the file? Was the download interrupted for the last sample (likely)? Maybe the sequencing facility made a mistake and sent the wrong file or sequenced the wrong sample.
In another scenario, perhaps you are processing some data and doing some filtering. How can you tell, at a glance, the extent of your filtering? Well, if the filtering works by removing data rows from your DataFrame, then you can count the rows before and after filtering.
Let's read in the Indiana storms data and count the rows with len():
storms = pd.read_csv("indiana_storms_full.csv")
print(len(storms))1315
You can also use the .shape attribute of the DataFrame, which gives both the number of rows and the number of columns:
storms.shape(1315, 51)
Note that the header line of the file is not counted as a row of the DataFrame, since pandas used it for the column names. So, for a file with a header, the number of rows in the DataFrame is one less than the number of lines in the file.
Now, let's do some filtering. Say we only want to analyze storms that have a value for property damage, so we remove rows where the DAMAGE_PROPERTY column is missing with .dropna() (see Part 5). Then we count the rows before and after filtering:
storms_damage = storms.dropna(subset=["DAMAGE_PROPERTY"])
num_before = len(storms)
num_after = len(storms_damage)
num_removed = num_before - num_after
print("Rows before filtering:", num_before)
print("Rows after filtering:", num_after)
print("Rows removed:", num_removed)
print("Percent removed:", round(num_removed / num_before * 100, 1))Rows before filtering: 1315 Rows after filtering: 1011 Rows removed: 304 Percent removed: 23.1
Filtering removed 304 rows, about 23 percent of the data. Is this expected, or are your filtering parameters too strict? (Tweaking filtering criteria is a time-honored data analysis task.) Whether this is expected or not, you will have to report and justify this filtering when you communicate your results in talks or papers.
Exercise: Filter the
stormsDataFrame to keep only the rows where theEVENT_TYPEis either"Flood"or"Flash Flood". How many rows were kept, how many were removed, and what percent of the rows were removed? Hint: the.isin()method may be useful here.
# Your code here??? success "Solution" ```python
storms_floods = storms[storms["EVENT_TYPE"].isin(["Flood", "Flash Flood"])]
num_before = len(storms)
num_after = len(storms_floods)
num_removed = num_before - num_after
print("Rows kept:", num_after)
print("Rows removed:", num_removed)
print("Percent removed:", round(num_removed / num_before * 100, 1))
```
<pre class="output-block">Rows kept: 246
Rows removed: 1069
Percent removed: 81.3
</pre>
Another basic check would be to count duplicated rows in a DataFrame. In pandas, the .duplicated() method returns True for each row that is an exact copy of a row that came before it, and False otherwise. Since True counts as 1 and False counts as 0, we can add these up with .sum() to count the duplicated rows. None of our example datasets have duplicated rows, so the following code is not run, but for a DataFrame called my_data it would look like this:
# (not run)
my_data.duplicated().sum()If the number displayed on the screen is 0, then there are no duplicated rows in the DataFrame. If it is greater than 0, then there are duplicated rows. To see the duplicated rows themselves, you can use .duplicated() to filter the DataFrame. Setting keep=False marks every copy of a duplicated row as True, including the first one, so you see all of them:
# (not run)
my_data[my_data.duplicated(keep=False)]Note that these two can give different numbers. For example, if one row appears 3 times in the DataFrame, my_data.duplicated().sum() would count 2 duplicated rows (the copies after the first one), while my_data[my_data.duplicated(keep=False)] would show all 3 copies.
You can also check for duplicates based on only some of the columns with the subset argument. For example, to count rows that repeat a sample ID, even if the rest of the row is different:
# (not run)
my_data.duplicated(subset=["sample_id"]).sum()Again, you will have to determine if the number of duplicated rows is expected or not, and if not, you will have to figure out how and when they were added to the data. pandas does have a .drop_duplicates() method to remove duplicated rows, but you should only use it once you understand where the duplicates came from.
Oftentimes, one column of a data file will contain some sort of unique identifier that corresponds to the sample, event, or whatever the row represents. One good check is to count the number of these unique identifiers to match against your expectations.
For instance, perhaps you or an LLM has been processing the data through several steps and you decide it's time to pause and do some sanity checks. You started out your experiment with 100 samples, but when you count the unique sample IDs in your current data, there are only 99. Now you have to stop and assess whether this is expected or not. Did one of your steps filter out "low quality" samples and this one didn't make it? Was it dropped erroneously? Or was this a conscious choice based on other experimental design factors?
In pandas, the .nunique() method counts the number of unique values in a column. In the storms data, each storm event has an ID in the EVENT_ID column. Let's count the unique event IDs and compare that to the number of rows:
print("Rows:", len(storms))
print("Unique event IDs:", storms["EVENT_ID"].nunique())Rows: 1315 Unique event IDs: 1315
The number of unique event IDs is the same as the number of rows, so each row is a different storm event. The storms data also has an EPISODE_ID column. Let's count those too:
storms["EPISODE_ID"].nunique()200
There are far fewer unique episode IDs than rows. This is because a single storm episode can include several events (e.g. one storm system that causes hail in one county and flooding in another). So EVENT_ID uniquely identifies each row, but EPISODE_ID does not. Knowing which column (if any) uniquely identifies the rows of your data is an important part of understanding it.
To see which IDs appear most often, use the .value_counts() method:
storms["EPISODE_ID"].value_counts().head()EPISODE_ID 98724 38 101603 29 101193 27 93198 25 98723 25 Name: count, dtype: int64
Counting unique IDs tells you how many there are, but if the count doesn't match your expectations, you'll want to know which IDs are missing. One way to do this is with sets (see Part 2), which only contain unique values. Taking the difference between two sets with the - operator gives all of the values in the first set that are not in the second set. For example, to compare a list of samples you expect with the sample IDs in your data (not run):
# (not run)
expected_ids = set(my_samples["sample_id"])
found_ids = set(my_data["sample_id"])
missing_ids = expected_ids - found_ids
print(missing_ids)For the next exercise, let's read in the bird observations data:
bird_obs = pd.read_csv("bird_observations.csv")
bird_obs.head()id time_observed_at taxon_id 0 145591043 2023-01-01 17:33:31 UTC 14886 1 145610149 2023-01-01 20:55:00 UTC 7004 2 145610383 2023-01-01 21:13:00 UTC 6993 3 145611915 2023-01-01 21:12:00 UTC 13858 4 145611916 2023-01-01 21:08:00 UTC 14850
Exercise: The
taxon_idcolumn ofbird_obscontains the taxon ID of the bird that was observed. How many unique taxon IDs are inbird_obs? Are any of these taxon IDs missing from thetaxon_idcolumn of thebird_namesDataFrame that we read in correctly above? What about thebird_names_wrongDataFrame? Hint: inbird_names_wrong, the taxon IDs are in the third column, which you can select with.iloc[:, 2].
# Your code here??? success "Solution" ```python
observed_ids = set(bird_obs["taxon_id"])
print("Unique taxon IDs in bird_obs:", len(observed_ids))
# bird_names was read with header=None, so it has every bird in the file
missing_ids = observed_ids - set(bird_names["taxon_id"])
print("Missing from bird_names:", missing_ids)
# bird_names_wrong used the first bird in the file as the column names
missing_ids_wrong = observed_ids - set(bird_names_wrong.iloc[:, 2])
print("Missing from bird_names_wrong:", missing_ids_wrong)
```
<pre class="output-block">Unique taxon IDs in bird_obs: 105
Missing from bird_names: set()
Missing from bird_names_wrong: {6924}
</pre>
If you compare the two results, you'll see that how we read in bird_names changes the answer. A mistake in one step (reading the file) can show up as a confusing problem in a later step (comparing IDs), which is why it's good to validate your data as you go.
In a data table, the symbol NA is often used to denote missing data. Do you expect missing data in your data? How many missing values are too many for your experiment?
When you read a file with pd.read_csv(), pandas automatically converts empty fields and several common symbols for missing data, including NA, NaN, N/A, and null, into its own missing value, NaN (see Part 5). This is convenient, but be aware of it: if NA is a real value in one of your columns (for example, as an abbreviation), pandas will still treat it as missing. You can control this behavior with the na_values and keep_default_na arguments of pd.read_csv().
Once the data is read in, you can count the missing values in a column with the .isna() method, which returns True for each missing value, followed by .sum(). Let's count the missing values in the DAMAGE_PROPERTY column of the storms data:
print(storms["DAMAGE_PROPERTY"].isna().sum())304
This is the same as the number of rows we removed when we filtered out the storms with missing property damage above.
You can also count the missing values in every column at once by using .isna().sum() on the whole DataFrame. The storms data has a lot of columns, so let's only show the ones that have at least one missing value:
na_counts = storms.isna().sum()
na_counts[na_counts > 0]DAMAGE_PROPERTY 304 DAMAGE_CROPS 306 MAGNITUDE 738 MAGNITUDE_TYPE 925 FLOOD_CAUSE 1069 CATEGORY 1315 TOR_F_SCALE 1291 TOR_LENGTH 1291 TOR_WIDTH 1291 TOR_OTHER_WFO 1312 TOR_OTHER_CZ_STATE 1312 TOR_OTHER_CZ_FIPS 1312 TOR_OTHER_CZ_NAME 1312 BEGIN_RANGE 444 BEGIN_AZIMUTH 444 BEGIN_LOCATION 444 END_RANGE 444 END_AZIMUTH 444 END_LOCATION 444 BEGIN_LAT 444 BEGIN_LON 444 END_LAT 444 END_LON 444 EVENT_NARRATIVE 236 dtype: int64
There is a lot of missing data here, but some of it is expected. For example, the CATEGORY column is missing in all 1315 rows, so it contains no data at all for these storms. And the columns starting with TOR_ are only filled in for tornado events, so they are missing for most rows. Understanding why data is missing is just as important as counting it.
Exercise: Count the number of missing values in each column of the
bird_obsDataFrame. Then, display only the rows ofbird_obsthat have a missing value in thetime_observed_atcolumn.
# Your code here??? success "Solution" ```python
print(bird_obs.isna().sum())
bird_obs[bird_obs["time_observed_at"].isna()]
```
<pre class="output-block">id 0
time_observed_at 3
taxon_id 0
dtype: int64
</pre>
<pre class="output-block"> id time_observed_at taxon_id
568 161145010 NaN 20044
569 161145128 NaN 20044
961 181797640 NaN 1454382
</pre>
Here is a quick reference for the checks in this notebook:
| Validation check | pandas or Python code |
|---|---|
| Look at the first few lines of a file | open() and .readline() |
| Read a file that has no header | pd.read_csv(filename, header=None, names=column_names) |
| Skip metadata lines | pd.read_csv(filename, skiprows=n) or pd.read_csv(filename, comment="#") |
| Get the size of a file in bytes | os.path.getsize(filename) |
| Count rows | len(df) or df.shape |
| Count duplicated rows | df.duplicated().sum() |
| View all copies of duplicated rows | df[df.duplicated(keep=False)] |
| Count unique values in a column | df["column"].nunique() |
| Count how often each value appears | df["column"].value_counts() |
| Find expected IDs that are missing | set(expected_ids) - set(found_ids) |
| Count missing values in a column | df["column"].isna().sum() |
| Count missing values in every column | df.isna().sum() |
None of these checks are complicated, but learning these skills even a little bit can give you powerful abilities that, at the very least, allow you to check an LLM's work, or, at most, could allow you to do the work yourself faster. Most importantly, whenever a number doesn't match your expectations, stop and figure out why before moving on.
<style> /* Output table styles */ div:has(> .dataframe) { width: 100%; overflow-x: auto; } .dataframe { margin-left: auto; margin-right: auto; min-width: max-content; font-size: 12px; border: none; } .dataframe thead tr:last-child th { font-weight: bold; background: #fff; border-bottom: 1px solid #000; } .dataframe tbody th { font-weight: bold; } .dataframe th, .dataframe td { border: none; padding: 3px 3px 3px 18px; text-align: right; white-space: nowrap; } .dataframe thead tr th, .dataframe tbody tr th, .dataframe tbody tr td { border: none; } .dataframe tbody tr:nth-child(odd) { background: #eee; } .dataframe tbody tr:nth-child(even) { background: #fff; } .dataframe tbody tr:hover { background: #cce6ff !important; } /* Output block styles */ .output-block { border: 1px dotted #999999; margin-bottom: 0 !important; padding: 10px; overflow-x: auto; overflow-y: auto; word-break: break-all; word-wrap: break-word; white-space: pre-wrap; font-size: 13px; margin-left: 40px; color: rgba(0,0,0,0.87) !important; } /* Code block styles */ .language-python { padding-left: 40px; font-size: 15px; } /* Hide all 2nd-level navs */ .md-nav--secondary .md-nav__item .md-nav { display: none !important; } /* Show when parent has .expanded class */ .md-nav--secondary .md-nav__item.expanded > .md-nav { display: block !important; } </style>