pandas

Lecture 08

Dr. Colin Rundel

NumPy dtypes

dtype

Every NumPy array stores elements of a single type, its dtype. As with R’s atomic vectors the type is inferred at creation and mixed values are coerced, but NumPy has many more types and each has an explicit size - int8 to int64, uint8 to uint64, float16 to float64, bool, complex128, etc.

np.array([1, 2, 3]).dtype
dtype('int64')
np.array([1.5, 2]).dtype
dtype('float64')
np.array([True, False]).dtype
dtype('bool')
np.array(["a", "bcd"]).dtype
dtype('<U3')
np.array([1, 2.5, True]).dtype
dtype('float64')
np.array([1, 2, 3], dtype="float32")
array([1., 2., 3.], dtype=float32)
np.array([1.7, 2.2]).astype("int64")
array([1, 2])

dtypes are fixed

The dtype is set when the array is created and does not change. Where an R vector is promoted to fit what is assigned into it, a NumPy array converts the value to its own dtype.

x = c(1L, 2L, 3L)
typeof(x)
[1] "integer"
x[1] = 2.9
x
[1] 2.9 2.0 3.0
typeof(x)
[1] "double"
x = np.array([1, 2, 3])
x.dtype
dtype('int64')
x[0] = 2.9
x
array([2, 2, 3])
y = np.array([100, 200], dtype="uint8")
y + y
array([200, 144], dtype=uint8)

pandas

pandas

Python has no built-in tabular data structure. pandas (2008) is an established choice - it grew out of NumPy and borrows heavily from R.

import pandas as pd
pd.__version__
'3.0.5'

Three classes do the heavy lifting:

  • Series - a 1d typed array with an index (a column)

  • Index - the labels attached to rows and columns

  • DataFrame - a collection of Series sharing an index (a table)

Series

A Series is a 1d array with a dtype together with an index of labels - the closest thing in R is a named vector. The difference is that a Series always has an index (integer positions by default) and the index participates in lookup, alignment, and joins rather than being decoration/annotation.

s = pd.Series([4, 2, 1, 3])
s
0    4
1    2
2    1
3    3
dtype: int64
s.index
RangeIndex(start=0, stop=4, step=1)
pd.Series(
  [4, 2, 1, 3],
  index=["a", "b", "c", "d"]
)
a    4
b    2
c    1
d    3
dtype: int64
pd.Series({"a": 4, "b": 2}).index
Index(['a', 'b'], dtype='str')

dtypes

pandas dtypes come from NumPy (int64, float64, bool, datetime64) plus extension dtypes (str, category, Int64, …).

For mixtures such as numbers and strings, pandas falls back to object rather than coercing everything as R does. An object Series still supports elementwise operations, but storing arbitrary Python objects usually costs more memory and computation.

pd.Series([1, 2, 3]).dtype
dtype('int64')
pd.Series([1.5, 2]).dtype
dtype('float64')
pd.Series([True, False]).dtype
dtype('bool')
pd.Series(["a", "b"]).dtype
<StringDtype(na_value=nan)>
pd.Series([1, "a", True])
0       1
1       a
2    True
dtype: object
pd.Series([1, "a", True]).dtype
dtype('O')
pd.Series(["1", "2"]).astype("int64")
0    1
1    2
dtype: int64

Labels vs positions

A pandas index can hold integers, strings, etc. A scalar s[key] looks up a label, even when the key is an integer. Use .iloc for positions and .loc for labels.

t = pd.Series({"a": 4, "b": 2, "c": 1, "d": 3})
u = pd.Series([4, 2, 1, 3], index=[3, 2, 1, 0])
t["b"]
np.int64(2)
t[1]
KeyError: 1
t.iloc[1]
np.int64(2)
u["b"]
KeyError: 'b'
u[1]
np.int64(1)
u.iloc[1]
np.int64(2)

Slices: labels vs positions

.loc label slices include both endpoints; .iloc position slices exclude the stop. Integer slices in s[...] are positional, unlike scalar integer lookups.

t.loc["b":"c"]
b    2
c    1
dtype: int64
t.iloc[1:3]
b    2
c    1
dtype: int64
u[1:3]
2    2
1    1
dtype: int64
u.loc[3:1]
3    4
2    2
1    1
dtype: int64

Index alignment

Arithmetic between Series aligns on index rather than position. The result spans the union of both indexes with NaN for labels missing on one side. Assigning a Series to a column in a DataFrame aligns the same way.

m = pd.Series([1, 2, 3], index=["a", "b", "c"])
n = pd.Series([10, 20, 30], index=["c", "b", "a"])
m + n
a    31
b    22
c    13
dtype: int64
m + n[["a", "c"]]
a    31.0
b     NaN
c    13.0
dtype: float64
d = pd.DataFrame(
  {"x": [1, 2, 3]}, 
  index=["a", "b", "c"]
)
d["y"] = n[["a", "c"]]
d
   x     y
