DS 208 · Week 6

Data Manipulation with pandas I

The DataFrame — a spreadsheet you drive with code. Loading a file, then selecting and filtering to the rows and columns you need.

Programming for Data Science · University of the Philippines Cebu

Session Map

From a file to the rows you want

Where We Left Off

Arrays are fast — but nameless

NumPy gave you speed, but column 2 means nothing to a reader. A DataFrame keeps that speed and adds names and an index so the data explains itself.

A Series

One labelled column — a NumPy array with an index (a label for each row).

A DataFrame

Many Series side by side — the whole table.

Words for Today

Ten words you will hear this week

DataFrame

pandas’ table: rows and named columns, like a spreadsheet you control with code.

e.g. df = pd.read_csv("cities.csv")

Series

One column of a DataFrame: a NumPy array plus a label for each row.

e.g. df["pop"]

column

One named field that every row has — a vertical strip of the table.

e.g. city, region, pop

row

One record: all the values about one thing (one city, one project).

e.g. Cebu · Visayas · 964,000

index (label)

The labels down the left side that name each row. By default 0, 1, 2… and they stay with their row.

e.g. df.loc[0] is the row labelled 0

dtype

The type of one column. object is pandas’ word for text.

e.g. pop is int64, city is object

.loc / .iloc

Two ways to pick rows and columns: .loc by label, .iloc by position (counting from 0).

e.g. df.loc[0, "city"], df.iloc[0, 0]

boolean filter

A True/False Series used inside df[ ] to keep only the rows where it is True.

e.g. df[df["pop"] > 1_000_000]

NaN (missing value)

“Not a Number”: pandas’ marker for an empty cell. Summaries skip it; you count it with .isna().

e.g. a project with no start date

method chaining

Writing several methods in a row, each working on the result of the one before.

e.g. df.sort_values("pop").head(3)

Part A

The DataFrame

Load a file; look before you leap.

Last week you did maths on whole arrays. pandas wraps those arrays in a table with column names and row labels — a spreadsheet you control with code.

One Line To Load

read_csv does the heavy lifting

A CSV file (comma-separated values) is a plain-text table: one row per line, commas between the columns. Point pandas at one and it parses the header (the first line of column names), infers each column's type, and hands back a DataFrame — a table of rows and named columns. .head() shows the first five rows.

import pandas as pd df = pd.read_csv("cities.csv") df.head() # first 5 rows df.shape # (16, 3)
  • import pandas as pdload pandas under its usual nickname pd
  • df = pd.read_csv("cities.csv")read the file into a DataFrame and call it df (short for data frame)
  • df.head()a method: show the first 5 rows as a table
  • df.shapean attribute (no brackets): (16, 3) = 16 rows, 3 columns
Look Before You Compute

Four checks on every new dataset

Names, types, size, and a summary — before any analysis. Wrong types here cause every bug later, exactly as in the Data Preparation phase of DS 227, the companion course.

df.columns # ['city','region','pop'] df.dtypes # pop int64, city object df.info() # types + missing counts df.describe() # mean, min, max...
  • df.columnsthe column names, in order
  • df.dtypeseach column’s type: int64 = whole numbers, object = text
  • df.info()types plus how many values are present in each column, so missing ones show up
  • df.describe()count, mean, spread, min, quartiles (the 25/50/75% marks) and max of every numeric column
The Second Thing You Run

.info() tells you types and memory in one call

.head() shows you the values. .info() shows you what pandas thinks they are — which is what every later bug comes from.

Read the Dtype column first

Anything numeric showing object is text. Sorting, summing and comparing will all be wrong, and none of them will raise.

df.info() RangeIndex: 34079 entries, 0 to 34078 Data columns (total 15 columns): # Column Non-Null Count Dtype 0 contract_id 34079 non-null object 4 budget 34079 non-null float64 8 start_date 33106 non-null object <- 10 year 34079 non-null int64 memory usage: 3.9+ MB # excerpt: 4 of the 15 column rows
  • RangeIndex: 34079 entries, 0 to 3407834,079 rows, labelled 0 to 34,078
  • Non-Null Counthow many cells in that column are filled in: start_date has 33,106, so 973 are missing
  • start_date … object <-the dates are stored as text, not as dates — the arrow marks a column to fix (two slides on). budget is float64: already numbers
  • memory usage: 3.9+ MBa quick estimate of the computer memory the table takes (MB = millions of bytes); the + means text was only partly counted
