4  Pandas and Polars

4.1 Topics Covered

  1. Creating and manipulating Series and DataFrames using Pandas
  2. Querying and subsetting DataFrames
  3. Handling missing values
  4. Summarizing and grouping DataFrames
  5. Concatenating and merging tables
  6. How Polars differs from Pandas
  7. Performing common data table operations using Polars

4.2 Introduction

Most scientists are first introduced to data tables in the form of spreadsheets, such as an Excel spreadsheet. In such a structure, each record or feature is represented by a row of data while each column represents a specific piece of information for each record. For example, each row in a table could contain all the data for a specific student, and each column could hold a specific attribute for all students, such as academic year, age, or height.

Further, spreadsheets are able to hold different data types in each column. For example, academic year is categorical or ordinal while height is ratio or continuous.

A comparable data structure would be handy for use in Python. This is made available by the Pandas library. Pandas allows for data to be stored in DataFrames. If you work in the R environment, this is very similar to the concept of data frames in R. In fact, Pandas DataFrames were inspired by R data frames.

Pandas makes use of the NumPy library (Chapter 3), so it is generally a good idea to import NumPy if you plan to use Pandas. Also, you will need to install Pandas and NumPy into your environment prior to using them. Both are included in the pybook environment.

The complete documentation for Pandas can be found here.

At the end of the chapter, we introduce Polars, a newer DataFrame library that offers an alternative to Pandas.

4.3 Intro to Series

import numpy as np
import pandas as pd

Before we talk about DataFrames, we introduce the concept of a Series. These are very similar to a NumPy array except that they allow for axis labels to be assigned. Examples of generating a Series from lists, NumPy arrays, and dictionaries are provided below.

A Series is comparable to a single column from a spreadsheet.

lst1 = ["GIS", "Remote Sensing", "Spatial Analysis", "Digital Cartography"]
arr1 = np.array([350, 455, 457, 642])
dict1 = {'Class1':"GIS", "Class2":"Remote Sensing", "Class3":"Spatial Analysis", "Class4":"Digital Cartography"}

s_lst = pd.Series(data=lst1, index = ["Class1", "Class2", "Class3", "Class4"])
s_arr = pd.Series(data=arr1, index = ["Class1", "Class2", "Class3", "Class4"])
s_dict = pd.Series(dict1)

print(s_lst)
Class1                    GIS
Class2         Remote Sensing
Class3       Spatial Analysis
Class4    Digital Cartography
dtype: str
print(s_arr)
Class1    350
Class2    455
Class3    457
Class4    642
dtype: int64
print(s_dict)
Class1                    GIS
Class2         Remote Sensing
Class3       Spatial Analysis
Class4    Digital Cartography
dtype: str

Labels or names can then be used to select data using either bracket notation or dot notation.

You can use whichever method you prefer. However, if you use dot notation, the labels cannot include spaces.

print(s_dict["Class3"])
Spatial Analysis
print(s_dict.Class3)
Spatial Analysis

4.4 Intro to DataFrames

Let’s start by building a DataFrame from a set of lists. First, we create three lists to hold different components of course title information. Next, we combine these lists into a dictionary. Finally, we convert the dictionary into a DataFrame. The keys from the dictionary are used as the column names, and a default index is assigned to each row.

A well-formatted table is generated by just calling the DataFrame name without the print() function; however, we use print() here since we have found that this works better when rendering a web page.

prefix = ["Geol", "Geol", "Geol", "Geol", "Geog", "Geog", "Geog"]
cnum = [103, 321, 331, 341, 350, 455, 462]
cname = ["Earth Through Time", "Geomorphology", "Paleontology", "Structural Geology", "GIScience", "Remote Sensing", "Digital Cartography"]
course_dict = {"prefix": prefix, "course_number": cnum, "course_name": cname}
course_df = pd.DataFrame(course_dict)
print(course_df)
  prefix  course_number          course_name
0   Geol            103   Earth Through Time
1   Geol            321        Geomorphology
2   Geol            331         Paleontology
3   Geol            341   Structural Geology
4   Geog            350            GIScience
5   Geog            455       Remote Sensing
6   Geog            462  Digital Cartography

Since column names are assigned, they can be used to select individual columns using bracket or dot notation. Single columns can be saved as a Series.

print(course_df["course_name"])
0     Earth Through Time
1          Geomorphology
2           Paleontology
3     Structural Geology
4              GIScience
5         Remote Sensing
6    Digital Cartography
Name: course_name, dtype: str
# Or course_df.course_name

A list of column names can be provided to subset multiple columns.

print(course_df[["course_number", "course_name"]])
   course_number          course_name
0            103   Earth Through Time
1            321        Geomorphology
2            331         Paleontology
3            341   Structural Geology
4            350            GIScience
5            455       Remote Sensing
6            462  Digital Cartography

You can also subset based on column names and row labels combined using .loc. .iloc, in contrast, is used for selection based on integer positions.

print(course_df.loc[[1, 2, 4],["course_number", "course_name"]])
   course_number    course_name
1            321  Geomorphology
2            331   Paleontology
4            350      GIScience
# Or course_df.iloc[[1, 2, 4],[1, 2]]

You can even use the data stored in existing columns to create a new column. Note that the new column does not need to be declared prior to writing to it. In the example, we write the entire course name to a new column. The map() method is used to make sure all data are treated as strings. It allows for the same function, in this case str(), to be applied to each element in a Series, in this case each row in the column. We include blank spaces so that the components do not run together.

course_df["full_name"] = course_df["prefix"].map(str)  + " " + course_df["course_number"].map(str)  + ": " + course_df["course_name"].map(str)
print(course_df)
  prefix  course_number          course_name                      full_name
0   Geol            103   Earth Through Time   Geol 103: Earth Through Time
1   Geol            321        Geomorphology        Geol 321: Geomorphology
2   Geol            331         Paleontology         Geol 331: Paleontology
3   Geol            341   Structural Geology   Geol 341: Structural Geology
4   Geog            350            GIScience            Geog 350: GIScience
5   Geog            455       Remote Sensing       Geog 455: Remote Sensing
6   Geog            462  Digital Cartography  Geog 462: Digital Cartography

Instead of creating data tables or DataFrames manually, you are more likely to read in a data table from a file or web link. Fortunately, Pandas provides functions for reading data in from a variety of formats. Here are some examples:

  • read_table(): delimited file (TXT, CSV, etc.)
  • read_csv(): comma-separated values (CSV)
  • read_excel(): Excel spreadsheet
  • read_json(): JavaScript Object Notation (JSON)
  • read_html(): HTML table
  • read_sas(): SAS file

Full documentation on reading in data can be found here.

In the example below, we read in a CSV file from the local computer. The sep argument is used to define the delimiter. However, commas are the default, so it isn’t necessary to include this argument in this case. Setting the header argument to 0 indicates that the first row of data should be treated as column names or headers. It isn’t always necessary to specify the character encoding; however, it is necessary for this dataset due to the use of special characters.

To view the first 10 rows of the data, we use the head() method. The len() function returns the number of rows.

The matts_movies.csv dataset was created by Matt, the author’s brother, and provides his ratings for movies along with each movie’s name, director, release year, genre, and whether or not he owns it.

movies_df = pd.read_csv("datasets/matts_movies.csv", sep=",", header=0, encoding="ISO-8859-1")
print(movies_df.head(10))
                 Movie Name             Director  ...        Genre  Own
0             Almost Famous        Cameron Crowe  ...        Drama  Yes
1  The Shawshank Redemption       Frank Darabont  ...        Drama  Yes
2             Groundhog Day         Harold Ramis  ...       Comedy  Yes
3              Donnie Darko        Richard Kelly  ...       Sci-Fi  Yes
4           Children of Men       Alfonso Cuaron  ...       Sci-Fi  Yes
5                Annie Hall          Woody Allen  ...       Comedy  Yes
6                  Rushmore         Wes Anderson  ...  Independent  Yes
7                   Memento    Christopher Nolan  ...     Thriller  Yes
8    No Country for Old Men  Joel and Ethan Coen  ...     Thriller  Yes
9                     Seven        David Fincher  ...     Thriller  Yes

[10 rows x 6 columns]
print(len(movies_df))
1852

4.5 DataFrame Query and Subset

Let’s now use this data table to explore data query and selection methods. In the first example, we select all movies that are dramas and save them to a new DataFrame. Note the use of bracket notation.

The second example includes a compound query. Note the use of parentheses within the query.

Lastly, it is also possible to subset only certain columns that meet the query. In the last example, we subset just the movie name and director.

#Example 1
just_dramas = movies_df[movies_df["Genre"]=="Drama"]
print(just_dramas.head(10))
                  Movie Name           Director  ...  Genre  Own