a  1  30.0
b  2   NaN
c  3  10.0

DataFrame

A DataFrame is a dict-like collection of Series sharing a row index, (keys are the column indexes).

penguins = pd.read_csv("data/penguins.csv"); penguins
       species     island  bill_length_mm  ...  body_mass_g     sex  year
0       Adelie  Torgersen            39.1  ...       3750.0    male  2007
1       Adelie  Torgersen            39.5  ...       3800.0  female  2007
..         ...        ...             ...  ...          ...     ...   ...
342  Chinstrap      Dream            50.8  ...       4100.0    male  2009
343  Chinstrap      Dream            50.2  ...       3775.0  female  2009

[344 rows x 8 columns]
penguins.index
RangeIndex(start=0, stop=344, step=1)
penguins.columns
Index(['species', 'island', 'bill_length_mm', 'bill_depth_mm',
       'flipper_length_mm', 'body_mass_g', 'sex', 'year'],
      dtype='str')

Missing values

R has typed NA values each atomic vector type. NumPy has no universal missing-value sentinel across dtypes: floating point arrays can use NaN and datetimes can use NaT, but integers and booleans have no equivalent. With pandas’ default interface, missing integers become float64, while the boolean example below becomes object holding None.

pd.Series([1, 2, None])
0    1.0
1    2.0
2    NaN
dtype: float64
pd.Series([True, False, None])
0     True
1    False
2     None
dtype: object
pd.Series(["a", None])
0      a
1    NaN
dtype: str
pd.Series(
  [pd.Timestamp("2026-09-15"), None]
)
0   2026-09-15
1          NaT
dtype: datetime64[us]

Detecting missing values

As we’ve seen previously, NaN is not equal to anything, including itself. So == cannot find missing values - isna() (and notna()) recognize NaN, None, NaT, and pd.NA alike. dropna() and fillna() can be used to remove or replace.

s = pd.Series([1, 2, None])
s == np.nan
0    False
1    False
2    False
dtype: bool
s.isna()
0    False
1    False
2     True
dtype: bool
s.isna().sum()
np.int64(1)
s.dropna()
0    1.0
1    2.0
dtype: float64
s.fillna(0)
0    1.0
1    2.0
2    0.0
dtype: float64

Missing values in aggregations

Reductions skip missing values by default (skipna=True), the opposite of R’s na.rm = FALSE default..

mass = penguins["body_mass_g"]
mass.mean()
np.float64(4201.754385964912)
mass.mean(skipna=False)
np.float64(nan)
mass.count()
np.int64(342)
penguins.dropna().shape
(333, 8)
penguins.isna().sum()
species               0
island                0
bill_length_mm        2
bill_depth_mm         2
flipper_length_mm     2
body_mass_g           2
sex                  11
year                  0
dtype: int64

Nullable dtypes

The extension dtypes Int64, Float64, boolean, and string use pd.NA to represent missing values without changing dtype. Only the numeric names here are capitalized. These types are opt in, via dtype=, dtype_backend= when reading, or convert_dtypes(). Arithmetic and comparisons use three valued logic - pd.NA == 1 is <NA> rather than False.

t = pd.Series([1, 2, None], dtype="Int64")
t
0       1
1       2
2    <NA>
dtype: Int64
t > 1
0    False
1     True
2     <NA>
dtype: boolean
pd.NA == 1
<NA>
pd.NA | True
True
pd.NA & True
<NA>
bool(pd.NA)
TypeError: boolean value of NA is ambiguous

Subsetting with []

df[...] is overloaded on the type of its argument - a string picks a column, a list of strings a set of columns, and a slice or boolean Series picks rows. There is no two argument df[i, j] form.

penguins["species"]
0         Adelie
1         Adelie
         ...    
342    Chinstrap
343    Chinstrap
Name: species, Length: 344, dtype: str
penguins[["species", "island"]]
       species     island
0       Adelie  Torgersen
1       Adelie  Torgersen
..         ...        ...
342  Chinstrap      Dream
343  Chinstrap      Dream

[344 rows x 2 columns]
penguins[penguins["body_mass_g"] > 6000]
    species  island  bill_length_mm  ...  body_mass_g   sex  year
169  Gentoo  Biscoe            49.2  ...       6300.0  male  2007
185  Gentoo  Biscoe            59.6  ...       6050.0  male  2007

[2 rows x 8 columns]

.loc and .iloc

Two dimensional subsetting like R’s df[i, j] goes through .loc (labels) and .iloc (positions), each taking [rows, cols]. Label slices include the stop; position slices exclude it. Both accept boolean arrays, but an indexed boolean Series belongs with .loc, not .iloc.

penguins.loc[0:2, "species":"island"]
  species     island
0  Adelie  Torgersen
1  Adelie  Torgersen
2  Adelie  Torgersen
penguins.iloc[0:2, 0:2]
  species     island
0  Adelie  Torgersen
1  Adelie  Torgersen
penguins.loc[penguins["species"] == "Gentoo", ["species", "body_mass_g"]]
    species  body_mass_g
152  Gentoo       4500.0
153  Gentoo       5700.0
..      ...          ...
274  Gentoo       5200.0
275  Gentoo       5400.0

[124 rows x 2 columns]

Combining conditions

Use & for elementwise AND, | for OR, and ~ for NOT. Parenthesize comparisons; Python’s and / or do not combine Series. .isin() tests membership in a collection.

mask = (
  penguins["species"].isin(["Adelie", "Gentoo"])
  & (penguins["body_mass_g"] > 5000)
)
penguins.loc[mask, ["species", "body_mass_g"]].head(3)
    species  body_mass_g
153  Gentoo       5700.0
155  Gentoo       5700.0
156  Gentoo       5400.0
penguins.loc[
  (penguins["species"] == "Gentoo")
  | (penguins["body_mass_g"] > 5000),
  ["species", "body_mass_g"]
].head(3)
    species  body_mass_g
152  Gentoo       4500.0
153  Gentoo       5700.0
154  Gentoo       4450.0

Rows are Series too

Selecting a single row returns a Series, and a Series has one dtype - so a heterogeneous row is upcast to object. R keeps a single row as a data frame for exactly this reason.

cols = ["species", "year"]
penguins.loc[0, cols]
species    Adelie
year         2007
Name: 0, dtype: object
r = penguins.loc[[0], cols]
r
  species  year
0  Adelie  2007
r.dtypes
species      str
year       int64
dtype: object

Mutation

DataFrames are mutable - df["z"] = ... changes the object in place, and any other name bound to the same object sees the change (reference semantics). This is unlike R’s copy-on-modify, where the same code would leave d untouched. copy() makes an independent data frame, and methods such as assign() return a new one.

d = pd.DataFrame({"x": [1, 2, 3]})
d2 = d
d2["y"] = d2["x"] * 2
d
   x  y
0  1  2
1  2  4
2  3  6
d3 = d.copy()
d3["z"] = 1
d
   x  y
0  1  2
1  2  4
2  3  6

Updating selected rows

Use a single .loc[rows, column] = value assignment on the frame you want to change. Chained assignment such as df["x"][mask] = value cannot update the original frame under copy-on-write.

updated = penguins.copy()
updated["size"] = "other"
updated.loc[updated["body_mass_g"] > 5000, "size"] = "large"
updated.loc[updated["size"] == "large", ["body_mass_g", "size"]].head(3)
     body_mass_g   size
153       5700.0  large
155       5700.0  large
156       5400.0  large

Referring to columns

dplyr’s filter(df, x > 1) works because R captures the unevaluated expression. Python evaluates arguments eagerly and a bare x is a NameError, so pandas offers several workarounds, none of which are quite as convenient.

pg = penguins[["species", "island", "body_mass_g"]]

Explicit - reuse the object,

pg[pg["body_mass_g"] > 6000]
    species  island  body_mass_g
169  Gentoo  Biscoe       6300.0
185  Gentoo  Biscoe       6050.0

Callable - a function / lambda

pg[lambda d: d["body_mass_g"] > 6000]
    species  island  body_mass_g
169  Gentoo  Biscoe       6300.0
185  Gentoo  Biscoe       6050.0

String - DSL parsed by pandas,

pg.query("body_mass_g > 6000")
    species  island  body_mass_g
169  Gentoo  Biscoe       6300.0
185  Gentoo  Biscoe       6050.0

Expression - pd.col(),

pg[pd.col("body_mass_g") > 6000]
    species  island  body_mass_g
169  Gentoo  Biscoe       6300.0
185  Gentoo  Biscoe       6050.0

Method chaining

There is no pipe operator, but since nearly every method returns a new DataFrame the equivalent is method chaining. Callables and pd.col() are what let a step refer to columns of the intermediate result. Subsetting with [] can be a step in the chain like any other.

(penguins
  .query("species == 'Gentoo'")
  .assign(kg = pd.col("body_mass_g") / 1000)
  .sort_values("kg", ascending=False)
  [["species", "island", "kg"]]
  .head(3)
)
    species  island    kg
169  Gentoo  Biscoe  6.30
185  Gentoo  Biscoe  6.05
229  Gentoo  Biscoe  6.00

