AI-110 · Lesson 5 of 12 · 32 min

Data frames with pandas

Most data that feeds AI systems arrives as tables: rows of records with named columns of different types. A table of customers has names (text), sign-up dates (dates), plans (categories) and monthly spend (numbers). A log of sensor readings has timestamps, device identifiers and measurements. NumPy arrays hold one type of number in a grid; they have no column names, no mixed types and no natural way to handle dates or missing values. For tables, Python has pandas.

Pandas provides the DataFrame, a table with labeled rows and columns in which each column can have its own type, and a large set of tools to load, clean, filter, combine, group, summarize and reshape such tables. Industry surveys consistently rank it among the most used tools in data science, and in practice a large share of any AI project's time goes into exactly this kind of data preparation. This chapter covers the operations you will use every day. The examples use pandas 2.2 or later; text columns appear as "object" in pandas 2 and as "str" in pandas 3, but everything shown works the same in both.

Series and DataFrames

Pandas has two main objects:

  • A Series is a one-dimensional array of values with an index (labels for each value). It is like a NumPy array with names attached, and it is what you get when you select one column of a table.
  • A DataFrame is a two-dimensional table: a collection of Series sharing the same index, one per column. Each column has a name and a dtype.

The index labels the rows. By default it is simply 0, 1, 2 and so on, but it can be anything meaningful, such as rack identifiers or timestamps. Labels let pandas align data automatically: when two Series are added, values are matched by label, not by position.

Anatomy of a DataFrame: a table with column names across the top (rack, hall, row, power_kw, inlet_c, status), an index down the left side (0 to 7), and one highlighted column labeled as a Series. Each column is annotated with its dtype: text, integer, text, float, float, text. One cell shows NaN, marked as a missing value.
A DataFrame is a set of named columns (each a Series with its own dtype) sharing one row index. NaN marks a missing value.

Loading data

Pandas reads data from many formats: pd.read_csv for comma-separated text, pd.read_excel for spreadsheets, pd.read_json, pd.read_parquet for the efficient columnar format used in data engineering, and pd.read_sql for databases. CSV is the most common. The example below reads a small CSV of rack measurements from a string (via io.StringIO, which makes a string behave like a file) so that the program is self-contained; with a real file you would write pd.read_csv("racks.csv").

Reading a CSV and taking a first look
import io
import pandas as pd

csv_text = """rack,hall,row,power_kw,inlet_c,status
A01,1,A,11.8,23.1,ok
A02,1,A,12.6,24.0,ok
A03,1,A,,23.7,offline
B01,1,B,15.2,26.8,ok
B02,1,B,14.9,27.4,warning
C01,2,C,9.4,21.9,ok
C02,2,C,10.1,22.5,ok
C03,2,C,18.7,28.9,warning
"""
df = pd.read_csv(io.StringIO(csv_text))
print(df.shape)
print(df.head(3))
print(df["power_kw"].describe().round(2))
Output
(8, 6)
  rack  hall row  power_kw  inlet_c   status
0  A01     1   A      11.8     23.1       ok
1  A02     1   A      12.6     24.0       ok
2  A03     1   A       NaN     23.7  offline
count     7.00
mean     13.24
std       3.25
min       9.40
25%      10.95
50%      12.60
75%      15.05
max      18.70
Name: power_kw, dtype: float64

The first look at any new data set follows the same routine: df.shape for the number of rows and columns, df.head() to see a few rows, df.info() for column types and non-missing counts, and df.describe() for summary statistics of numeric columns. Notice that read_csv did several things automatically. It used the first line as column names, worked out that hall is an integer and power_kw a float, and turned the empty power_kw value for rack A03 into NaN ("not a number"), pandas' marker for a missing value. The count of 7 in describe() shows that one of the eight values is missing and was left out of the statistics.

Selecting, filtering and new columns

Pandas offers several ways to select data, and it is worth learning the clear, explicit ones:

  • df["power_kw"] selects one column as a Series; df[["rack", "power_kw"]] (note the double brackets) selects several columns as a DataFrame.
  • df.loc[rows, columns] selects by label: df.loc[3, "inlet_c"], or df.loc[df["hall"] == 2, ["rack", "inlet_c"]].
  • df.iloc[rows, columns] selects by integer position, like NumPy: df.iloc[0:3, 1:4].
  • A Boolean mask in brackets filters rows: df[df["inlet_c"] > 25].