0              Almost Famous      Cameron Crowe  ...  Drama  Yes
1   The Shawshank Redemption     Frank Darabont  ...  Drama  Yes
21                   Boyhood  Richard Linklater  ...  Drama   No
26           American Beauty         Sam Mendes  ...  Drama  Yes
39              Mystic River     Clint Eastwood  ...  Drama  Yes
51    Hannah and Her Sisters        Woody Allen  ...  Drama  Yes
52            In the Bedroom         Todd Field  ...  Drama  Yes
57                  Big Fish         Tim Burton  ...  Drama  Yes
60       The Sweet Hereafter        Atom Egoyan  ...  Drama  Yes
62               Stand By Me         Rob Reiner  ...  Drama  Yes

[10 rows x 6 columns]
print(len(just_dramas))
321
#Example 2
dramas_gt_9 = movies_df[(movies_df["Genre"]=="Drama") & (movies_df["My Rating"]>9.0)]
print(len(dramas_gt_9))
23
#Example 3
dramas_gt_9_sub = movies_df[(movies_df["Genre"]=="Drama") & (movies_df["My Rating"]>9.0)][["Movie Name", "Director"]]
print(dramas_gt_9_sub.head(10))
                  Movie Name           Director
0              Almost Famous      Cameron Crowe
1   The Shawshank Redemption     Frank Darabont
21                   Boyhood  Richard Linklater
26           American Beauty         Sam Mendes
39              Mystic River     Clint Eastwood
51    Hannah and Her Sisters        Woody Allen
52            In the Bedroom         Todd Field
57                  Big Fish         Tim Burton
60       The Sweet Hereafter        Atom Egoyan
62               Stand By Me         Rob Reiner

Another option for performing queries is to use the query() method provided by Pandas. When using this method, the query must be provided as an expression in string form. Also, spaces in column names can be problematic, so spaces should be removed or replaced with underscores.

#Remove spaces in column names using list comprehension
movies_df.columns = [column.replace(" ", "_") for column in movies_df.columns]
#Example 1
just_dramas = movies_df.query('Genre=="Drama"')
print(just_dramas.head(10))
                  Movie_Name           Director  ...  Genre  Own
0              Almost Famous      Cameron Crowe  ...  Drama  Yes
1   The Shawshank Redemption     Frank Darabont  ...  Drama  Yes
21                   Boyhood  Richard Linklater  ...  Drama   No
26           American Beauty         Sam Mendes  ...  Drama  Yes
39              Mystic River     Clint Eastwood  ...  Drama  Yes
51    Hannah and Her Sisters        Woody Allen  ...  Drama  Yes
52            In the Bedroom         Todd Field  ...  Drama  Yes
57                  Big Fish         Tim Burton  ...  Drama  Yes
60       The Sweet Hereafter        Atom Egoyan  ...  Drama  Yes
62               Stand By Me         Rob Reiner  ...  Drama  Yes

[10 rows x 6 columns]
print(len(just_dramas))
321
#Example 2
dramas_gt_9 = movies_df.query('Genre=="Drama" and My_Rating > 9.0')
print(len(dramas_gt_9))
23
#Example 3
dramas_gt_9_sub = movies_df.query('Genre=="Drama" and My_Rating > 9.0')[["Movie_Name", "Director"]]
print(dramas_gt_9_sub.head(10))
                  Movie_Name           Director
0              Almost Famous      Cameron Crowe
1   The Shawshank Redemption     Frank Darabont
21                   Boyhood  Richard Linklater
26           American Beauty         Sam Mendes
39              Mystic River     Clint Eastwood
51    Hannah and Her Sisters        Woody Allen
52            In the Bedroom         Todd Field
57                  Big Fish         Tim Burton
60       The Sweet Hereafter        Atom Egoyan
62               Stand By Me         Rob Reiner

Once a query is complete, you may want to save the result back to a file on your local machine. The code below provides an example of saving the last subset of data to a CSV file in the output folder. The Pandas documentation provides examples for saving to other formats.

dramas_gt_9_sub.to_csv("output/matts_movies_sub.csv", sep=",", header=True)

4.6 Missing Values

The NULL, NoData, or missing indicator in Pandas is NaN. To begin exploring missing values, let’s recode some of the data to NaN in the movies dataset. In the example below, we change the “Drama” and “Fantasy” genres to NaN. We also recode any ratings between 8.9 and 9.9 to NaN. The replace() method is used to change the categories while the mask() method is used to recode the rating values. np.nan is a NumPy constant used to represent null values.

movies_nan = movies_df.copy()
movies_nan["Genre"] = movies_nan[["Genre"]].replace(["Drama", "Fantasy"], np.nan)
movies_nan['My_Rating'] = movies_nan['My_Rating'].mask(movies_nan['My_Rating'].between(8.9, 9.9))
print(movies_nan.head(10))
                 Movie_Name             Director  ...        Genre  Own
0             Almost Famous        Cameron Crowe  ...          NaN  Yes
1  The Shawshank Redemption       Frank Darabont  ...          NaN  Yes
2             Groundhog Day         Harold Ramis  ...       Comedy  Yes
3              Donnie Darko        Richard Kelly  ...       Sci-Fi  Yes
4           Children of Men       Alfonso Cuaron  ...       Sci-Fi  Yes
5                Annie Hall          Woody Allen  ...       Comedy  Yes
6                  Rushmore         Wes Anderson  ...  Independent  Yes
7                   Memento    Christopher Nolan  ...     Thriller  Yes
8    No Country for Old Men  Joel and Ethan Coen  ...     Thriller  Yes
9                     Seven        David Fincher  ...     Thriller  Yes

[10 rows x 6 columns]
print(len(movies_nan))
1852

The dropna() method can be used to remove rows or columns that contain missing data. If the axis parameter is set to 0, rows with missing values in any column are removed. If it is set to 1, columns with missing data in any row are removed.

movies_drop = movies_nan.dropna(axis=0)
print(movies_drop.head(10))
           Movie_Name           Director  ...        Genre  Own
2       Groundhog Day       Harold Ramis  ...       Comedy  Yes
3        Donnie Darko      Richard Kelly  ...       Sci-Fi  Yes
4     Children of Men     Alfonso Cuaron  ...       Sci-Fi  Yes
5          Annie Hall        Woody Allen  ...       Comedy  Yes
6            Rushmore       Wes Anderson  ...  Independent  Yes
7             Memento  Christopher Nolan  ...     Thriller  Yes
127       After Hours    Martin Scorsese  ...       Comedy  Yes
128   Shotgun Stories       Jeff Nichols  ...  Independent  Yes
129  The Untouchables     Brian de Palma  ...       Action  Yes
130   2 Days in Paris        Julie Delpy  ...      Romance  Yes

[10 rows x 6 columns]
print(len(movies_drop))
1420
movies_dropc = movies_nan.dropna(axis=1)
print(movies_dropc.head(10))
                 Movie_Name             Director  Release_Year  Own
0             Almost Famous        Cameron Crowe          2000  Yes
1  The Shawshank Redemption       Frank Darabont          1994  Yes
2             Groundhog Day         Harold Ramis          1993  Yes
3              Donnie Darko        Richard Kelly          2001  Yes
4           Children of Men       Alfonso Cuaron          2006  Yes
5                Annie Hall          Woody Allen          1977  Yes
6                  Rushmore         Wes Anderson          1998  Yes
7                   Memento    Christopher Nolan          2000  Yes
8    No Country for Old Men  Joel and Ethan Coen          2007  Yes
9                     Seven        David Fincher          1995  Yes
print(len(movies_dropc))
1852

The fillna() method can be used to replace NaN values with another value or string. In the example, we change the missing genres to “Unknown Genre”.

movies_nan["Genre"] = movies_nan["Genre"].fillna(value="Unknown Genre")
print(movies_nan.head(10))
                 Movie_Name             Director  ...          Genre  Own
0             Almost Famous        Cameron Crowe  ...  Unknown Genre  Yes
1  The Shawshank Redemption       Frank Darabont  ...  Unknown Genre  Yes
2             Groundhog Day         Harold Ramis  ...         Comedy  Yes
3              Donnie Darko        Richard Kelly  ...         Sci-Fi  Yes
4           Children of Men       Alfonso Cuaron  ...         Sci-Fi  Yes
5                Annie Hall          Woody Allen  ...         Comedy  Yes
6                  Rushmore         Wes Anderson  ...    Independent  Yes
7                   Memento    Christopher Nolan  ...       Thriller  Yes
8    No Country for Old Men  Joel and Ethan Coen  ...       Thriller  Yes
9                     Seven        David Fincher  ...       Thriller  Yes

[10 rows x 6 columns]

It is also possible to replace null values with a statistic derived from the available values. In the example, we replace the missing ratings with the mean of all available ratings. This would likely not be valid in this case since the missing genres might have different average ratings than the available genres. This is just an example.

movies_nan["My_Rating"] = movies_nan["My_Rating"].fillna(value=movies_nan["My_Rating"].mean())
print(movies_nan.head(10))
                 Movie_Name             Director  ...          Genre  Own