groupby

Split-apply-combine works the same way - groupby() returns a DataFrameGroupBy which is then used by subsequent aggregation calls. By default the group keys become the index of the result (as_index=False keeps them as columns, like .by). Named aggregations use (column, function) pairs.

g = penguins.groupby("species")
type(g)
<class 'pandas.api.typing.DataFrameGroupBy'>
g["body_mass_g"].mean()
species
Adelie       3700.662252
Chinstrap    3733.088235
Gentoo       5076.016260
Name: body_mass_g, dtype: float64
(penguins
  .groupby("species", as_index=False)
  .agg(
    n = ("species", "size"),
    mass = ("body_mass_g", "mean")
  )
)
     species    n         mass
0     Adelie  152  3700.662252
1  Chinstrap   68  3733.088235
2     Gentoo  124  5076.016260

Grouped transforms

transform() returns a result the same length as its input, broadcasting each group’s value back over that group’s rows - the equivalent of mutate() with .by. Since the result shares the frame’s index it can be used directly in arithmetic or assignment.

avg = g["body_mass_g"].transform("mean")
avg.head(3)
0    3700.662252
1    3700.662252
2    3700.662252
Name: body_mass_g, dtype: float64
penguins.assign(rel = pd.col("body_mass_g") / avg)[["species", "rel"]].head(3)
  species       rel
0  Adelie  1.013332
1  Adelie  1.026843
2  Adelie  0.878221

MultiIndex

Group by two keys and the result has a hierarchical row MultiIndex; aggregate a column two ways with the dictionary/list syntax and the columns get one too. The tidyverse usually keeps group keys and summaries as ordinary columns.

(penguins
  .groupby(["species", "island"])
  ["body_mass_g"].mean()
)
species    island   
Adelie     Biscoe       3709.659091
           Dream        3688.392857
           Torgersen    3706.372549
Chinstrap  Dream        3733.088235
Gentoo     Biscoe       5076.016260
Name: body_mass_g, dtype: float64
(penguins
  .groupby("species")
  .agg({
    "body_mass_g": ["mean", "max"]
  })
)
           body_mass_g        
                  mean     max
species                       
Adelie     3700.662252  4775.0
Chinstrap  3733.088235  4800.0
Gentoo     5076.016260  6300.0

Exercise 1

Using pandas and data/flights.parquet, answer the following. Questions 1 and 2 are the core; 3 and 4 are extensions if time allows.

  1. How many flights to LAX did each legacy carrier (AA, UA, DL, US) have in May from JFK, and what was their mean actual air time (air_time)? Report only carriers with matching records.

  2. Which plane (tailnum) has the most flight records from each New York airport? Exclude missing tail numbers and return all ties.

  3. Which five calendar dates (year, month, day) had the lowest mean departure delay? Skip missing delays and break ties by earliest date.

  4. Which flight has the largest arrival delay as a percentage of its actual air time (100 * arr_delay / air_time)? Exclude missing values and nonpositive air times, and return all ties.

Summary

R vs pandas

Concept R / tibble pandas
column atomic vector Series (values + index)
row labels row.names, rarely used Index, central
alignment by position, with recycling by index label
mixed types coerced to a common type object dtype
missing values NA in any type NaN / None / NaT / pd.NA by dtype
column references data masking strings, callables, pd.col()
mutation copy-on-modify in place, copy-on-write for subsets
grouped result keys as columns keys as (Multi)Index

Verbs

dplyr pandas
filter(x > 1) query("x > 1"), [pd.col("x") > 1]
select(x, y) [["x", "y"]], filter(regex=)
mutate(z = x * 2) assign(z = pd.col("x") * 2)
arrange(desc(x)) sort_values("x", ascending=False)
summarize(m = mean(x)) agg(m = ("x", "mean"))
group_by(g) groupby("g", as_index=False)
mutate(m = mean(x), .by = g) groupby("g")["x"].transform("mean")
across(where(is.numeric), f) select_dtypes("number").apply(f)
rename(new = old) rename(columns={"old": "new"})

Takeaways

  • A pandas Series is a typed array plus an index, and the index is what makes pandas different from R - lookup is by label, arithmetic and assignment align on labels, and group keys end up in the index. .loc and .iloc exist because [] cannot tell labels from positions.

  • pandas dtypes are messier than R’s - mixed data becomes object, and missing values are NaN, None, NaT, or pd.NA depending on the dtype, with NaN silently turning integer columns into floats. Use isna(), never ==, and remember that aggregations skip missing values by default.

  • Python has no data masking, so pandas refers to columns with strings, callables, or pd.col() and chains methods instead of piping. DataFrames are mutable, so aliases matter.