Types Cost Memory

Three columns as category, 7.3 MB back

An object column stores a separate Python string per row. A category column stores each distinct value once, plus small integer codes.

Worth it when values repeat

18 regions across 34,079 rows means each region name is stored 1,893 times as an object — and once as a category.

df.memory_usage(deep=True).sum() / 1e6 28.484198 # MB, as loaded for c in ["status", "region", "category"]: df[c] = df[c].astype("category") df.memory_usage(deep=True).sum() / 1e6 21.147474 # 26% smaller # and groupby gets faster too
  • df.memory_usage(deep=True).sum() / 1e6total bytes used by every column (deep=True counts the text fully), divided by a million = megabytes: 28.5 MB
  • df[c] = df[c].astype("category").astype converts a column to another dtype; the loop does it for three columns
  • # and groupby gets faster toogroupby = summarising per group (e.g. per region) — next week’s topic
Fix The Types Once

Convert at load, not in every cell afterwards

A NaN (“Not a Number”) is pandas’ marker for a missing value. Convert the types immediately after reading the file. Every line you write after that point can then assume the types are right.

errors="coerce" makes failures countable

Bad values become NaN instead of raising, so you can count them and go look rather than guessing.

df = pd.read_csv(path) df["budget"] = pd.to_numeric(df.budget, errors="coerce") df["start_date"] = pd.to_datetime( df.start_date, errors="coerce") # how many refused? df.budget.isna().sum(), df.start_date.isna().sum() (0, 973) # 973 = the rows that were already blank
  • df.budgeta shortcut for df["budget"]; it works when the column name has no spaces
  • pd.to_numeric(…, errors="coerce")turn text into numbers; anything that is not a number becomes NaN instead of crashing
  • pd.to_datetime(…)the same for dates: text becomes a real date type (datetime64)
  • .isna().sum().isna() is True where a value is missing; summing counts them: (0, 973)
The Mental Model

An index down the side, names across the top

index city region pop 0 Cebu Visayas 964,000 1 Davao Mindanao 1,777,000 2 Iloilo Visayas 457,000 one column = a Series · one row = a record
Part B

Selecting

Columns, and rows by label or position.

You know the parts of a DataFrame. This part pulls pieces out — one column, several columns, or rows by label or position — like list indexing from week 3, but with names.

Picking Columns

One bracket or two

One name in single brackets gives a Series. A list of names in double brackets gives a smaller DataFrame. The double bracket is a list inside the select.

df["pop"] # Series (one column) df[["city", "pop"]] # DataFrame (two columns)
  • df["pop"]one name in the brackets: one column back, as a Series
  • df[["city", "pop"]]the inner brackets are a Python list of names; a list in → a smaller DataFrame out
Two Ways To Reach Rows

.loc by label, .iloc by position

.loc.iloc
Reaches byindex labelinteger position
First rowdf.loc[0]df.iloc[0]
A slicedf.loc[0:2] — inclusivedf.iloc[0:2] — exclusive
Accepts a maskyes — for filteringno
loc vs iloc

One reads labels, the other counts positions

# .loc — by LABEL df.loc[0, "region"] 'Region V' df.loc[0:2, ["region","budget"]] rows 0,1,2 — INCLUSIVE # label slices include the end
# .iloc — by POSITION df.iloc[0, 11] 'Region V' df.iloc[0:2, [11,4]] rows 0,1 — EXCLUSIVE # position slices stop before the end
The Index Is Not A Row Number

It is a label, and it survives filtering

After filtering, the index keeps the original labels. This is a feature — you can always trace a row back — but it surprises people who expect 0, 1, 2.

reset_index(drop=True) when you want fresh numbering

Leave off drop=True and the old index is kept as a new column, which is occasionally what you want and usually not.