0             Almost Famous        Cameron Crowe  ...  Unknown Genre  Yes
1  The Shawshank Redemption       Frank Darabont  ...  Unknown Genre  Yes
2             Groundhog Day         Harold Ramis  ...         Comedy  Yes
3              Donnie Darko        Richard Kelly  ...         Sci-Fi  Yes
4           Children of Men       Alfonso Cuaron  ...         Sci-Fi  Yes
5                Annie Hall          Woody Allen  ...         Comedy  Yes
6                  Rushmore         Wes Anderson  ...    Independent  Yes
7                   Memento    Christopher Nolan  ...       Thriller  Yes
8    No Country for Old Men  Joel and Ethan Coen  ...       Thriller  Yes
9                     Seven        David Fincher  ...       Thriller  Yes

[10 rows x 6 columns]

4.7 Grouping and Summarizing DataFrames

Pandas provides methods for summarizing data, as described in the examples below. First, we create individual statistics and save them to variables. We then create a Series from a dictionary of these statistics, convert it to a DataFrame using the to_frame() method, and then transpose the DataFrame using transpose().

movies_df = pd.read_csv("datasets/matts_movies.csv", sep=",", header=0, encoding="ISO-8859-1")
movies_df.columns = [column.replace(" ", "_") for column in movies_df.columns]
mov_cnt = movies_df["My_Rating"].count()
mov_mn = movies_df["My_Rating"].mean()
mov_max = movies_df["My_Rating"].max()
mov_min = movies_df["My_Rating"].min()
mov_rang = mov_max-mov_min
mov_stats= pd.Series({"Count": mov_cnt, "Mean": mov_mn, "Max": mov_max, "Min": mov_min, "Range": mov_rang}).to_frame().transpose()
print(mov_stats)
    Count      Mean   Max   Min  Range
0  1852.0  6.952527  9.99  0.67   9.32

It is also possible to obtain summary statistics for each group separately by applying the very useful groupby() method. In the example below, we obtain statistics for each genre and save them into a DataFrame. The columns do not need to be defined beforehand.

movies_df = pd.read_csv("datasets/matts_movies.csv", sep=",", header=0, encoding="ISO-8859-1")
movies_df.columns =[column.replace(" ", "_") for column in movies_df.columns]
genre_stats = pd.DataFrame()
genre_stats["Count"] = movies_df.groupby("Genre")["My_Rating"].count()
genre_stats["Mean"] = movies_df.groupby("Genre")["My_Rating"].mean()
genre_stats["Max"] = movies_df.groupby("Genre")["My_Rating"].max()
genre_stats["Min"] = movies_df.groupby("Genre")["My_Rating"].min()
genre_stats["Range"] = genre_stats["Max"] - genre_stats["Min"]
print(genre_stats)
             Count      Mean   Max   Min  Range
Genre                                          
Action         197  6.651878  9.87  0.67   9.20
Classic         47  6.741064  8.33  4.03   4.30
Comedy         275  6.738545  9.96  1.76   8.20
Documentary     78  7.283718  8.87  5.43   3.44
Drama          321  7.140561  9.99  0.88   9.11
Family          84  6.670714  9.86  1.01   8.85
Fantasy         21  7.473810  9.85  6.21   3.64
Foreign        157  7.453439  9.63  3.76   5.87
Horror          96  6.523021  9.60  1.65   7.95
Independent    190  6.940263  9.92  1.81   8.11
Romance         94  7.238723  9.84  3.45   6.39
Sci-Fi          62  6.872419  9.95  1.92   8.03
Sports          23  6.391304  8.78  2.94   5.84
Thriller       164  7.095976  9.91  3.11   6.80
War             27  6.542593  8.67  4.37   4.30
Western         14  6.960000  8.65  4.53   4.12
comedy           1  6.030000  6.03  6.03   0.00
thriller         1  5.750000  5.75  5.75   0.00

You may have noticed above that there are some issues with the genre names. For example, “Comedy” and “Thriller” show up twice in the summary because these genres were not always capitalized. This can be fixed.

In the example below, we use the unique() method to obtain a list of all genre names. We then define a list of new names where capitalization is consistent. Next, we use the replace() method to change the original name set to our new name set and fix the capitalization issues.

If we re-execute the code from above, you can see that the duplicates have been removed in the summary.

orig_names = list(movies_df["Genre"].unique())
print(orig_names)
['Drama', 'Comedy', 'Sci-Fi', 'Independent', 'Thriller', 'Action', 'Family', 'Fantasy', 'Romance', 'Foreign', 'Horror', 'Documentary', 'Sports', 'War', 'Western', 'Classic', 'comedy', 'thriller']
new_names = ['Drama', 'Comedy', 'Sci-Fi', 'Independent', 'Thriller', 'Action', 'Family',
             'Fantasy', 'Romance', 'Foreign', 'Horror', 'Documentary', 'Sports', 'War',
             'Western', 'Classic', 'Comedy', 'Thriller']
movies_df['Genre'] = movies_df['Genre'].replace(orig_names, new_names)
list(movies_df["Genre"].unique())

genre_stats = pd.DataFrame()
genre_stats["Count"] = movies_df.groupby("Genre")["My_Rating"].count()
genre_stats["Mean"] = movies_df.groupby("Genre")["My_Rating"].mean()
genre_stats["Max"] = movies_df.groupby("Genre")["My_Rating"].max()
genre_stats["Min"] = movies_df.groupby("Genre")["My_Rating"].min()
genre_stats["Range"] = genre_stats["Max"] - genre_stats["Min"]
print(genre_stats)
             Count      Mean   Max   Min  Range
Genre                                          
Action         197  6.651878  9.87  0.67   9.20
Classic         47  6.741064  8.33  4.03   4.30
Comedy         276  6.735978  9.96  1.76   8.20
Documentary     78  7.283718  8.87  5.43   3.44
Drama          321  7.140561  9.99  0.88   9.11
Family          84  6.670714  9.86  1.01   8.85
Fantasy         21  7.473810  9.85  6.21   3.64
Foreign        157  7.453439  9.63  3.76   5.87
Horror          96  6.523021  9.60  1.65   7.95
Independent    190  6.940263  9.92  1.81   8.11
Romance         94  7.238723  9.84  3.45   6.39
Sci-Fi          62  6.872419  9.95  1.92   8.03
Sports          23  6.391304  8.78  2.94   5.84
Thriller       165  7.087818  9.91  3.11   6.80
War             27  6.542593  8.67  4.37   4.30
Western         14  6.960000  8.65  4.53   4.12

The describe() method can be used to obtain a set of default summary statistics for a column of data. Combining this with groupby() allows for the calculation of statistics by group.

print(movies_df.groupby("Genre")["My_Rating"].describe())
             count      mean       std   min     25%    50%     75%   max
Genre                                                                    
Action       197.0  6.651878  1.687098  0.67  5.8400  6.950  7.7300  9.87
Classic       47.0  6.741064  0.903364  4.03  6.3000  6.790  7.2550  8.33
Comedy       276.0  6.735978  1.494788  1.76  5.9025  6.800  7.7025  9.96
Documentary   78.0  7.283718  0.778165  5.43  6.8725  7.290  7.8800  8.87
Drama        321.0  7.140561  1.292603  0.88  6.5800  7.150  7.8900  9.99
Family        84.0  6.670714  1.654381  1.01  5.9550  6.930  7.4875  9.86
Fantasy       21.0  7.473810  1.113838  6.21  6.6500  7.030  7.9400  9.85
Foreign      157.0  7.453439  0.778073  3.76  6.9500  7.410  7.9200  9.63
Horror        96.0  6.523021  1.491829  1.65  5.9000  6.770  7.2425  9.60
Independent  190.0  6.940263  1.208283  1.81  6.3300  6.880  7.6525  9.92
Romance       94.0  7.238723  1.471279  3.45  6.4925  7.085  8.2150  9.84
Sci-Fi        62.0  6.872419  1.901382  1.92  5.9300  7.040  8.2000  9.95
Sports        23.0  6.391304  1.591958  2.94  5.5800  6.770  7.3000  8.78
Thriller     165.0  7.087818  1.427839  3.11  6.3200  7.120  8.0300  9.91
War           27.0  6.542593  1.096518  4.37  5.8400  6.560  7.2100  8.67
Western       14.0  6.960000  1.411033  4.53  6.0400  7.070  8.2275  8.65

4.8 Concatenate and Merge

The Pandas concat() function is used to concatenate DataFrames that have the same columns. This is comparable to copying and pasting rows from two spreadsheets into a new spreadsheet. To demonstrate this, we extract two sets of rows using indexes. Next, we concatenate them into a new DataFrame.

movies_df = pd.read_csv("datasets/matts_movies.csv", sep=",", header=0, encoding="ISO-8859-1")
movies_df.columns =[column.replace(" ", "_") for column in movies_df.columns]