New columns are created by assigning a vectorized expression, exactly as with NumPy arrays.

Filtering rows, adding a column and sorting
import io
import pandas as pd

df = pd.read_csv(io.StringIO("""rack,hall,row,power_kw,inlet_c,status
A01,1,A,11.8,23.1,ok
A02,1,A,12.6,24.0,ok
A03,1,A,,23.7,offline
B01,1,B,15.2,26.8,ok
B02,1,B,14.9,27.4,warning
C01,2,C,9.4,21.9,ok
C02,2,C,10.1,22.5,ok
C03,2,C,18.7,28.9,warning
"""))
hot = df[df["inlet_c"] > 25]                      # filter rows with a mask
print(hot[["rack", "power_kw", "inlet_c"]])
df["inlet_f"] = df["inlet_c"] * 9 / 5 + 32        # new column, vectorized
print(df.loc[df["hall"] == 2, ["rack", "inlet_f"]])
print(df.sort_values("power_kw", ascending=False).head(3)["rack"].tolist())
Output
  rack  power_kw  inlet_c
3  B01      15.2     26.8
4  B02      14.9     27.4
7  C03      18.7     28.9
  rack  inlet_f
5  C01    71.42
6  C02    72.50
7  C03    84.02
['C03', 'B01', 'B02']

Filtering keeps the original index labels (3, 4 and 7), which tells you where each row came from. Conditions combine with & and |, with parentheses around each one, as in NumPy: df[(df["hall"] == 1) & (df["status"] != "ok")]. The method df.query("inlet_c > 25 and hall == 1") expresses the same thing as a readable string.

Grouping and aggregation

Many questions about data take the form "for each group, compute something": total power per row, average temperature per hall, number of alarms per day. Pandas answers them with groupby, which follows a pattern called split-apply-combine: split the rows into groups by the values of one or more columns, apply a summary function to each group, and combine the results into a new table.

Split-apply-combine diagram. A table of eight racks in rows A, B and C is split into three smaller tables by row. Each smaller table is summed, giving 24.4, 30.1 and 38.2 kilowatts. The three sums are combined into a result table indexed by row.
groupby splits the rows by a key, applies a summary to each group and combines the results.
Group summaries and counts
import io
import pandas as pd

df = pd.read_csv(io.StringIO("""rack,hall,row,power_kw,inlet_c,status
A01,1,A,11.8,23.1,ok
A02,1,A,12.6,24.0,ok
A03,1,A,,23.7,offline
B01,1,B,15.2,26.8,ok
B02,1,B,14.9,27.4,warning
C01,2,C,9.4,21.9,ok
C02,2,C,10.1,22.5,ok
C03,2,C,18.7,28.9,warning
"""))
by_row = df.groupby("row").agg(racks=("rack", "count"),
                               total_kw=("power_kw", "sum"),
                               max_inlet=("inlet_c", "max"))
print(by_row)
print(df.groupby("hall")["power_kw"].mean().round(2))
print(df["status"].value_counts())
Output
     racks  total_kw  max_inlet
row
A        3      24.4       24.0
B        2      30.1       27.4
C        3      38.2       28.9
hall
1    13.62
2    12.73
Name: power_kw, dtype: float64
status
ok         5
warning    2
offline    1
Name: count, dtype: int64

The agg method with named aggregations (new_name=(column, function)) produces tidy, clearly labeled results. Note that row A has 3 racks but its total uses only the two with readings, because sum skips NaN. The hall 1 mean of 13.62 kW is likewise the average of the four racks with readings, not five. value_counts is the quickest way to see how often each category occurs, and it is one of the first things to run on any categorical column.

Worked problem

Reading a group summary carefully

Using the rack table, a manager reads the summary above and says: "Hall 1 averages 13.6 kW per rack across 5 racks, so its total load is 68 kW." What is wrong, and what is the correct total?