big = df[df.budget > 1e9] big.index Index([18777, 28049, 33185], dtype='int64') # their positions in the ORIGINAL big.iloc[0] # works — 1st row big.loc[0] # KeyError — no label 0 big = big.reset_index(drop=True) big.index RangeIndex(start=0, stop=3, step=1)
  • big = df[df.budget > 1e9]keep the rows with a budget over one billion
  • Index([18777, 28049, 33185], …)the three rows kept their original labels (int64 = the labels are whole numbers)
  • big.loc[0]raises KeyError: the last line would read KeyError: 0 — no row is labelled 0 any more
  • reset_index(drop=True)give the rows fresh labels 0, 1, 2 and throw the old ones away (stop=3 means up to, not including, 3)
Rows In Practice

One row, or the rows that match

Grab a single row by its label, or hand .loc a condition to keep every row where it's true. That second form is filtering — Part C.

df.loc[0] # first row (label 0) df.iloc[-1] # last row (position) df.loc[df["city"] == "Cebu"] # the Cebu row
  • df.loc[0]the row labelled 0, returned as a Series whose labels are the column names
  • df.iloc[-1]the row in the last position (-1 counts from the end, as with lists)
  • df.loc[df["city"] == "Cebu"]a True/False Series inside .loc keeps only the matching rows
Quick Check

Tap to reveal

What type does df["pop"] return?

A · a DataFrame
B · a Series
C · a list
D · a single number

B — a Series.

Single brackets with one name give a Series. For a one-column DataFrame instead, use double brackets: df[["pop"]].

Break

  Five minutes

Back to keep only the rows that answer your question.

Part C

Filtering

Keep the rows that match a condition.

Last week you kept array elements with a boolean mask. The same move works on a DataFrame — it keeps whole rows.

The Same Mask, On Rows

A condition selects rows

Boolean filtering keeps rows by a rule. A comparison on a column gives a True/False Series. Put it in the brackets and pandas keeps the rows where it's True — the NumPy mask, on a table.

df["pop"] > 1_000_000 # True/False per row df[df["pop"] > 1_000_000] # only the big cities
  • df["pop"] > 1_000_000one True/False per row; the underscores in 1_000_000 are only there to make it readable
  • df[df["pop"] > 1_000_000]that Series inside df[ ]: keep the rows marked True, drop the rest

It is Excel’s filter button, except the rule is written down, so anyone can re-run it and get the same rows.

.str

Every string method, applied down a column

.str is an accessor: a doorway to a group of methods for one kind of column (text). Put .str in front and the familiar Python string methods work on all 34,079 rows at once — and skip NaN instead of crashing.

na=False on contains

Without it, missing values come back as NaN rather than False, and using the result as a mask raises.

df.description.str.contains("FLOOD", case=False, na=False).sum() 22,022 # 65% of all projects df.description.str.contains("REVETMENT", na=False).sum() 2,782 df.description.str.len().mean() 139.43490125883974 # chars # also: .lower() .strip() .split() # .replace() .startswith()
  • .str.contains("FLOOD", case=False, na=False)True where the description mentions flood, in any capitals; missing descriptions count as False
  • .sum()True counts as 1, so the sum is how many rows matched: 22,022
  • .str.len().mean()the length of every description, then their average: about 139 characters
.dt

The same idea for dates

Once a column is datetime64, .dt gives you year, month, weekday and the rest — each as a normal column you can group by.

Only works after conversion

On an object column .dt raises. That error is usually telling you the pd.to_datetime step is missing.

df.start_date.min(), df.start_date.max() 2015-05-17 2025-09-23 df.start_date.dt.month.value_counts().sort_index() 1 1054 2 9416 <- 3 7122 <- 4 3972 ... 12 1041
  • df.start_date.min(), .max()the earliest and latest start dates in the table
  • .dt.monththe month number (1–12) of each date
  • .value_counts().sort_index()method chaining: count each month, then order the result by month number instead of by count
Read That Again

Half the country’s flood projects start in February or March

MonthProjects startedShare
February9,41628.4%
March7,12221.5%
April3,97212.0%
every other month12,59638.0%
More Than One Rule

Combine with & and |

Use & for and, | for or — not the words and/or. Wrap each condition in parentheses, or precedence bites you.

