Cleaning, searching, and extracting from messy text — with Python's string tools and the pattern language called regex.
Programming for Data Science · University of the Philippines Cebu
The built-in methods that clean and split text.
Describe a shape of text and find every match.
Extract fields, and apply patterns across a whole column.
Real data is full of text — names, addresses, codes. Cleaning it is often the largest, least glamorous part of any project.
Pull every phone number out of a block of messy text.
A built-in action a piece of text can do, written after a dot.
e.g. " hi ".strip() gives back "hi"
A short code that describes a shape of text, so Python can find every piece that fits.
e.g. \d{4} means "four digits in a row"
The shape you are looking for, written in regex. Most characters mean themselves; a few are special.
e.g. 09\d{9} = "09, then nine more digits"
A stretch of text that fits the pattern. re.search finds the first; re.findall finds them all.
e.g. re.findall(r"\d+", "3 cats 4 dogs") gives ['3', '4']
A set of allowed characters that fills one position in the text.
e.g. [A-Z] = one capital letter; \d = one digit
A symbol that says how many times the thing just before it may repeat.
e.g. {4} exactly four · + one or more · * zero or more
Round brackets around part of a pattern; the text inside is kept ("captured") so you can pull it out.
e.g. (\d{4})-(\d{2}) on 2024-08 keeps 2024 and 08
A symbol that matches a position, not a character: ^ start, $ end, \b edge of a word.
e.g. ^09 = "the text must begin with 09"
A backslash \ in front of a character flips its meaning between special and ordinary.
e.g. \( = a real bracket; \d = any digit, not the letter d
A string written r"…": Python leaves every backslash alone, so the regex gets it untouched.
e.g. always write patterns as r"\d+", never "\d+"
Whatever the source, text arrives with stray spaces, mixed case, and values buried inside sentences. Before you can analyse it, you have to tidy and extract.
You already know a string is text in quotes (week 2) and a pandas column is a Series of values (week 6). Today adds the tools that clean and search the text inside them.
Enough for tidy, predictable text.
For when you need a pattern, not an exact string.
String methods are like Find & Replace in Word: you type the exact text. Regex is like telling a friend "find every 11-digit number that starts with 09" — you describe what it looks like, not what it is.
The everyday cleaning toolkit.
You already store text in strings. This part adds string methods — built-in actions a string can do to itself — to trim, re-case, swap and split it.
strip removes surrounding whitespace, lower normalises
case so comparisons work, and replace swaps out unwanted pieces.
text.lower() (week 4)"Cebu" and "CEBU" compare equals = " Cebu City "store the text, with two spaces at each end, in a variable called ss.strip()a new string with the spaces at both ends removed; s itself is unchangeds.strip().lower()chaining: strip first, then make the result lowercases.replace("City", "")swap every "City" for an empty string; the spaces around it stay, so the result still has spaces at both endsEvery flood control project has a description. They are inconsistent, abbreviated, and full of exactly the structure regex is for.
str.contains answers most questions and cannot fail silently the way extraction can. (Extracting = copying the matched piece out into its own column; later today you will see it quietly miss rows.)
d = df.descriptiondf is the flood-control projects DataFrame from weeks 5–9; d is its description column (34,079 texts)for kw in [...]:run the indented line once for each of the four keywordsd.str.contains(kw, na=False).str applies a string method to every row → one True/False per row; na=False counts a missing description as False.sum()adds up the Trues (True counts as 1)CONSTRUCTION 26,55226,552 descriptions contain the letters CONSTRUCTION somewheresplit breaks apart; join puts togetherThese two are inverses (each undoes the other). split turns one
string into a list on a separator — the character that marks where
to cut; join glues a list back into one string.
"Cebu,Davao,Iloilo".split(",")cut the text at every comma; the commas are thrown away['Cebu', 'Davao', 'Iloilo']square brackets = a list (week 3) of three strings, each in quotes", ".join([...])the string before the dot is the glue: put ", " between the items"a, b, c"one single string again| Method | Does | Example → result |
|---|---|---|
.strip() | trim whitespace | " hi ".strip() → "hi" |
.lower() | lowercase | "HI".lower() → "hi" |
.replace(a, b) | swap text | "a-b".replace("-","_") → "a_b" |
.startswith(x) | test the start | "09xx".startswith("09") → True |
x in s | contains? | "bu" in "Cebu" → True |
These handle predictable text (True/False answers are
booleans, week 2). When the thing you want has a shape but not a fixed value, you need
a regular expression (regex): a short code that describes that shape.
"Any 11-digit number starting 09" is a pattern — string methods can't express it.
Describe the shape; find every match.
You can now clean text whose exact value you know. This part adds regex: a way to describe text you only know the shape of — "four digits", "starts with 09" — and find every piece that fits.
A regex is a mini-language for describing patterns:
"four digits," "a word," "starts with 09." search finds the first;
findall returns them all.
The description itself is the pattern; each stretch of text that fits it is a match.
A regex is a wanted poster. It doesn't name anyone; it describes them — "all digits, one or more of them" — and Python checks every stretch of the text against the description.
import reload re, Python's built-in regex module (a toolbox that ships with Python)r"\d+"the pattern. \d = one digit, + = one or more of it → "a run of digits". The r makes it a raw string, so the backslash survivesre.search(pattern, text)find the first match. Python shows <re.Match object; span=(6, 8), match='42'>: a match object, found at positions 6–8; None if nothing fitsre.findall(pattern, text)find every match → a list of strings, ['3', '4']; an empty list [] if noneSearching for CONSTRUCTION also matches it inside RECONSTRUCTION — which is different work, quietly added to your total.
An assertion is a check, not a character: it matches the position between a word character and a non-word one. It consumes nothing, so it does not change what you capture.
_ — what \w matches\b\b matches a position, so it uses up none"CONSTRUCTION"plain letters: found anywhere, including inside RECONSTRUCTIONr"\bCONSTRUCTION\b"read it: word edge, the letters CONSTRUCTION, word edge. In RECONSTRUCTION the "RE" sits before it, so there is no edge → no match.str.contains(...)treats its text as a regex by default, so \b works here393descriptions counted only because they say RECONSTRUCTION| Token | Matches | Use it when |
|---|---|---|
^ | Start of the string | The pattern must begin the field |
$ | End of the string | Validating a whole value — e.g. the contract-ID pattern |
\b | A word boundary | Counting a keyword without catching it inside longer words |
(?=...) | A lookahead | Requiring something follows without consuming it |
Anchoring both ends with ^ and $ is what turns "contains something like this" into "is exactly this shape" — the difference between finding and validating. Example: ^09 matches 09171234567 but not tel:09171234567.
(?=...): "only if this comes next", without including it in the match| Token | Matches | Token | Matches |
|---|---|---|---|
\d | a digit 0–9 | + | one or more |
\w | letter, digit, _ | * | zero or more |
. | any character | {4} | exactly four |
[A-Z] | one in the set | ^ $ | start / end |
A token (one building block) such as \d, or a
character class such as [A-Z] (a set of allowed characters
filling one position), says what. A quantifier
(+, *, {4}) says how many of the thing just
before it. Combine them and you can describe almost any field.
r prefixAlways write patterns as r"..." so \d stays literal.
In a normal string a backslash starts an escape: "\n" is a
line break and "\b" is a backspace. A raw string
r"..." passes every backslash to the regex untouched.
Contract IDs. All 8 characters. This is the most common regex failure there is.
Look at the first few IDs — 22F00136 — and the structure seems obvious: two digits, a letter or two, then five digits.
That is the trap. The pattern is validated against the handful of rows you happened to read.
^ … $anchors: the whole ID, start to end, must fit(\d{2})group 1: exactly two digits (22)([A-Z]{1,2})group 2: one or two capital letters (F)(\d{5})group 3: exactly five digits (00136).str.extract(pat)run the pattern on every ID; each group becomes a column 0, 1, 2; a row that does not match gets NaN (missing, week 6)ex[0].notna().sum() / .mean()how many / what fraction of rows are not missing → 3,311 rows, 0.097 = 9.7%| Shape | Rows | Share | Example |
|---|---|---|---|
DDLLDDDD | 30,768 | 90.3% | 18BI0029 — 2 letters, 4 digits |
DDLDDDDD | 3,311 | 9.7% | 22F00136 — 1 letter, 5 digits |
In the Shape column, D stands for any digit and L for any letter. All 34,079 are eight characters, but the letters-to-digits split moves. The pattern demanding exactly five trailing digits matched only the minority — which happened to be what the first rows looked like.
cid.apply(lambda s: ''.join('D' if c.isdigit() else 'L' for c in s)) then .value_counts(). Shapes, not guesses.
In words: for each ID (cid = the contract-ID column), write D for every digit and L for everything else, then count how many IDs share each shape. lambda s: … is a one-line, unnamed function.
+ means "one or more". Using it where the length genuinely varies, and exact counts only where it does not, takes this from 9.7% to 100%.
Always print what fraction matched. An extraction that silently returns NaN is the whole problem.
(\d{2})still exact: every ID starts with exactly two year digits([A-Z]+)one or more capital letters: F and BI both fit(\d+)one or more digits: four or five both fit1.0the fraction of rows that matched: all of themsorted(ex[0].unique())the distinct year prefixes, in order: '14' … '26' = 2014–2026With the pattern working on all rows, the extracted year prefix can be checked against the year column. They agree 96.3% of the time.
Not a regex bug — a data one. Contract 20BH0131 carries a 2020 prefix on a row labelled 2019. Worth a footnote in anything that uses either field.
"20" + ex[0]+ on text glues it: "20" + "22" → "2022", for every row at oncedf.year.astype(str)the year column holds numbers (int64); turn them into text so we compare like with like. Without it every row differs: 34,079df[... != ...]keep only the rows where the two years disagree (a True/False mask, week 6)len(bad)how many rows that is: 1,266Read left to right: a literal 09 (characters
that simply mean themselves), then a digit, repeated nine times. That's exactly an 11-digit
Philippine mobile number.
Write a pattern piece by piece, testing each addition on real samples.
What does
re.findall(r"\d{4}", "1998 and 2020") return?
['1', '9', '9', '8', ...]['1998', '2020']['19982020'][]B — ['1998', '2020'].
\d{4} means "exactly four digits in
a row," so it grabs each four-digit run as one match. \d alone would give
answer A.
{4} groups four digits into one match. The quantifier changes the
whole result.
\d{4} = a four-digit chunk.
Back to pull fields out and apply patterns to a whole column.
Extract fields; clean whole columns.
You can now write a pattern and find its matches. This part adds groups, which pull out just the pieces you want, and runs patterns down a whole pandas column.
Wrap part of a pattern in ( ) to capture it: that
bracketed part is a group. After a match, .group(1),
.group(2) give you each captured piece separately.
It is like highlighting parts of a sentence you found: the whole match is found, but you copy out only the highlighted bits.
(\d{4})-(\d{2})group 1: four digits; then a literal hyphen; then group 2: two digitsm = re.search(...)m is the match object: the text found plus what each group caughtm.group(1)the text group 1 caught → '2024'. m.group(0) is the whole match, '2024-08'm.groups()every group at once, as a tuple: ('2024', '08')* and + are greedy: they take as much as they can while still allowing a match. With two sets of brackets, that is not what you want. Adding ? makes them lazy: as little as possible.
Same character that broke the encoding in week 9. Real text keeps handing you the same problems.
tthe description text shown in the two comment lines\( and \)escaped: a real "(" and ")" in the text, not a group(.*)group: . any character, * zero or more, as many as possible → runs to the last ")"(.*?)the extra ? = as few as possible → stops at the first ")"search returns the first match. findall returns every one — and with a lazy quantifier it gets the brackets individually.
.str.extractall() is the vectorised version, returning a row per match with a MultiIndex.
| You want | Pattern | Read it as | Matches |
|---|---|---|---|
| A 4-digit year | \d{4} | exactly four digits | 2024 |
| A PH mobile | 09\d{9} | "09", then exactly nine digits | 09171234567 |
| A date | \d{4}-\d{2}-\d{2} | 4 digits, hyphen, 2 digits, hyphen, 2 digits | 2024-08-31 |
| A word | \w+ | one or more word characters (letters, digits, _) | Cebu |
Don't memorise — recognise. Keep a cheat sheet and adapt these to what your data actually looks like.
A site like regex101 shows, live, what each part of your pattern matches.
.str applies a pattern to every rowpandas exposes string and regex tools through .str.
contains filters; extract pulls a captured group into a new
column — vectorized (the whole column in one call), no loop.
df["phone"], df["code"]columns of a small made-up example table (not the flood data)r"^09\d{9}$"start, "09", nine digits, end: the whole value must be an 11-digit mobile.str.contains(...)True/False for every row; df[...] around it keeps only the True rows.str.extract(r"(\d{4})")the first four-digit run in each row, as a new column; NaN where there is nonePatterns that look right often miss edge cases. Check against actual data.
If .startswith("09") does the job, skip the regex.
A dense pattern is unreadable in a month. Say what it matches.
A regex is code. It deserves the same clarity and testing as the functions you wrote in Week 4.
Simplest tool that works — reach for regex only when you truly need a pattern.
Free text written by thousands of people will not yield to one pattern. The goal is not 100% — it is knowing your coverage and saying so.
Good enough to describe where the bulk of work happens; not good enough to claim a complete geographic breakdown. Report which.
d.str.extract(pattern, expand=False)the place name from every description; expand=False gives back one column (a Series), not a tableplaces.notna().sum()12,400 rows matched; the other 21,679 are NaN.value_counts().head(3)the three most common place names and how often each appears| Piece | Read it as |
|---|---|
\b | at the edge of a word |
(?:ALONG|IN) | the word ALONG or IN. | means "or"; (?: ) groups without capturing (nothing is kept) |
\s+ | one or more whitespace characters |
( … ) | group 1: the place name we keep |
[A-Z] | it starts with a capital letter |
[A-Z\s\.]{3,40}? | then 3 to 40 more capitals, spaces or dots (\. = a real dot); the ? = lazy, stop as early as possible |
(?:,|\s+\() | stop at a comma, or at spaces followed by a real "(" |
On "CONSTRUCTION OF FLOOD CONTROL STRUCTURE ALONG AGNO RIVER, BRGY. POBLACION" group 1 is 'AGNO RIVER': ALONG, a space, then capitals and spaces up to the comma.
Descriptions that name a place without ALONG or IN, or that end the name some other way, are not caught. That is fine, as long as you say so.
12,400 rows. The numerator of anything you go on to claim.
36.4%. Without this, a reader assumes you extracted everything.
Print ten non-matching rows. They tell you whether the gap is random or systematic — and systematic misses bias your result.
An extraction that quietly covers a third of your data, reported as though it covered all of it, is the same failure as an unnoticed dropna() (which throws away rows with blanks, week 1) — a silent change to which rows your conclusion is about.
Regex is for shapes of text. When the structure is already known, something else parses it better and more safely.
Do not parse HTML or JSON with regex. Both nest arbitrarily; a regular expression fundamentally cannot track nesting.
->not Python: read it as "use this instead"pd.read_html, json.loadsthe parsers from week 9 for HTML tables and JSON textThe common advice is "compile for performance". Measured over 34,079 strings, the gain is 1.26× — because the re module already caches recent patterns.
A named object you can reuse, pass around and test. And flags attach once rather than at every call site.
re.compile(pat) turns pattern text into a ready-to-use pattern object, oncere quietly keeps recently used patterns so it need not redo themfor s in descriptions: re.search(pat, s)search each of the 34,079 texts, handing re the pattern text every timerx = re.compile(pat)build the pattern object once and name it rxrx.search(s)the same search, as a method of that object| Token | Means | Example |
|---|---|---|
\d \w \s | digit / word char / whitespace | \d{2} → 22 |
[A-Z] | any one of a set | [A-Z]+ → BI |
+ * ? | one-or-more / zero-or-more / optional | [A-Z]+ |
{n} {n,m} | exactly n / between n and m | \d{5} |
. | any character except newline | (.*?) |
? after a quantifier | make it lazy | (.*?) vs (.*) |
( ) | capture a group | (?P<yr>\d{2}) |
^ $ \b | positions, not characters | \bCONSTRUCTION\b |
| | alternation | (CONST|REHAB) |
\ | escape a special character | \( for a literal bracket |
Every pattern in this session is built from these. If you find yourself reaching past them, that is usually the signal that the text has structure a parser should be handling instead.
| means "or": CONST|REHAB matches either word(?P<yr>...): a group with a name instead of a number.str.strip(), .str.upper(), re.sub(r"\s+", " ", s) (every run of whitespace becomes one space). Most "regex problems" are inconsistent whitespace and case.
Word boundaries for counting, anchors for validating, lazy quantifiers when there is more than one match per row.
notna().mean(), then read ten rows that did not match. Always. This is the step people skip.
Step 3 is what separates this from guessing. An extraction is a claim about your data, and like every other claim in this course it needs its denominator (the total you divide by: all 34,079 rows) stated.
Run the rigid contract-ID pattern. Confirm you get 9.7% and that nothing raised.
Map each ID to a D/L signature (D for a digit, L for a letter, as on the shapes slide) and value_counts() it. Find the two formats yourself.
Loosen the quantifiers. Do not stop until notna().mean() is 1.0.
Pull the work type (CONSTRUCTION / REHABILITATION / …) from the description. Report your coverage, and look at ten rows that did not match.
Step 4 is where you will meet the real lesson: your first pattern will cover less than you expect, and the misses will not be random.
Your .str.extract() runs without error and the new column is 90% NaN. What happened?
expand=Truestr.extract reports a non-match as NaN, so a pattern that fits only a minority of your data produces a mostly-empty column and no error anywhere. This is why you check notna().mean() after every extraction. Here the rigid contract-ID pattern matched 9.7% because it demanded exactly five trailing digits, and 90.3% of IDs have four.On "OCAÑA RIVER (GABIONS) (UPSTREAM)", what does re.search(r"\((.*)\)", t) capture?
"GABIONS""GABIONS) (UPSTREAM" — greedy matching runs to the last closing bracket["GABIONS", "UPSTREAM"].* is greedy: it takes as much as it can while still permitting a match, so it runs past the first ) to the last one. Use the lazy .*? to stop at the first, and re.findall (or .str.extractall) when you want every pair. 1,543 descriptions in this dataset contain two or more brackets.One pattern, one call, and every Philippine mobile number falls out — regardless of the words around it. This is what regex is for.
09literal: the characters 0 then 9\d{9}exactly nine more digits (11 in total)re.findall(...)every match, left to right, as a list; the words "Call" and "or" are simply skippedEverything you can find, you can replace. Collapsing whitespace is the one you will use most, because real text is full of stray runs of it.
\s+ squeezes internal runs but leaves the single leading and trailing space it produced.
\1 in the replacement means "whatever group 1 caught"re.sub(pattern, new, text)find every match and swap in new → a new stringr"\s+" → " "each run of spaces becomes a single space; .strip() then trims the endsr"\s+([,.])"spaces, then group 1: one comma or full stop (inside [ ] a dot is just a dot)r"\1"put back only the punctuation group 1 caught, so the spaces before it vanish(?P<name>...) turns a positional capture into a named one — and in pandas, straight into column names.
ex[0], ex[1], ex[2] is unreadable a week later. ex.yr is not.
(?P<yr>\d{2})a group named yr that catches two digits; ?P<name> is just how you attach the namelist(ex.columns)the new columns are called after the groups, not 0, 1, 2ex.head(3)the first three IDs, split into their three partsA flag is an on/off option for the whole pattern. Three you will actually use. Pass them to re functions, or use the inline (?i) form inside a pandas pattern.
.str.contains(..., case=False) is the same thing as re.IGNORECASE, and reads better in a chain.
re.IGNORECASEupper and lower case count as the same letterre.VERBOSEspaces and # comments inside the pattern are ignored, so you can lay it outre.MULTILINEfor text with line breaks: ^/$ mean start/end of each liner"""…"""a raw string that may run over several linesIn a regex, what does \d match?
B — a single digit 0–9.
\d is one digit; add a quantifier
like + or {4} for more. \w is the one that also
covers letters.
\d digit, \w word char, . anything. Learn
these three first.
\d = one digit.
You'll tidy a messy column with string methods, write patterns to pull out years and phone numbers, and apply one across a whole DataFrame column. ~45 minutes.
strip/lower/split, re.findall, groups, and
.str.extract.
Two new names: re.split cuts text wherever the pattern
matches, and re.fullmatch succeeds only if the whole string fits (like
wrapping the pattern in ^…$).
Write one pattern that accepts both 0917... and +63917... formats.
strip/lower/replace and split/join for tidy text.
\d \w . + * {n} [ ] — describe a pattern, then findall.
Capture with ( ); apply to columns with .str.
One sentence: tidy text with string methods, and when you need a shape rather than an exact value, describe it with regex.
Extract structured values out of unstructured text.
s.strip()09\d{9}[A-Z], \d, \w, \s+ * ? {4} {1,2}( ) that capture part of a match^ start, $ end, \b word edge\( is a real bracketr"…": Python leaves backslashes alone; use it for every patternsplit cuts at09re.search returns; read it with .group()\b, the edge where a word starts or ends.* takes as much as possible; .*? as little| means "or"\1: whatever group 1 caughtre.IGNORECASEFinish the Week 10 lab and submit it. Be able to write \d{4} and
re.findall from memory.
Python "Regular Expression HOWTO" — docs.python.org/3/howto/regex.
Everything here is linked on the course page beside this deck.
Next week: making your whole project reproducible — Git, environments, and debugging.
Git for version history, virtual environments for pinned dependencies, and a calm, systematic way to debug — so your work runs the same for everyone.
DS 208 · Programming for Data Science