Show solution
  1. Hall 1 has 5 racks, but A03 is offline with no reading (NaN), so the mean of 13.62 kW is over 4 racks.
  2. Correct total of measured load: \(11.8 + 12.6 + 15.2 + 14.9 = 54.5\) kW.
  3. Multiplying the 4-rack mean by 5 racks invents a reading for A03 equal to the average: \(13.62 \times 5 = 68.1\) kW.
  4. The right question is what A03 actually draws. If it is truly offline, its load is near zero and the hall total is 54.5 kW.

Answer: The total is 54.5 kW of measured load, not 68 kW. Group statistics silently skip missing values, so always check counts (df.groupby("hall")["power_kw"].count()) alongside means and sums.

Missing data

Missing values are the norm in real data: sensors fail, forms are left incomplete, systems are offline. Pandas represents them as NaN for numbers (and NaT for missing dates), and provides tools to find and handle them (Lesson 6 discusses how to decide which to use):

  • df.isna() returns a mask of missing values; df.isna().sum() counts them per column.
  • df.dropna() removes rows with missing values (subset= restricts which columns to check).
  • df.fillna(value) replaces missing values, for example with a constant, a column mean or the previous value (ffill).
  • s.interpolate() estimates missing values in a series from their neighbors, which suits smoothly varying measurements over time.
Counting, skipping, filling and dropping missing values
import io
import pandas as pd

df = pd.read_csv(io.StringIO("""rack,hall,row,power_kw,inlet_c,status
A01,1,A,11.8,23.1,ok
A02,1,A,12.6,24.0,ok
A03,1,A,,23.7,offline
B01,1,B,15.2,26.8,ok
"""))
print(df["power_kw"].isna().sum(), "missing")
print(round(df["power_kw"].mean(), 2))             # NaN is skipped
print(round(df["power_kw"].fillna(0).mean(), 2))   # treating missing as zero
print(df.dropna(subset=["power_kw"])["rack"].tolist())
Output
1 missing
13.2
9.9
['A01', 'A02', 'B01']

The two means differ by a third, which shows why the choice matters. There is no universally right answer: it depends on why the data is missing. If the rack is offline, zero is correct for energy totals; if its meter failed while the rack ran normally, zero badly understates the load and skipping or interpolating is better. Whatever you choose, make it a deliberate decision, write it down, and check how much it changes the results. In machine learning, how missing values are filled can also leak information or bias a model, a topic covered in AI-220.

Combining tables

Data usually lives in several tables that must be joined: measurements in one, equipment details in another, customer information in a third. The merge method joins two DataFrames on matching key columns, like a database join:

  • how="inner" keeps only rows whose key appears in both tables.
  • how="left" keeps every row of the left table, filling NaN where the right table has no match.
  • how="outer" keeps every row from both.
A left join between racks and customers
import pandas as pd

racks = pd.DataFrame({"rack": ["A01", "A02", "B01", "C01"],
                      "customer_id": [7, 7, 12, 31],
                      "power_kw": [11.8, 12.6, 15.2, 9.4]})
customers = pd.DataFrame({"customer_id": [7, 12, 40],
                          "name": ["Northwind", "Bluebird AI", "Cedar Labs"]})
merged = racks.merge(customers, on="customer_id", how="left")
print(merged)
print(merged.groupby("name")["power_kw"].sum())
Output
  rack  customer_id  power_kw         name
0  A01            7      11.8    Northwind
1  A02            7      12.6    Northwind
2  B01           12      15.2  Bluebird AI
3  C01           31       9.4          NaN
name
Bluebird AI    15.2
Northwind      24.4
Name: power_kw, dtype: float64

Rack C01 belongs to customer 31, who is missing from the customer table, so the left join gives it a NaN name, and the groupby then drops it from the totals without any warning. A careful analyst checks for unmatched keys after every merge (for example with merged["name"].isna().sum()) because a silent loss of rows like this is one of the most common causes of wrong numbers in reports. The validate argument of merge can also enforce expectations, such as each rack matching at most one customer. pd.concat, by contrast, stacks tables with the same columns on top of each other, for example a month of daily files.

Time series

Measurements over time are central to engineering and AI: sensor logs, energy use, transactions, web traffic. Pandas has strong support for them. pd.to_datetime converts text to timestamps; pd.date_range creates regular sequences of times; and a Series or DataFrame with a DatetimeIndex gains time-aware tools. resample changes the frequency (hourly to daily, for example) with an aggregation, and rolling computes moving-window statistics such as a 24-hour moving average.