big = df["pop"] > 500_000 vis = df["region"] == "Visayas" df[big & vis] # big AND Visayan df[big | vis] # big OR Visayan
  • big = df["pop"] > 500_000store the first rule as a True/False Series called big
  • vis = df["region"] == "Visayas"the second rule; == asks “is it equal?”
  • df[big & vis]& = and: rows where both are True
  • df[big | vis]| (the vertical bar key) = or: rows where at least one is True
The Two-Part Trap

Why and and missing parentheses fail

The Rule That Changed

A filtered frame is its own copy

pandas 3.0 made Copy-on-Write permanent. The old SettingWithCopyWarning is gone.

So far you have only read from a DataFrame. This part is about changing one. Copy-on-Write means every filtered result behaves as its own copy. (The in-browser labs still run pandas 2.2, so you may meet the old warning there.)

What Happens Now

Editing a filter never touches the original

Filter, assign, and the frame you filtered from is untouched. No warning, no ambiguity — the copy is guaranteed.

Filtering is photocopying the pages you need: scribbling on the photocopy never changes the book. To change the book, write in it directly — that is the next slide’s .loc.

Verified on pandas 3.0.5

Under pandas 2.x this same code printed a SettingWithCopyWarning and might or might not have written through. That uncertainty is what 3.0 removed.

sub = df[df.g == "x"] sub["a"] = 99 sub.a.tolist() [99, 99] df.a.tolist() [1, 2, 3, 4] # UNCHANGED # zero warnings raised
  • dfa tiny 4-row frame with a text column g and a number column a
  • sub = df[df.g == "x"]the rows where g is "x" (two of them), as a new frame
  • sub["a"] = 99set column a to 99 — in sub only
  • .tolist()turn a column into a plain Python list for printing; df.a is still [1, 2, 3, 4]
So How Do You Edit?

Assign through .loc on the frame you mean to change

If the goal is to change the original, say so explicitly: one .loc with the mask and the column, on df itself.

One statement, not two

Filtering into a variable and editing that variable now provably does nothing to the original. That is safer, but only if you know it.

# does NOT change df (it is a copy) sub = df[df.g == "x"] sub["a"] = 99 # DOES change df df.loc[df.g == "x", "a"] = 77 df.a.tolist() [77, 77, 3, 4] # mask and column in ONE .loc
  • df.loc[df.g == "x", "a"] = 77rows where the mask is True, column a, on df itself: set to 77
  • [77, 77, 3, 4]the two "x" rows changed in the original
If You See The Old Warning

You are on pandas 2.x — the advice still applies

Answer In One Call

Count categories, summarise numbers

value_counts() tallies a text column; .mean() and describe() summarise numbers. Chain them onto a filtered frame to answer real questions.

df["region"].value_counts() # Visayas 7, Luzon 5... df["pop"].mean() # 612_500
  • df["region"].value_counts()how many rows have each region, most common first
  • df["pop"].mean()the average population across the rows
Counting Categories

value_counts, and the argument that makes it readable

Raw counts are hard to compare across datasets. normalize=True gives proportions instead.

Percentages beat counts for claims

"80.8% of projects are completed" travels. "27,534 projects" makes the reader do the division.

df.status.value_counts() Completed 27534 On-Going 5442 For Procurement 876 df.status.value_counts(normalize=True) Completed 0.8079 On-Going 0.1597 For Procurement 0.0257 # 80.8% — the honest headline figure
  • df.status.value_counts()a Series: each status (as the label) with its count
  • normalize=Truegive fractions of the total instead: 0.8079 = 80.79% of projects
Sorting And Top-N

nlargest says what you mean

sort_values(...).head(n) is method chaining — sort, then take the first n — and it sorts all 34,079 rows to show you three. nlargest does not.

Keep the other columns

Sorting a DataFrame keeps whole rows, so you still have the region and the description next to the number.

df.nlargest(3, "budget")[["budget", "region"]] budget region 1,447,499,996 Central Office 1,096,255,230 Central Office 1,042,782,802 Central Office # ascending, for the other end df.nsmallest(3, "budget")
  • df.nlargest(3, "budget")the 3 whole rows with the biggest budget
  • [["budget", "region"]]then keep just two columns to display
  • df.nsmallest(3, "budget")the other end: the 3 smallest budgets
query()

The same filter, in words

A readability choice, not a speed one. Long boolean expressions get hard to scan; query keeps them close to the sentence you would say out loud.

