Data Analysis with Python: From Raw Data to Real-World Insights
BOOK 2 · PANDAS · CHAPTER 10

Operations with Pandas

On this page

In the previous chapters, we learned how to structure and combine datasets using Pandas. Now, the next step in real-world data analysis is to explore, transform, and summarize that data — turning it from raw information into usable insight.

These day-to-day tasks fall under what we call Pandas operations. They include identifying unique values, filtering rows, applying functions, sorting, cleaning, and creating quick summaries such as pivot tables.

In this chapter, we will work through these operations using a small but realistic dataset inspired by literary and cultural analytics.

An Illustrative Literary Dataset

Suppose a digital humanities researcher studies book publications by different authors across years. They collect a dataset containing each author’s name, the number of books published, and their average reader rating:

These rows are an invented teaching fixture, including ratings; they are not a verified bibliography or empirical study. The fixed reference year 2025 makes the years-ago calculation reproducible.

import pandas as pd
import numpy as np

data = {
    'Author': ['Austen', 'Tolstoy', 'Orwell', 'Austen', 'Orwell', 'Shakespeare', 'Tolstoy', 'Orwell'],
    'Year': [1813, 1877, 1945, 1811, 1949, 1603, 1869, 1933],
    'Books_Published': [1, 1, 1, 1, 1, 1, 1, 1],
    'Avg_Rating': [4.3, 4.1, 4.5, np.nan, 4.7, 4.8, np.nan, 4.4],
    'Genre': ['Romance', 'Historical', 'Dystopian', 'Romance', 'Political', 'Tragedy', 'Historical', 'Political']
}

df = pd.DataFrame(data)
df.head()
Author Year Books_Published Avg_Rating Genre
0 Austen 1813 1 4.3 Romance
1 Tolstoy 1877 1 4.1 Historical
2 Orwell 1945 1 4.5 Dystopian
3 Austen 1811 1 NaN Romance
4 Orwell 1949 1 4.7 Political

This dataset mirrors the kind of information used in literary data analysis, where researchers compare authors, genres, and publication trends.

Exploring Unique Values

The first step in exploration is to check what unique categories exist in our data. For example, how many different genres are present?

df['Genre'].unique()
array(['Romance', 'Historical', 'Dystopian', 'Political', 'Tragedy'],
      dtype=object)

We can also count how many unique authors appear and how often each one occurs:

print(df['Author'].nunique(), "\n\n", df['Author'].value_counts())
4 

 Author
Orwell         3
Austen         2
Tolstoy        2
Shakespeare    1
Name: count, dtype: int64

Orwell appears in three of these eight invented rows. That describes the fixture. The table contains no edition identifier or selection evidence from which to infer why an author appears more often.

Filtering Data with Conditions

Next, analysts often want to focus on a subset of the data — for example, books published after 1900 with ratings above 4.5.

high_rated_modern = df[(df['Year'] > 1900) & (df['Avg_Rating'] > 4.5)]
high_rated_modern
Author Year Books_Published Avg_Rating Genre
4 Orwell 1949 1 4.7 Political

Filtering this way allows researchers to isolate specific cultural trends, for instance, high-rated works in modern political literature.

Applying Functions to Data

Sometimes data needs to be transformed or enriched using functions. Pandas’ .apply() method lets us apply a function to each value in a column. For example, compute elapsed calendar years from the recorded publication year to the fixed reference year 2025. This is neither a century count nor evidence of the year of composition:

def years_ago(year):
    return 2025 - year

print()
df['Years_Ago'] = df['Year'].apply(years_ago)
df[['Author', 'Year', 'Years_Ago']]

Author Year Years_Ago
0 Austen 1813 212
1 Tolstoy 1877 148
2 Orwell 1945 80
3 Austen 1811 214
4 Orwell 1949 76
5 Shakespeare 1603 422
6 Tolstoy 1869 156
7 Orwell 1933 92

This kind of derived variable helps contextualize data — here showing how distant in time each publication is.

Aggregation and Summarization

To summarize numerical columns, we can use aggregation functions like sum(), mean(), or count(). For instance, calculate the unweighted mean of non-missing row-level ratings. This is not an equal-author average, and no underlying reviewer counts are supplied:

print(df['Avg_Rating'].mean())
4.466666666666666

Or count non-missing Books_Published entries per author. Every supplied row has value 1, so counting rows and summing books happen to agree here. For rows containing several books, use sum() to total the recorded quantities:

print(df.groupby('Author')['Books_Published'].count())
Author
Austen         2
Orwell         3
Shakespeare    1
Tolstoy        2
Name: Books_Published, dtype: int64

This describes how many selected rows contribute per author in the invented table, not an author’s complete publishing productivity.

Sorting and Ordering Data

Sorting helps uncover patterns, for example, finding the most highly rated works.

df.sort_values(by='Avg_Rating', ascending=False)
Author Year Books_Published Avg_Rating Genre Years_Ago
5 Shakespeare 1603 1 4.8 Tragedy 422
4 Orwell 1949 1 4.7 Political 76
2 Orwell 1945 1 4.5 Dystopian 80
7 Orwell 1933 1 4.4 Political 92
0 Austen 1813 1 4.3 Romance 212
1 Tolstoy 1877 1 4.1 Historical 148
3 Austen 1811 1 NaN Romance 214
6 Tolstoy 1869 1 NaN Historical 156

Sorting places the Shakespeare row with rating 4.8 first, followed by one Orwell row at 4.7. This is a row-level ordering of invented ratings, not a supported ranking of authors or literary quality.

Handling Missing Data

Missing values are a normal part of real-world datasets. In our example, some average ratings are missing:

df['Avg_Rating'].isnull()
0    False
1    False
2    False
3     True
4    False
5    False
6     True
7    False
Name: Avg_Rating, dtype: bool

For a mechanical demonstration, fill the missing ratings with the mean of the observed ratings. This is an assumed replacement policy, not evidence that those books received that rating. The following pivot includes these inserted values:

observed_ratings = df['Avg_Rating'].copy()
df['Rating_Was_Missing'] = observed_ratings.isna()
df.fillna({'Avg_Rating': df['Avg_Rating'].mean()}, inplace=True)
df
Author Year Books_Published Avg_Rating Genre Years_Ago Rating_Was_Missing
0 Austen 1813 1 4.300000 Romance 212 False
1 Tolstoy 1877 1 4.100000 Historical 148 False
2 Orwell 1945 1 4.500000 Dystopian 80 False
3 Austen 1811 1 4.466667 Romance 214 True
4 Orwell 1949 1 4.700000 Political 76 False
5 Shakespeare 1603 1 4.800000 Tragedy 422 False
6 Tolstoy 1869 1 4.466667 Historical 156 True
7 Orwell 1933 1 4.400000 Political 92 False

Every row now has a numeric entry, but two entries are estimates. Numeric completeness does not establish reliable averages or correlations. Preserve an observed-versus-filled flag and report the policy; never describe an inserted value as an observed rating.

Creating Pivot Tables

Pivot tables are a powerful way to summarize data across multiple dimensions. For example, let’s explore the average rating per author and genre:

pivot = df.pivot_table(values='Avg_Rating', index='Author', columns='Genre', aggfunc='mean')
pivot
Genre Dystopian Historical Political Romance Tragedy
Author
Austen NaN NaN NaN 4.383333 NaN
Orwell 4.5 NaN 4.55 NaN NaN
Shakespeare NaN NaN NaN NaN 4.8
Tolstoy NaN 4.283333 NaN NaN NaN

The pivot summarizes this particular invented, partly filled table. It does not identify reader preferences or differences between literary traditions. A missing author–genre combination is an absent combination, not a zero rating.

Summary

This chapter showed how Pandas operations turn raw data into insight — through filtering, transformation, aggregation, sorting, and summarization. Using a literary dataset, we uncovered trends in publication, ratings, and genres, illustrating how data becomes interpretation.

Next, connect these operations to exploratory reasoning: inspect denominators, weights and subgroup relationships before defending a comparison.

Experiment: a count is different from a quantity

Suppose a row summarizes several books rather than one. Predict the row count and the sum before running.

quantities = pd.DataFrame({"author": ["A", "A", "B"], "books": [2, 3, 4]})
summary = quantities.groupby("author")["books"].agg(rows="count", books="sum")
print(summary)
assert summary.loc["A", "rows"] == 2
assert summary.loc["A", "books"] == 5
        rows  books
author             
A          2      5
B          1      4

Author A has two non-missing rows representing five books. Replacing one quantity with a missing value makes count() fall, while size() still counts both rows. Neither missing quantity nor an absent combination should silently become a recorded zero.

Teaching edition · verified experiments · PDF and EPUB editions not generated.