Resampling and rolling windows on simulated hourly load
import numpy as np
import pandas as pd

rng = np.random.default_rng(1)
times = pd.date_range("2026-07-01", periods=7 * 24, freq="h")    # one week, hourly
hours = times.hour.to_numpy()
load = 800 + 120 * np.sin((hours - 9) / 24 * 2 * np.pi) + rng.normal(0, 15, len(times))
s = pd.Series(load, index=times, name="it_load_kw")

daily = s.resample("D").agg(["mean", "max"]).round(1)
print(daily.head(3))
print(s.rolling(24).mean().dropna().round(1).iloc[:2])
print("peak hour:", s.idxmax())
Output
             mean    max
2026-07-01  801.7  929.0
2026-07-02  796.2  921.6
2026-07-03  799.3  918.9
2026-07-01 23:00:00    801.7
2026-07-02 00:00:00    799.8
Freq: h, Name: it_load_kw, dtype: float64
peak hour: 2026-07-07 16:00:00

The program simulates a week of hourly IT load with a daily cycle (peaking in the afternoon) plus random noise. Resampling to daily frequency gives each day's mean and peak. The 24-hour rolling mean needs 24 values before it can produce its first result, so the first 23 entries are NaN, which dropna removes; the first full window ends at 23:00 on 1 July. Rolling means smooth out the daily cycle to reveal longer trends, a standard first step before forecasting.

Worked problem

From readings to an energy bill

A meter records power every 15 minutes in a Series s (kW) with a DatetimeIndex covering a 30-day month. Describe pandas operations that give (a) the energy each day in kWh, (b) the month's peak 15-minute demand and when it happened, and (c) how many 15-minute intervals are missing.

Show solution
  1. (a) Each reading represents 0.25 h, so energy per reading is s * 0.25 kWh. Daily energy: (s * 0.25).resample("D").sum().
  2. (b) Peak demand: s.max(); its timestamp: s.idxmax().
  3. (c) A complete month has \(30 \times 96 = 2880\) intervals. Missing count: s.isna().sum() plus any timestamps absent entirely, found by comparing len(s) with 2880 (or reindexing to a full pd.date_range).

Answer: (s * 0.25).resample("D").sum() for daily kWh; s.max() and s.idxmax() for the peak; 2880 − s.count() for missing intervals, after reindexing to the full range so absent rows count too. Missing intervals must be handled before the daily sums are trusted.

Tidy data and method chaining

Analysis is easiest when data is "tidy": each variable is a column, each observation is a row, and each kind of observational unit is its own table. A spreadsheet with one column per month is not tidy, because the month is a variable hiding in the column names; df.melt reshapes it into long form with a month column and a value column, and df.pivot_table reshapes the other way for reports. Pandas code is also often written as a chain of methods, one per line, which reads like a recipe:

  • (df.query("status == 'ok'").groupby("row")["power_kw"].sum().sort_values(ascending=False))

Each step returns a new DataFrame, so the original is unchanged, and the chain documents exactly how a result was produced.

Performance and scale

Pandas keeps the whole table in memory and works best on data up to a few gigabytes. As with NumPy, vectorized column operations are fast and row-by-row loops (iterrows, or apply with a Python function) are slow, so prefer column expressions. For larger data, the Parquet file format loads faster and smaller than CSV, and libraries such as Polars, DuckDB and Spark handle data sets that are too large or too slow for pandas while keeping similar ideas.

Common mistakes

  • MistakeTrusting means and sums without checking how many values were missing.

    InsteadCheck counts with .count() or .isna().sum(); aggregations skip NaN silently.

  • MistakeFilling every missing value with zero by habit.

    InsteadDecide based on why the data is missing (offline, failed meter, not applicable) and record the choice.

  • MistakeMerging tables without checking for unmatched keys.

    InsteadAfter a merge, look for NaN in the joined columns and compare row counts; use validate= to enforce expectations.

  • MistakeLooping over rows with iterrows to compute a new column.

    InsteadUse vectorized column expressions, which are far faster.

  • MistakeSelecting multiple columns with single brackets, df["a", "b"].

    InsteadUse a list inside the brackets: df[["a", "b"]].