movies_sub1 = movies_df[100:500]
movies_sub2 = movies_df[900:1300]
movies_subc = pd.concat([movies_sub1, movies_sub2])
print(len(movies_subc))
800

Merge is comparable to table joins when using SQL. This requires the use of keys and the declaration of a joining method, such as “left”, “right”, “inner”, or “outer”.

In the example, we first create a unique ID by copying the row index to a column. We then break the data into two components, each containing the ID and a subset of the remaining columns. Next, we use the merge() function to merge the DataFrames using the “inner” method and the common “id” field. “inner” only returns rows that occur in both datasets. Since both DataFrames were derived from the same original DataFrame, they have identical rows, so the result would be the same as using “left”, where all rows from the left table are maintained even if they don’t occur in the right table, or “right”, where all rows from the right table are maintained even if they don’t occur in the left table.

In the second example, we use a query to separate out only movies released in 2008. When we perform a join with all of the data using the “inner” method, we only get back the common or shared rows.

Note that there is also a join() method that joins based on indexes. However, that is not demonstrated here.

movies_df = pd.read_csv("datasets/matts_movies.csv", sep=",", header=0, encoding="ISO-8859-1")
movies_df.columns =[column.replace(" ", "_") for column in movies_df.columns]

movies_df["id"] = movies_df.index
print(movies_df.head())
                 Movie_Name        Director  Release_Year  ...   Genre  Own id
0             Almost Famous   Cameron Crowe          2000  ...   Drama  Yes  0
1  The Shawshank Redemption  Frank Darabont          1994  ...   Drama  Yes  1
2             Groundhog Day    Harold Ramis          1993  ...  Comedy  Yes  2
3              Donnie Darko   Richard Kelly          2001  ...  Sci-Fi  Yes  3
4           Children of Men  Alfonso Cuaron          2006  ...  Sci-Fi  Yes  4

[5 rows x 7 columns]
movies_first = movies_df[["id", "Movie_Name"]]
movies_second = movies_df[["id", "Director", "Release_Year"]]

movies_merge = pd.merge(movies_first, movies_second, how="inner", on="id")
print(movies_merge.head())
   id                Movie_Name        Director  Release_Year
0   0             Almost Famous   Cameron Crowe          2000
1   1  The Shawshank Redemption  Frank Darabont          1994
2   2             Groundhog Day    Harold Ramis          1993
3   3              Donnie Darko   Richard Kelly          2001
4   4           Children of Men  Alfonso Cuaron          2006
print(len(movies_merge))
1852
movies_third = movies_df.query('Release_Year == 2008')[["id", "Director", "Release_Year"]]
movies_merge2 = pd.merge(movies_first, movies_third, how="inner", on="id")
print(movies_merge2.head())
    id            Movie_Name           Director  Release_Year
0   11                Wall-E     Andrew Stanton          2008
1   38       The Dark Knight  Christopher Nolan          2008
2  100             In Bruges    Martin McDonagh          2008
3  102           Gran Torino     Clint Eastwood          2008
4  104  Let the Right One In    Tomas Alfredson          2008
print(len(movies_merge2))
93

4.9 Polars

4.9.1 How Polars Differs from Pandas

Polars is a more recent DataFrame library that serves the same general purpose as Pandas: reading, querying, manipulating, and summarizing data tables. However, it was designed from scratch with performance in mind. Polars is written in the Rust programming language and stores data using the Apache Arrow columnar memory format. Here are some of the key differences between Polars and Pandas:

  • No index: Pandas DataFrames have an index that labels each row, which is used by methods such as .loc. Polars DataFrames do not have an index. Rows are identified by their position only, and rows are selected by filtering based on column values.
  • Expressions: In Polars, most operations are written as expressions that describe a calculation on one or more columns, such as pl.col("My Rating").mean(). Expressions are provided to a small set of methods, such as select(), filter(), with_columns(), and group_by().agg(). Once you understand this pattern, it can be applied consistently.
  • No in-place modification: In Pandas, it is common to create or change a column using assignment (for example, df["new_col"] = ...). In Polars, methods return a new DataFrame, and new or altered columns are created using with_columns().
  • Lazy evaluation: In addition to executing operations immediately (eager mode), Polars offers a lazy mode in which a sequence of operations is defined first and then executed all at once. This allows Polars to optimize the query before running it, for example, by only reading in the columns and rows that are actually needed.
  • Speed and memory: Polars uses all available processor cores by default and is often much faster and more memory efficient than Pandas, especially for large datasets.
  • Missing data: Polars represents missing values as null for all data types. NaN is a separate, valid floating point value as opposed to an indicator of missing data.
  • Ecosystem: Pandas has been around much longer, and many libraries, including Seaborn, Statsmodels, and GeoPandas, are built around Pandas DataFrames. Fortunately, it is easy to convert between Pandas and Polars.

Table 4.1 compares some common operations in Pandas and Polars.

Table 4.1. Common operations in Pandas and Polars.
Task Pandas Polars
Read a CSV file pd.read_csv() pl.read_csv()
Select columns df[["a", "b"]] df.select("a", "b")
Filter rows df[df["a"] > 5] or df.query() df.filter(pl.col("a") > 5)
Create a new column df["c"] = df["a"] + df["b"] df.with_columns((pl.col("a") + pl.col("b")).alias("c"))
Group and summarize df.groupby("g")["a"].mean() df.group_by("g").agg(pl.col("a").mean())
Join tables pd.merge() df.join()
Stack tables pd.concat() pl.concat()
Remove missing values df.dropna() df.drop_nulls()
Fill missing values df.fillna() df.fill_null()

It is common to assign Polars an alias of pl. Polars prints DataFrames as a formatted table with the data type of each column listed below the column name. By default, only 10 rows are printed. We use pl.Config.set_tbl_rows() to increase this to 20 rows.

import polars as pl
pl.Config.set_tbl_rows(20)
<class 'polars.config.Config'>

4.9.2 Reading Data

To compare the two libraries, we repeat several of the Pandas examples from above using the same movie dataset. Data are read from a CSV file using read_csv(). Similar to Pandas, we need to specify the character encoding for this file due to the use of special characters. The shape property returns the number of rows and columns, and the height property returns the number of rows. The schema property returns the column names and their associated data types.

movies_pl = pl.read_csv("datasets/matts_movies.csv", encoding="ISO-8859-1")
print(movies_pl.head(10))
shape: (10, 6)
┌──────────────────────────┬─────────────────────┬──────────────┬───────────┬─────────────┬─────┐
│ Movie Name               ┆ Director            ┆ Release Year ┆ My Rating ┆ Genre       ┆ Own │
│ ---                      ┆ ---                 ┆ ---          ┆ ---       ┆ ---         ┆ --- │
│ str                      ┆ str                 ┆ i64          ┆ f64       ┆ str         ┆ str │
╞══════════════════════════╪═════════════════════╪══════════════╪═══════════╪═════════════╪═════╡
│ Almost Famous            ┆ Cameron Crowe       ┆ 2000         ┆ 9.99      ┆ Drama       ┆ Yes │
│ The Shawshank Redemption ┆ Frank Darabont      ┆ 1994         ┆ 9.98      ┆ Drama       ┆ Yes │
│ Groundhog Day            ┆ Harold Ramis        ┆ 1993         ┆ 9.96      ┆ Comedy      ┆ Yes │
│ Donnie Darko             ┆ Richard Kelly       ┆ 2001         ┆ 9.95      ┆ Sci-Fi      ┆ Yes │
│ Children of Men          ┆ Alfonso Cuaron      ┆ 2006         ┆ 9.94      ┆ Sci-Fi      ┆ Yes │
│ Annie Hall               ┆ Woody Allen         ┆ 1977         ┆ 9.93      ┆ Comedy      ┆ Yes │
│ Rushmore                 ┆ Wes Anderson        ┆ 1998         ┆ 9.92      ┆ Independent ┆ Yes │
│ Memento                  ┆ Christopher Nolan   ┆ 2000         ┆ 9.91      ┆ Thriller    ┆ Yes │
│ No Country for Old Men   ┆ Joel and Ethan Coen ┆ 2007         ┆ 9.9       ┆ Thriller    ┆ Yes │
│ Seven                    ┆ David Fincher       ┆ 1995         ┆ 9.88      ┆ Thriller    ┆ Yes │
└──────────────────────────┴─────────────────────┴──────────────┴───────────┴─────────────┴─────┘
print(movies_pl.shape)
(1852, 6)
print(movies_pl.height)
1852
print(movies_pl.schema)
Schema([('Movie Name', String), ('Director', String), ('Release Year', Int64), ('My Rating', Float64), ('Genre', String), ('Own', String)])

Since columns are referenced using pl.col() and the column name as a string, spaces in column names are not as problematic as they are when using dot notation or query() with Pandas. However, to stay consistent with the Pandas examples, we replace the spaces with underscores using the rename() method. rename() accepts either a dictionary of old and new names or a function to apply to every column name, in this case a lambda function.

movies_pl = movies_pl.rename(lambda col: col.replace(" ", "_"))
print(movies_pl.columns)
['Movie_Name', 'Director', 'Release_Year', 'My_Rating', 'Genre', 'Own']

4.9.3 Query and Subset

Rows are subset using the filter() method and an expression that evaluates to True or False for each row. Columns are subset using the select() method. The three examples below are equivalent to the first set of Pandas query examples. Similar to Pandas, compound queries are written using & (and) or | (or), and each condition must be placed in parentheses.

#Example 1
just_dramas = movies_pl.filter(pl.col("Genre")=="Drama")
print(just_dramas.head(10))
shape: (10, 6)
┌──────────────────────────┬───────────────────┬──────────────┬───────────┬───────┬─────┐
│ Movie_Name               ┆ Director          ┆ Release_Year ┆ My_Rating ┆ Genre ┆ Own │
│ ---                      ┆ ---               ┆ ---          ┆ ---       ┆ ---   ┆ --- │
│ str                      ┆ str               ┆ i64          ┆ f64       ┆ str   ┆ str │
╞══════════════════════════╪═══════════════════╪══════════════╪═══════════╪═══════╪═════╡
│ Almost Famous            ┆ Cameron Crowe     ┆ 2000         ┆ 9.99      ┆ Drama ┆ Yes │
│ The Shawshank Redemption ┆ Frank Darabont    ┆ 1994         ┆ 9.98      ┆ Drama ┆ Yes │
│ Boyhood                  ┆ Richard Linklater ┆ 2014         ┆ 9.75      ┆ Drama ┆ No  │
│ American Beauty          ┆ Sam Mendes        ┆ 1999         ┆ 9.7       ┆ Drama ┆ Yes │
│ Mystic River             ┆ Clint Eastwood    ┆ 2003         ┆ 9.56      ┆ Drama ┆ Yes │
│ Hannah and Her Sisters   ┆ Woody Allen       ┆ 1986         ┆ 9.47      ┆ Drama ┆ Yes │
│ In the Bedroom           ┆ Todd Field        ┆ 2001         ┆ 9.47      ┆ Drama ┆ Yes │
│ Big Fish                 ┆ Tim Burton        ┆ 2003         ┆ 9.44      ┆ Drama ┆ Yes │
│ The Sweet Hereafter      ┆ Atom Egoyan       ┆ 1997         ┆ 9.42      ┆ Drama ┆ Yes │
│ Stand By Me              ┆ Rob Reiner        ┆ 1986         ┆ 9.4       ┆ Drama ┆ Yes │
└──────────────────────────┴───────────────────┴──────────────┴───────────┴───────┴─────┘
print(just_dramas.height)
321
#Example 2
dramas_gt_9 = movies_pl.filter((pl.col("Genre")=="Drama") & (pl.col("My_Rating")>9.0))
print(dramas_gt_9.height)
23
#Example 3
dramas_gt_9_sub = movies_pl.filter((pl.col("Genre")=="Drama") & (pl.col("My_Rating")>9.0)).select("Movie_Name", "Director")
print(dramas_gt_9_sub.head(10))
shape: (10, 2)
┌──────────────────────────┬───────────────────┐
│ Movie_Name               ┆ Director          │
│ ---                      ┆ ---               │
│ str                      ┆ str               │
╞══════════════════════════╪═══════════════════╡
│ Almost Famous            ┆ Cameron Crowe     │
│ The Shawshank Redemption ┆ Frank Darabont    │
│ Boyhood                  ┆ Richard Linklater │
│ American Beauty          ┆ Sam Mendes        │
│ Mystic River             ┆ Clint Eastwood    │
│ Hannah and Her Sisters   ┆ Woody Allen       │
│ In the Bedroom           ┆ Todd Field        │
│ Big Fish                 ┆ Tim Burton        │
│ The Sweet Hereafter      ┆ Atom Egoyan       │
│ Stand By Me              ┆ Rob Reiner        │
└──────────────────────────┴───────────────────┘

New columns are created using with_columns(). In the example, we use concat_str() to combine the movie name and release year into a new column. The lit() function is used to include literal strings (in this case, the parentheses), and the alias() method assigns a name to the new column. Note that the release year is an integer, but concat_str() converts it to a string automatically. Since Polars does not modify DataFrames in place, we must save the result to a variable.

movies_pl2 = movies_pl.with_columns(
    pl.concat_str([pl.col("Movie_Name"), pl.lit(" ("), pl.col("Release_Year"), pl.lit(")")]).alias("Title_Year")
)
print(movies_pl2.head(5))
shape: (5, 7)
┌───────────────────┬────────────────┬──────────────┬───────────┬────────┬─────┬───────────────────┐
│ Movie_Name        ┆ Director       ┆ Release_Year ┆ My_Rating ┆ Genre  ┆ Own ┆ Title_Year        │
│ ---               ┆ ---            ┆ ---          ┆ ---       ┆ ---    ┆ --- ┆ ---               │
│ str               ┆ str            ┆ i64          ┆ f64       ┆ str    ┆ str ┆ str               │
╞═══════════════════╪════════════════╪══════════════╪═══════════╪════════╪═════╪═══════════════════╡
│ Almost Famous     ┆ Cameron Crowe  ┆ 2000         ┆ 9.99      ┆ Drama  ┆ Yes ┆ Almost Famous     │
│                   ┆                ┆              ┆           ┆        ┆     ┆ (2000)            │
│ The Shawshank     ┆ Frank Darabont ┆ 1994         ┆ 9.98      ┆ Drama  ┆ Yes ┆ The Shawshank     │
│ Redemption        ┆                ┆              ┆           ┆        ┆     ┆ Redemption (1994… │
│ Groundhog Day     ┆ Harold Ramis   ┆ 1993         ┆ 9.96      ┆ Comedy ┆ Yes ┆ Groundhog Day     │
│                   ┆                ┆              ┆           ┆        ┆     ┆ (1993)            │
│ Donnie Darko      ┆ Richard Kelly  ┆ 2001         ┆ 9.95      ┆ Sci-Fi ┆ Yes ┆ Donnie Darko      │
│                   ┆                ┆              ┆           ┆        ┆     ┆ (2001)            │
│ Children of Men   ┆ Alfonso Cuaron ┆ 2006         ┆ 9.94      ┆ Sci-Fi ┆ Yes ┆ Children of Men   │
│                   ┆                ┆              ┆           ┆        ┆     ┆ (2006)            │
└───────────────────┴────────────────┴──────────────┴───────────┴────────┴─────┴───────────────────┘

Data are written to a file using a write_ method, such as write_csv().

dramas_gt_9_sub.write_csv("output/matts_movies_sub_polars.csv")

4.9.4 Missing Values

To compare missing data handling, we repeat the Pandas example in which the “Drama” and “Fantasy” genres and ratings between 8.9 and 9.9 were recoded to missing. In Polars, conditional values are created using when(), then(), and otherwise(), which is similar to an If…Else statement. Providing None to then() results in a null value. The null_count() method returns the number of missing values in each column.

movies_null = movies_pl.with_columns(
    pl.when(pl.col("Genre").is_in(["Drama", "Fantasy"])).then(None).otherwise(pl.col("Genre")).alias("Genre"),
    pl.when(pl.col("My_Rating").is_between(8.9, 9.9)).then(None).otherwise(pl.col("My_Rating")).alias("My_Rating")
)
print(movies_null.head(10))
shape: (10, 6)
┌──────────────────────────┬─────────────────────┬──────────────┬───────────┬─────────────┬─────┐
│ Movie_Name               ┆ Director            ┆ Release_Year ┆ My_Rating ┆ Genre       ┆ Own │
│ ---                      ┆ ---                 ┆ ---          ┆ ---       ┆ ---         ┆ --- │
│ str                      ┆ str                 ┆ i64          ┆ f64       ┆ str         ┆ str │
╞══════════════════════════╪═════════════════════╪══════════════╪═══════════╪═════════════╪═════╡
│ Almost Famous            ┆ Cameron Crowe       ┆ 2000         ┆ 9.99      ┆ null        ┆ Yes │
│ The Shawshank Redemption ┆ Frank Darabont      ┆ 1994         ┆ 9.98      ┆ null        ┆ Yes │
│ Groundhog Day            ┆ Harold Ramis        ┆ 1993         ┆ 9.96      ┆ Comedy      ┆ Yes │
│ Donnie Darko             ┆ Richard Kelly       ┆ 2001         ┆ 9.95      ┆ Sci-Fi      ┆ Yes │
│ Children of Men          ┆ Alfonso Cuaron      ┆ 2006         ┆ 9.94      ┆ Sci-Fi      ┆ Yes │
│ Annie Hall               ┆ Woody Allen         ┆ 1977         ┆ 9.93      ┆ Comedy      ┆ Yes │
│ Rushmore                 ┆ Wes Anderson        ┆ 1998         ┆ 9.92      ┆ Independent ┆ Yes │
│ Memento                  ┆ Christopher Nolan   ┆ 2000         ┆ 9.91      ┆ Thriller    ┆ Yes │
│ No Country for Old Men   ┆ Joel and Ethan Coen ┆ 2007         ┆ null      ┆ Thriller    ┆ Yes │
│ Seven                    ┆ David Fincher       ┆ 1995         ┆ null      ┆ Thriller    ┆ Yes │
└──────────────────────────┴─────────────────────┴──────────────┴───────────┴─────────────┴─────┘
print(movies_null.null_count())
shape: (1, 6)
┌────────────┬──────────┬──────────────┬───────────┬───────┬─────┐
│ Movie_Name ┆ Director ┆ Release_Year ┆ My_Rating ┆ Genre ┆ Own │
│ ---        ┆ ---      ┆ ---          ┆ ---       ┆ ---   ┆ --- │
│ u32        ┆ u32      ┆ u32          ┆ u32       ┆ u32   ┆ u32 │
╞════════════╪══════════╪══════════════╪═══════════╪═══════╪═════╡
│ 0          ┆ 0        ┆ 0            ┆ 119       ┆ 342   ┆ 0   │
└────────────┴──────────┴──────────────┴───────────┴───────┴─────┘

The drop_nulls() method removes all rows that have a missing value in any column. The fill_null() method replaces missing values with a value or with the result of an expression. In the example, we fill the missing genres with “Unknown Genre” and the missing ratings with the mean of the available ratings, which is equivalent to the Pandas examples above.

movies_drop = movies_null.drop_nulls()
print(movies_drop.height)
1420
movies_fill = movies_null.with_columns(
    pl.col("Genre").fill_null("Unknown Genre"),
    pl.col("My_Rating").fill_null(pl.col("My_Rating").mean())
)
print(movies_fill.head(10))
shape: (10, 6)
┌──────────────────────────┬─────────────────────┬──────────────┬───────────┬───────────────┬─────┐
│ Movie_Name               ┆ Director            ┆ Release_Year ┆ My_Rating ┆ Genre         ┆ Own │
│ ---                      ┆ ---                 ┆ ---          ┆ ---       ┆ ---           ┆ --- │
│ str                      ┆ str                 ┆ i64          ┆ f64       ┆ str           ┆ str │
╞══════════════════════════╪═════════════════════╪══════════════╪═══════════╪═══════════════╪═════╡
│ Almost Famous            ┆ Cameron Crowe       ┆ 2000         ┆ 9.99      ┆ Unknown Genre ┆ Yes │
│ The Shawshank Redemption ┆ Frank Darabont      ┆ 1994         ┆ 9.98      ┆ Unknown Genre ┆ Yes │
│ Groundhog Day            ┆ Harold Ramis        ┆ 1993         ┆ 9.96      ┆ Comedy        ┆ Yes │
│ Donnie Darko             ┆ Richard Kelly       ┆ 2001         ┆ 9.95      ┆ Sci-Fi        ┆ Yes │
│ Children of Men          ┆ Alfonso Cuaron      ┆ 2006         ┆ 9.94      ┆ Sci-Fi        ┆ Yes │
│ Annie Hall               ┆ Woody Allen         ┆ 1977         ┆ 9.93      ┆ Comedy        ┆ Yes │
│ Rushmore                 ┆ Wes Anderson        ┆ 1998         ┆ 9.92      ┆ Independent   ┆ Yes │
│ Memento                  ┆ Christopher Nolan   ┆ 2000         ┆ 9.91      ┆ Thriller      ┆ Yes │
│ No Country for Old Men   ┆ Joel and Ethan Coen ┆ 2007         ┆ 6.786509  ┆ Thriller      ┆ Yes │
│ Seven                    ┆ David Fincher       ┆ 1995         ┆ 6.786509  ┆ Thriller      ┆ Yes │
└──────────────────────────┴─────────────────────┴──────────────┴───────────┴───────────────┴─────┘
print(movies_fill.null_count())
shape: (1, 6)
┌────────────┬──────────┬──────────────┬───────────┬───────┬─────┐
│ Movie_Name ┆ Director ┆ Release_Year ┆ My_Rating ┆ Genre ┆ Own │
│ ---        ┆ ---      ┆ ---          ┆ ---       ┆ ---   ┆ --- │
│ u32        ┆ u32      ┆ u32          ┆ u32       ┆ u32   ┆ u32 │
╞════════════╪══════════╪══════════════╪═══════════╪═══════╪═════╡
│ 0          ┆ 0        ┆ 0            ┆ 0         ┆ 0     ┆ 0   │
└────────────┴──────────┴──────────────┴───────────┴───────┴─────┘

4.9.5 Grouping and Summarizing

Summary statistics can be calculated by providing multiple expressions to select(). Each expression returns a single value, so the result is a DataFrame with one row, similar to the mov_stats DataFrame created with Pandas. Note that expressions can be combined; the range is calculated by subtracting the minimum from the maximum.

mov_stats_pl = movies_pl.select(
    pl.col("My_Rating").count().alias("Count"),
    pl.col("My_Rating").mean().alias("Mean"),
    pl.col("My_Rating").max().alias("Max"),
    pl.col("My_Rating").min().alias("Min"),
    (pl.col("My_Rating").max() - pl.col("My_Rating").min()).alias("Range")
)
print(mov_stats_pl)
shape: (1, 5)
┌───────┬──────────┬──────┬──────┬───────┐
│ Count ┆ Mean     ┆ Max  ┆ Min  ┆ Range │
│ ---   ┆ ---      ┆ ---  ┆ ---  ┆ ---   │
│ u32   ┆ f64      ┆ f64  ┆ f64  ┆ f64   │
╞═══════╪══════════╪══════╪══════╪═══════╡
│ 1852  ┆ 6.952527 ┆ 9.99 ┆ 0.67 ┆ 9.32  │
└───────┴──────────┴──────┴──────┴───────┘

Grouped summaries are calculated using group_by() followed by agg(). In comparison to the Pandas example, all of the statistics are calculated in a single step as opposed to calling groupby() separately for each statistic. By default, Polars does not maintain a specific order of the groups, so we sort the result by genre using sort().

As with Pandas, “Comedy” and “Thriller” appear twice in the result due to inconsistent capitalization.

genre_stats_pl = (
    movies_pl.group_by("Genre")
    .agg(
        pl.col("My_Rating").count().alias("Count"),
        pl.col("My_Rating").mean().alias("Mean"),
        pl.col("My_Rating").max().alias("Max"),
        pl.col("My_Rating").min().alias("Min")
    )
    .with_columns((pl.col("Max") - pl.col("Min")).alias("Range"))
    .sort("Genre")
)
print(genre_stats_pl)
shape: (18, 6)
┌─────────────┬───────┬──────────┬──────┬──────┬───────┐
│ Genre       ┆ Count ┆ Mean     ┆ Max  ┆ Min  ┆ Range │
│ ---         ┆ ---   ┆ ---      ┆ ---  ┆ ---  ┆ ---   │
│ str         ┆ u32   ┆ f64      ┆ f64  ┆ f64  ┆ f64   │
╞═════════════╪═══════╪══════════╪══════╪══════╪═══════╡
│ Action      ┆ 197   ┆ 6.651878 ┆ 9.87 ┆ 0.67 ┆ 9.2   │
│ Classic     ┆ 47    ┆ 6.741064 ┆ 8.33 ┆ 4.03 ┆ 4.3   │
│ Comedy      ┆ 275   ┆ 6.738545 ┆ 9.96 ┆ 1.76 ┆ 8.2   │
│ Documentary ┆ 78    ┆ 7.283718 ┆ 8.87 ┆ 5.43 ┆ 3.44  │
│ Drama       ┆ 321   ┆ 7.140561 ┆ 9.99 ┆ 0.88 ┆ 9.11  │
│ Family      ┆ 84    ┆ 6.670714 ┆ 9.86 ┆ 1.01 ┆ 8.85  │
│ Fantasy     ┆ 21    ┆ 7.47381  ┆ 9.85 ┆ 6.21 ┆ 3.64  │
│ Foreign     ┆ 157   ┆ 7.453439 ┆ 9.63 ┆ 3.76 ┆ 5.87  │
│ Horror      ┆ 96    ┆ 6.523021 ┆ 9.6  ┆ 1.65 ┆ 7.95  │
│ Independent ┆ 190   ┆ 6.940263 ┆ 9.92 ┆ 1.81 ┆ 8.11  │
│ Romance     ┆ 94    ┆ 7.238723 ┆ 9.84 ┆ 3.45 ┆ 6.39  │
│ Sci-Fi      ┆ 62    ┆ 6.872419 ┆ 9.95 ┆ 1.92 ┆ 8.03  │
│ Sports      ┆ 23    ┆ 6.391304 ┆ 8.78 ┆ 2.94 ┆ 5.84  │
│ Thriller    ┆ 164   ┆ 7.095976 ┆ 9.91 ┆ 3.11 ┆ 6.8   │
│ War         ┆ 27    ┆ 6.542593 ┆ 8.67 ┆ 4.37 ┆ 4.3   │
│ Western     ┆ 14    ┆ 6.96     ┆ 8.65 ┆ 4.53 ┆ 4.12  │
│ comedy      ┆ 1     ┆ 6.03     ┆ 6.03 ┆ 6.03 ┆ 0.0   │
│ thriller    ┆ 1     ┆ 5.75     ┆ 5.75 ┆ 5.75 ┆ 0.0   │
└─────────────┴───────┴──────────┴──────┴──────┴───────┘

Polars provides string methods through the str namespace. As an alternative to the approach used with Pandas, in which we defined a list of corrected names, we use str.to_titlecase() to capitalize the first letter of each word in the genre names. Once the names are fixed, the duplicates are removed from the summary.

movies_pl = movies_pl.with_columns(pl.col("Genre").str.to_titlecase())
print(movies_pl["Genre"].unique().sort().to_list())
['Action', 'Classic', 'Comedy', 'Documentary', 'Drama', 'Family', 'Fantasy', 'Foreign', 'Horror', 'Independent', 'Romance', 'Sci-Fi', 'Sports', 'Thriller', 'War', 'Western']
genre_stats_pl = (
    movies_pl.group_by("Genre")
    .agg(
        pl.col("My_Rating").count().alias("Count"),
        pl.col("My_Rating").mean().alias("Mean"),
        pl.col("My_Rating").max().alias("Max"),
        pl.col("My_Rating").min().alias("Min")
    )
    .with_columns((pl.col("Max") - pl.col("Min")).alias("Range"))
    .sort("Genre")
)
print(genre_stats_pl)
shape: (16, 6)
┌─────────────┬───────┬──────────┬──────┬──────┬───────┐
│ Genre       ┆ Count ┆ Mean     ┆ Max  ┆ Min  ┆ Range │
│ ---         ┆ ---   ┆ ---      ┆ ---  ┆ ---  ┆ ---   │
│ str         ┆ u32   ┆ f64      ┆ f64  ┆ f64  ┆ f64   │
╞═════════════╪═══════╪══════════╪══════╪══════╪═══════╡
│ Action      ┆ 197   ┆ 6.651878 ┆ 9.87 ┆ 0.67 ┆ 9.2   │
│ Classic     ┆ 47    ┆ 6.741064 ┆ 8.33 ┆ 4.03 ┆ 4.3   │
│ Comedy      ┆ 276   ┆ 6.735978 ┆ 9.96 ┆ 1.76 ┆ 8.2   │
│ Documentary ┆ 78    ┆ 7.283718 ┆ 8.87 ┆ 5.43 ┆ 3.44  │
│ Drama       ┆ 321   ┆ 7.140561 ┆ 9.99 ┆ 0.88 ┆ 9.11  │
│ Family      ┆ 84    ┆ 6.670714 ┆ 9.86 ┆ 1.01 ┆ 8.85  │
│ Fantasy     ┆ 21    ┆ 7.47381  ┆ 9.85 ┆ 6.21 ┆ 3.64  │
│ Foreign     ┆ 157   ┆ 7.453439 ┆ 9.63 ┆ 3.76 ┆ 5.87  │
│ Horror      ┆ 96    ┆ 6.523021 ┆ 9.6  ┆ 1.65 ┆ 7.95  │
│ Independent ┆ 190   ┆ 6.940263 ┆ 9.92 ┆ 1.81 ┆ 8.11  │
│ Romance     ┆ 94    ┆ 7.238723 ┆ 9.84 ┆ 3.45 ┆ 6.39  │
│ Sci-Fi      ┆ 62    ┆ 6.872419 ┆ 9.95 ┆ 1.92 ┆ 8.03  │
│ Sports      ┆ 23    ┆ 6.391304 ┆ 8.78 ┆ 2.94 ┆ 5.84  │
│ Thriller    ┆ 165   ┆ 7.087818 ┆ 9.91 ┆ 3.11 ┆ 6.8   │
│ War         ┆ 27    ┆ 6.542593 ┆ 8.67 ┆ 4.37 ┆ 4.3   │
│ Western     ┆ 14    ┆ 6.96     ┆ 8.65 ┆ 4.53 ┆ 4.12  │
└─────────────┴───────┴──────────┴──────┴──────┴───────┘

Similar to Pandas, the describe() method returns a set of default summary statistics.

print(movies_pl.select("Release_Year", "My_Rating").describe())
shape: (9, 3)
┌────────────┬──────────────┬───────────┐
│ statistic  ┆ Release_Year ┆ My_Rating │
│ ---        ┆ ---          ┆ ---       │
│ str        ┆ f64          ┆ f64       │
╞════════════╪══════════════╪═══════════╡
│ count      ┆ 1852.0       ┆ 1852.0    │
│ null_count ┆ 0.0          ┆ 0.0       │
│ mean       ┆ 1999.191145  ┆ 6.952527  │
│ std        ┆ 14.946878    ┆ 1.402167  │
│ min        ┆ 1921.0       ┆ 0.67      │
│ 25%        ┆ 1995.0       ┆ 6.32      │
│ 50%        ┆ 2004.0       ┆ 7.02      │
│ 75%        ┆ 2009.0       ┆ 7.85      │
│ max        ┆ 2014.0       ┆ 9.99      │
└────────────┴──────────────┴───────────┘

4.9.6 Concatenate and Join

Tables with the same columns are concatenated using pl.concat(). Rows can be extracted by position using bracket notation, as in the Pandas example, or using slice(), which accepts a starting position and the number of rows to extract.

movies_sub1 = movies_pl.slice(100, 400)
movies_sub2 = movies_pl[900:1300]
movies_subc = pl.concat([movies_sub1, movies_sub2])
print(movies_subc.height)
800

Table joins are performed using the join() method. Since Polars DataFrames do not have an index, we use with_row_index() to add a column containing the row number, which serves as a unique ID. We then repeat the Pandas example in which the movies released in 2008 are joined to all of the movies using an inner join. Other options for the how argument include “left”, “right”, and “full”.

movies_pl = movies_pl.with_row_index("id")

movies_first = movies_pl.select("id", "Movie_Name")
movies_third = movies_pl.filter(pl.col("Release_Year")==2008).select("id", "Director", "Release_Year")

movies_join = movies_first.join(movies_third, on="id", how="inner")
print(movies_join.head())
shape: (5, 4)
┌─────┬──────────────────────┬───────────────────┬──────────────┐
│ id  ┆ Movie_Name           ┆ Director          ┆ Release_Year │
│ --- ┆ ---                  ┆ ---               ┆ ---          │
│ u32 ┆ str                  ┆ str               ┆ i64          │
╞═════╪══════════════════════╪═══════════════════╪══════════════╡
│ 11  ┆ Wall-E               ┆ Andrew Stanton    ┆ 2008         │
│ 38  ┆ The Dark Knight      ┆ Christopher Nolan ┆ 2008         │
│ 100 ┆ In Bruges            ┆ Martin McDonagh   ┆ 2008         │
│ 102 ┆ Gran Torino          ┆ Clint Eastwood    ┆ 2008         │
│ 104 ┆ Let the Right One In ┆ Tomas Alfredson   ┆ 2008         │
└─────┴──────────────────────┴───────────────────┴──────────────┘
print(movies_join.height)
93

4.9.7 Lazy Evaluation

All of the examples above used Polars in eager mode, in which each step is executed immediately. In lazy mode, operations are combined into a query plan that is optimized and then executed when collect() is called. A DataFrame can be converted to a LazyFrame using the lazy() method. Data can also be read directly into a LazyFrame using scan_csv(); however, scan_csv() only supports UTF-8 encoded files, so we use lazy() here.

In the example, we calculate the mean rating and number of dramas for each release year. The explain() method prints the optimized query plan. Note that Polars has determined that only three of the columns are needed for the query, so the remaining columns are not processed. The pl.len() function returns the number of rows in each group. For a dataset of this size, the speed difference is not noticeable; however, these optimizations can be very beneficial when working with large datasets.

drama_by_year = (
    movies_pl.lazy()
    .filter(pl.col("Genre")=="Drama")
    .group_by("Release_Year")
    .agg(pl.col("My_Rating").mean().alias("Mean_Rating"), pl.len().alias("Count"))
    .sort("Release_Year", descending=True)
)
print(drama_by_year.explain())
SORT BY [descending: [true]] [col("Release_Year")]
  AGGREGATE[maintain_order: false]
    [col("My_Rating").mean().alias("Mean_Rating"), len().alias("Count")] BY [col("Release_Year")]
    FROM
    simple π 2/2 ["Release_Year", "My_Rating"]
      FILTER col("Genre") == "Drama"
      FROM
        DF ["id", "Movie_Name", "Director", "Release_Year", ...]; PROJECT["Release_Year", "My_Rating", "Genre"] 3/7 COLUMNS
print(drama_by_year.collect().head(10))
shape: (10, 3)
┌──────────────┬─────────────┬───────┐
│ Release_Year ┆ Mean_Rating ┆ Count │
│ ---          ┆ ---         ┆ ---   │
│ i64          ┆ f64         ┆ u32   │
╞══════════════╪═════════════╪═══════╡
│ 2014         ┆ 9.75        ┆ 1     │
│ 2013         ┆ 7.682727    ┆ 22    │
│ 2012         ┆ 6.914       ┆ 10    │
│ 2011         ┆ 6.676522    ┆ 23    │
│ 2010         ┆ 7.254545    ┆ 11    │
│ 2009         ┆ 6.9375      ┆ 16    │
│ 2008         ┆ 7.7325      ┆ 16    │
│ 2007         ┆ 7.161053    ┆ 19    │
│ 2006         ┆ 7.065       ┆ 18    │
│ 2005         ┆ 6.65        ┆ 12    │
└──────────────┴─────────────┴───────┘

4.9.8 Converting Between Polars and Pandas

Since many libraries expect Pandas DataFrames, it is useful to be able to convert between the two. The to_pandas() method converts a Polars DataFrame to a Pandas DataFrame, and pl.from_pandas() converts a Pandas DataFrame to a Polars DataFrame. These conversions require the pyarrow library, which is included in the pybook environment. Polars DataFrames can also be converted to NumPy arrays using to_numpy().

movies_pd = movies_pl.to_pandas()
print(type(movies_pd))
<class 'pandas.DataFrame'>
movies_pl3 = pl.from_pandas(movies_df)
print(type(movies_pl3))
<class 'polars.dataframe.frame.DataFrame'>
print(movies_pl3.shape)
(1852, 7)

4.10 Concluding Remarks

There are many more topics that could be explored relating to Pandas. However, we have found the methods and techniques discussed here to be the ones we use most often and that are most useful. Polars offers an alternative to Pandas that is often faster and has a consistent, expression-based syntax. Since Pandas is more widely supported by other libraries, including those used in the remainder of this book, we primarily use Pandas moving forward. However, if you work with large datasets, Polars is worth exploring.

For more examples and details, please consult the documentation for Pandas and Polars.

If you need to perform a specific task that we did not cover, search for it online! Python and Pandas have a large user base, so it is likely that you will find examples similar to your needs with a simple search.

In Chapter 5, we discuss methods for graphing and visualizing data using matplotlib, Seaborn, and Pandas.

4.11 Questions

  1. Explain the difference between a Series and a DataFrame.
  2. Explain the difference between the loc and iloc indexers.
  3. Explain the difference between bracket notation and dot notation for referencing columns. When can dot notation not be used?
  4. How are missing values represented in Pandas? Explain the purpose of the dropna() and fillna() methods.
  5. Explain what the groupby() method does and how it is combined with a summary statistic.
  6. Explain the difference between concatenating and merging DataFrames. How does an inner join differ from a left join?
  7. List three differences between Pandas and Polars.
  8. Explain the difference between eager and lazy evaluation. Why can lazy evaluation be faster?

4.12 Exercises

Exercise 4.1: Marvel vs. DC Comparison

30 Points scaled to 20 Points

Introduction

This assignment makes use of a dataset that is made available on Kaggle (https://www.kaggle.com/datasets/jcraggy/marvel-vs-dc-imdb-rotten-tomatoes), which consists of data relating to DC and Marvel movies. The dataset has been provided in the datasets folder of the book download (the link above may no longer be active). In this assignment, you will only use movies that were released during or after the year 2000. So, you will need to filter the dataset prior to answering the questions. Your goal is to compare the DC and Marvel franchises by answering the 15 posed questions. Note that the questions assist you in determining what fields to use. The “entity” field differentiates the two groups: “MARVEL” and “DC”.

Objectives
  • Query and filter datasets

  • Obtain summary statistics

  • Compare groups

Deliverables
  • Jupyter Notebook (Python) or R Markdown file (R) with all code and answers to the questions embedded. Files can be rendered to HTML webpages if your instructor requires this. Questions should be stated and be answered within Markdown cells.

Questions

This assignment can be conducted using either Python or R, whichever you prefer or whichever your instructor requires. Generate code to perform the following tasks or answer the questions posed.

*Only use movies released during or after the year 2000 in this assignment.

Question 1. How many of the movies in the original table were released during or after 2000 (“year”)? (2 Points)

Question 2. How many movies did DC release during or after 2000? How many movies did Marvel release during or after 2000? (2 Points)

Question 3. Does DC or Marvel have the highest average rating on IMDB (“imdb_rating”)? (2 Points)

Question 4. Was the lowest rated movie on IMDB (“imdb_rating”) a DC or a Marvel movie? (2 Points)

Question 5. Was the highest rated movie on IMDB (“imdb_rating”) a DC or a Marvel movie? (2 Points)

Question 6. Does DC or Marvel have the highest average critic’s rating on Rotten Tomatoes (“tomato_meter”)? (2 Points)

Question 7. Does DC or Marvel have the highest average audience rating on Rotten Tomatoes (“tom_aud_score”)? (2 Points)

Question 8. Which movie had the largest difference between the critic’s score (“tomato_meter”) and audience score (“tom_aud_score”) on Rotten Tomatoes in which the audience score was higher than the critic’s score? (2 Points)

Question 9. Which movie had the largest difference between the critic’s score (“tomato_meter”) and audience score (“tom_aud_score”) on Rotten Tomatoes in which the audience score was lower than the critic’s score? (2 Points)

Question 10. From the movies included in the database and those released during or after 2000, did DC or Marvel have the highest total gross earnings (“imdb_gross”)? (2 Points)

Question 11. From the movies included in the database and those released during or after 2000, did DC or Marvel have the highest earning movie and what was the movie (“imdb_gross”)? (2 Points)

Question 12. Does DC or Marvel movies have a longer average runtime (“runtime”)? (2 Points)

Question 13. Does DC or Marvel have the larger total runtime for all movies released during or after 2000 (“runtime”)? (2 Points)

Question 14. Calculate the gross earnings per runtime for DC and Marvel (total gross earnings (“imdb_gross”) divided by total runtime (“runtime”)). Does DC or Marvel have the highest gross earnings per unit runtime? (2 Points)

Question 15. Does Marvel of DC have a higher percentage of movies with a PG-13 rating vs. all other ratings (“mpa_rating”)? (2 Points)

Exercise 4.2: US County Summary

30 Points scaled to 20 Points

Introduction

This assignment asks you to answer a series of questions relating to county-level statistics for the contiguous United States. You will make use of the us_counties_data.csv dataset. Make sure to read the associated description of the data (us_counties_data_ DESCRIPTION.pdf) prior to undertaking the assignment. The description file explains the fields available and provides the data source.

Objectives
  • Query and filter datasets

  • Obtain summary statistics

  • Compare groups

Deliverables
  • Jupyter Notebook (Python) or R Markdown file (R) with all code and answers to the questions embedded. Files can be rendered to HTML webpages if your instructor requires this. Questions should be stated and be answered within Markdown cells.

Questions

This assignment can be conducted using either Python or R, whichever you prefer or your instructor requires. Generate code to perform the following tasks or answer the questions posed.

Question 1: What county has the highest recorded mean elevation? (2 Points)

Question 2: What state has the lowest mean county elevation? (2 Points)

Question 3: How many counties have a mean elevation greater than 1,000 meters? (2 Points)

Question 4: How many counties have a mean elevation greater than 1,000 meters and a percent forest cover greater than 40%? (2 Points)

Question 5: How many counties have a mean elevation lower than 100 meters and a percent developed larger than 35%? (2 Points)

Question 6: Which subregion of the country has the largest number of counties? (2 Points)

Question 7: Which subregion of the country has the highest average county mean annual temperature? (2 Points)

Question 8: Does Nebraska or Kansas have the highest average county percent cropland? (2 Points)

Question 9: How many counties have a percent cropland greater than 25%, a mean temperature higher than 10°Celsius, and are not in the West South Central subregion? (2 Points)

Question 10: Which county in the country has the highest percent cropland? (2 Points)

Question 11: Which county in the country has the highest percent developed land? (2 Points)

Question 12: Which subregion has the highest total population? (2 Points)

Question 13: Which state has a higher percent of households with internet access (all internet access, not just broadband), West Virginia or North Dakota? (2 Points)

Question 14: Of all the counties with a mean elevation greater than 1,200 meters, which has the highest total annual precipitation? (2 Points)

Question 15: What county has the largest difference between its annual lowest and highest temperatures? (2 Points)