Note what disappears

No repeated df., no brackets around every clause. Use @name to refer to a Python variable.

# bracket form df[(df.region == "Region III") & (df.budget > 1e8) & (df.status == "Completed")] # query form — same result df.query("region == 'Region III' " "and budget > 1e8 " "and status == 'Completed'") # with a variable floor = 1e8 df.query("budget > @floor")
  • df.query("region == 'Region III' and …")the filter written as one string; inside it, and is allowed
  • "…" "…"Python joins strings that sit side by side, so a long condition can span lines
  • @floor@ means “the Python variable called floor”, not a column
and vs &

The most confusing error message in pandas

Python’s and wants a single True/False. A pandas mask is 34,079 of them, so it cannot decide — and says so in a way that reads like nonsense until you have seen it once.

Two rules

Use & | ~, and parenthesise every clause — & binds tighter than >.

# wrong df[df.budget > 1e8 and df.status == "Completed"] ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all() # right — note the parentheses df[(df.budget > 1e8) & (df.status == "Completed")] # also wrong: missing parens df[df.budget > 1e8 & df.status == ...] # & runs first, on the numbers
  • ValueError: The truth value of a Series is ambiguousread the last line: and asked the whole Series for one True/False, and pandas cannot choose one
  • Use a.empty, a.bool(), a.item(), a.any() or a.all()Python’s suggestions are not what you want here — the fix is & with parentheses
  • df[(…) & (…)]each condition in its own brackets, joined by &
describe() On Text

It answers different questions per dtype

On numbers you get quartiles. On an object or category column you get the four things that actually matter for text.

unique is the one to read

18 unique regions is a groupable column. 34,079 unique descriptions is free text — treat it with .str, not groupby.

df.region.describe() count 34079 unique 18 top Region III freq 5412 # 5412/34079 = 16% in one region df.describe() # numeric only, by default df.describe(include="all") # everything
  • counthow many values are present (not missing)
  • uniquehow many different values: 18 regions
  • top / freqthe most common value and how many times it appears
Filling Gaps

fillna takes a value, a method, or a rule

fillna replaces missing values (NaN) with something you choose. Whether to fill at all is a judgement call (DS 227, the companion course, debates it). This slide is how — and the choice of fill value is itself a claim about the data.

Never fill a date with 0

Or a category with the mean. A fill must be a plausible member of that column, or it will distort every summary that follows.

# a constant df.contractor.fillna("Not yet awarded") # a computed value df.budget.fillna(df.budget.median()) # per group — usually more honest df.budget.fillna( df.groupby("region").budget.transform("median")) # count what you changed before = df.budget.isna().sum()
  • fillna("Not yet awarded")every missing contractor gets the same text
  • fillna(df.budget.median())missing budgets get the middle budget of the whole table
  • groupby("region").budget.transform("median")each missing budget gets its own region’s median (groupby is next week)
  • isna().sum()count the gaps before and after, so you know how many you changed
apply Is The Slow Road

52× for doing the same arithmetic

.apply() calls your Python function once per row. A lambda is a one-line function with no name: lambda x: x / 1e6 takes x and gives back x ÷ 1,000,000. A vectorised expression hands the whole column to NumPy’s fast C code at once (week 5).

When apply is still right

When there is genuinely no vectorised equivalent — complex string parsing, a lookup with branching. Reach for it second, not first.

# per-row Python call df.budget.apply(lambda x: x / 1e6) 2.33 ms # one vectorised operation df.budget / 1e6 0.044 ms # 52x faster # same 34,079 answers
  • df.budget.apply(lambda x: x / 1e6)run the little function 34,079 times, once per budget
  • df.budget / 1e6the same division as one vectorised operation (week 5): the whole column at once
  • 2.33 ms vs 0.044 msmilliseconds: the vectorised version is about 52 times faster
Your Turn · 8 min

Interrogate a real frame

1 · Load and inspect

Read the flood CSV. Run .info(). List every column pandas guessed as object that should be numeric or a date.

2 · Fix the types

Convert them with errors="coerce". Count what became NaN in each.

3 · Prove the copy rule

Filter to one region into sub. Assign a new column on sub. Check df — is it changed? Now do it properly with .loc.

4 · Answer one question

Which region has the highest median budget? Use groupby, then nlargest. Is it the same region as the highest total?

Quick Check

Tap to reveal

You filter sub = df[df.region == "Region III"] then run sub["flag"] = 1. On pandas 3.x, what happens to df?

A · It gains a flag column for the matching rows
B · Nothing — a filtered frame is its own copy under Copy-on-Write
C · A SettingWithCopyWarning is raised and the result is undefined
D · It raises a ValueError
B. pandas 3.0 made Copy-on-Write permanent, so a filter is always a copy and df is untouched — with no warning. To change the original, use one statement: df.loc[df.region == "Region III", "flag"] = 1. (On pandas 2.x this same code raised SettingWithCopyWarning.)
Quick Check

Tap to reveal

df.loc[0:2] and df.iloc[0:2] on the same frame return different numbers of rows. Why?

A · .loc skips rows containing NaN
B · .loc slices by label and includes the endpoint; .iloc slices by position and excludes it
C · .iloc is limited to two rows at a time
D · They are identical; any difference is a bug
B. Label slicing is inclusive because pandas cannot know what comes "after" an arbitrary label, so it takes you literally. Position slicing follows normal Python half-open convention. .loc[0:2] gives 3 rows, .iloc[0:2] gives 2.
Putting It Together

The biggest Visayan city

Filter to one region, then sort and take the top row. Load, select, filter, summarise — the whole loop of this week, in four lines.

vis = df[df["region"] == "Visayas"] vis["pop"].mean() # avg Visayan city vis.sort_values("pop").tail(1) # the largest one
  • vis = df[df["region"] == "Visayas"]filter: only the Visayan rows
  • vis["pop"].mean()the average population of those cities
  • vis.sort_values("pop").tail(1)sort smallest to biggest, then .tail(1) keeps the last row: the biggest city
Quick Check

Tap to reveal

Which keeps rows that are both big and Visayan?

A · df[big and vis]
B · df[big & vis]
C · df[big, vis]
D · df[big | vis]

B — df[big & vis].

Element-wise AND on Series uses &. The word and (A) raises an error; | (D) is OR, not AND.

This Week's Lab

Load, select, filter a real CSV

You'll build a small table of Philippine cities, inspect its types, select columns, filter with single and combined conditions, sort, and count to answer set questions. ~45 minutes.

You'll practise

.describe, column selection, boolean filters, isin, sort_values, .loc/.iloc, value_counts, and a first taste of groupby.

Stretch, if you want

Use .query("pop > 500000") as a readable filter alternative.

Recap

Load, select, filter

Glossary Recap

Every new word from today, one line each

The DataFrame

DataFrame
pandas’ table of rows and named columns
Series
one column: values plus a label per row
column / row
one named field / one record
index (label)
the row labels down the left; they stay with their row
CSV / read_csv
a plain-text table with commas / the function that loads it
dtype / object
a column’s type / pandas’ word for text
category
a dtype that stores each repeated value once
NaN (missing value)
the marker for an empty cell; count with .isna().sum()
errors="coerce"
turn values that will not convert into NaN instead of crashing
.head / .info / .describe
first rows / types and counts / summary numbers
accessor (.str / .dt)
text methods / date parts, applied down a whole column

Selecting, filtering, changing

.loc / .iloc
pick by label (end included) / by position (end left out)
boolean filter
df[mask]: keep the rows where the mask is True
& / | / ~
and / or / not, for True/False Series; wrap each condition in ( )
method chaining
methods in a row, each on the last result: .sort_values().head()
value_counts
how many rows have each value
nlargest / query
top-n rows by a column / a filter written as a string
fillna
replace missing values with a value you choose
apply / lambda
run a function on every value / a one-line nameless function
reset_index
give the rows fresh labels 0, 1, 2…
Copy-on-Write
every filtered result is its own copy; change the original with .loc
groupby
summarise per group, e.g. per region (next week)
Before Next Week

Practice & reading

Next Week

pandas II: Grouping, Merging & Reshaping

Split-apply-combine, joining two tables on a shared key, and pivoting long data into wide — the moves behind every real analysis.

DS 208 · Programming for Data Science