Summary and key terms

  • A DataFrame is a table of named columns (Series), each with its own dtype, sharing a row index.
  • Start every data set with shape, head, info and describe, and check what read_csv inferred.
  • Select with df["col"], df[["a", "b"]], .loc (labels) and .iloc (positions); filter rows with Boolean masks or query.
  • groupby follows split-apply-combine; named aggregations with agg give clear summary tables.
  • Missing values (NaN) are skipped by aggregations; handling them is a deliberate, documented decision.
  • merge joins tables on keys (inner, left, outer); always check for unmatched keys.
  • Time series tools include to_datetime, date_range, resample and rolling.
  • Prefer tidy data, method chains and vectorized operations; move to Parquet, Polars or DuckDB when data outgrows pandas.
Series
A one-dimensional labeled array; one column of a DataFrame.
DataFrame
A two-dimensional table of labeled columns that may have different types.
Index
The labels of a Series or of the rows of a DataFrame.
NaN
"Not a number": pandas' and NumPy's marker for a missing numeric value.
.loc / .iloc
Selection by label and by integer position.
Boolean indexing
Selecting rows with a True/False mask.
groupby
Splitting rows into groups by key values to summarize each group.
Split-apply-combine
The pattern of splitting data into groups, applying a function and combining the results.
Aggregation
Reducing many values to one, such as a sum, mean, count or maximum.
merge (join)
Combining two tables by matching key columns.
Left join
A join that keeps every row of the left table, filling missing matches with NaN.
DatetimeIndex
An index of timestamps that enables time-based selection and resampling.
Resample
Changing the frequency of a time series, aggregating or filling as needed.
Rolling window
A moving window over consecutive values used to compute statistics such as a moving average.
Tidy data
A layout with one variable per column, one observation per row and one unit type per table.
Parquet
A compressed, column-oriented file format for efficient storage and loading of tables.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write the code to load "sensors.csv", treating the text "error" as missing, and show the number of missing values in each column.

    Show answer

    df = pd.read_csv("sensors.csv", na_values=["error"]); df.isna().sum().

  2. Using the rack table, which racks are in hall 1 with status other than "ok"?

    Show answer

    df[(df["hall"] == 1) & (df["status"] != "ok")] gives A03 (offline) and B02 (warning).

  3. What is the difference between df.loc[2] and df.iloc[2]?

    Show answer

    loc selects the row whose index label is 2; iloc selects the third row by position. They differ whenever the index is not 0, 1, 2…, for example after filtering or sorting.

  4. Write a groupby that gives, for each status, the number of racks and the mean inlet temperature.

    Show answer

    df.groupby("status").agg(racks=("rack", "count"), mean_inlet=("inlet_c", "mean")).

  5. Compute the mean inlet temperature of the rack table by hand (8 racks).

    Show answer

    Sum = 23.1 + 24.0 + 23.7 + 26.8 + 27.4 + 21.9 + 22.5 + 28.9 = 198.3; mean = 24.79 °C.

  6. A left join of 1000 sensor readings to a table of 40 sensors gives 1000 rows, but 35 rows have NaN in the sensor location. What does that tell you, and what should you do?

    Show answer

    35 readings come from sensor IDs missing from the sensor table. Investigate (new sensors, typos, decommissioned devices) and fix or document them before analyzing by location.

  7. A Series of minute-by-minute temperatures needs hourly averages and a 3-hour moving average of those. Write the code.

    Show answer

    hourly = s.resample("h").mean(); smooth = hourly.rolling(3).mean().

  8. A table has columns site, jan, feb, mar with monthly energy. How do you make it tidy?

    Show answer

    df.melt(id_vars="site", var_name="month", value_name="energy_kwh"), giving one row per site and month.

  9. Readings [10, NaN, 14] are interpolated linearly. What value replaces NaN?

    Show answer

    12, halfway between its neighbors.

  10. Why is df["power_w"] = df["power_kw"] * 1000 preferred to looping over rows with iterrows?

    Show answer

    The vectorized expression runs in compiled code on the whole column and is much faster and clearer; iterrows processes rows one at a time in Python.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz