Mathew K Analytics

Lesson 19 · Pandas Full Course

Clean Text in Pandas with .str: split, contains, extract and Regex | Pandas Course #19

In this lesson you'll be able to: Tidy case and stray spaces with the .str accessor Search text with contains, startswith and endswith Split text into…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Pandas 19: String Operations and Text Cleaning in Pandas#

In this lesson you'll be able to:

  • Tidy case and stray spaces with the .str accessor
  • Search text with contains, startswith and endswith
  • Split text into pieces, and one row per piece with explode
  • Pull numbers and words out of text with extract and patterns Data: Titanic passenger names and the Netflix catalogue

Warm-up: the cars from last lesson#

We converted their units. Their names are text, waiting for today.

import pandas as pd

cars = pd.read_csv("data/pandas/mpg.csv")
cars["l_per_100km"] = (235.215 / cars["mpg"]).round(1)
cars[["name", "mpg", "l_per_100km"]].head(3)
name mpg l_per_100km
0 chevrolet chevelle malibu 18.0 13.1
1 buick skylark 320 15.0 15.7
2 plymouth satellite 18.0 13.1
print(cars["name"].dtype)
print(cars["name"].str.len().max(), "characters in the longest")
str
36 characters in the longest

Clean up case and stray spaces#

Text methods live behind .str, and work on every row at once.

titanic = pd.read_csv("data/pandas/titanic.csv")
names = titanic["Name"]
names.head(3)
0                              Braund, Mr. Owen Harris
1    Cumings, Mrs. John Bradley (Florence Briggs Th...
2                               Heikkinen, Miss. Laina
Name: Name, dtype: str
print(names.str.upper().head(2).tolist())
print(names.str.lower().head(2).tolist())
print(names.str.len().describe().round(1)[["min", "mean", "max"]])
['BRAUND, MR. OWEN HARRIS', 'CUMINGS, MRS. JOHN BRADLEY (FLORENCE BRIGGS THAYER)']
['braund, mr. owen harris', 'cumings, mrs. john bradley (florence briggs thayer)']
min     12.0
mean    27.0
max     82.0
Name: Name, dtype: float64
print(cars["name"].str.title().head(3).tolist())
['Chevrolet Chevelle Malibu', 'Buick Skylark 320', 'Plymouth Satellite']
netflix = pd.read_csv("data/pandas/netflix_titles.csv")
print(netflix.shape)
netflix[["title", "type", "duration", "date_added"]].head(3)
(7787, 12)
title type duration date_added
0 3% TV Show 4 Seasons August 14, 2020
1 7:19 Movie 93 min December 23, 2016
2 23:59 Movie 78 min December 20, 2018
titles = netflix["title"]
tidy = titles.str.strip().str.replace(r"\s+", " ", regex=True)
changed = tidy != titles
print(changed.sum(), "titles changed")
print(titles[changed].tolist())
print(tidy[changed].tolist())
4 titles changed
['Consequences\xa0', 'The Memphis Belle: A Story of a\nFlying Fortress', 'Tremors 5:  Bloodline', 'Up Among  The Stars']
['Consequences', 'The Memphis Belle: A Story of a Flying Fortress', 'Tremors 5: Bloodline', 'Up Among The Stars']
added = netflix["date_added"]
padded = (added.str.strip() != added) & added.notna()
print(padded.sum(), "dates have stray spaces")
print(added[padded].head(2).tolist())
88 dates have stray spaces
[' August 4, 2017', ' December 23, 2018']

Common mistake: converting text to dates before stripping the spaces.

pd.to_datetime(added, format="%B %d, %Y")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[10], line 1
----> 1 pd.to_datetime(added, format="%B %d, %Y")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\tools\datetimes.py:1036, in to_datetime(arg, errors, dayfirst, yearfirst, utc, format, exact, unit, origin, cache)
   1034             result = arg.tz_localize("utc")
   1035 elif isinstance(arg, ABCSeries):
-> 1036     cache_array = _maybe_cache(arg, format, cache, convert_listlike)
   1037     if not cache_array.empty:
   1038         result = arg.map(cache_array)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\tools\datetimes.py:254, in _maybe_cache(arg, format, cache, convert_listlike)
    252 unique_dates = unique(arg)
    253 if len(unique_dates) < len(arg):
--> 254     cache_dates = convert_listlike(unique_dates, format)
    255     # GH#45319
    256     try:

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\tools\datetimes.py:435, in _convert_listlike_datetimes(arg, format, name, utc, unit, errors, dayfirst, yearfirst, exact)
    433 # `format` could be inferred, or user didn't ask for mixed-format parsing.
    434 if format is not None and format != "mixed":
--> 435     return _array_strptime_with_fallback(arg, name, utc, format, exact, errors)
    437 result, tz_parsed = objects_to_datetime64(
    438     arg,
    439     dayfirst=dayfirst,
   (...)
    443     allow_object=True,
    444 )
    446 if tz_parsed is not None:
    447     # We can take a shortcut since the datetime64 numpy array
    448     # is in UTC

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\tools\datetimes.py:470, in _array_strptime_with_fallback(arg, name, utc, fmt, exact, errors)
    459 def _array_strptime_with_fallback(
    460     arg,
    461     name,
   (...)
    465     errors: str,
    466 ) -> Index:
    467     """
    468     Call array_strptime, with fallback behavior depending on 'errors'.
    469     """
--> 470     result, tz_out = array_strptime(arg, fmt, exact=exact, errors=errors, utc=utc)
    471     if tz_out is not None:
    472         unit = np.datetime_data(result.dtype)[0]

File pandas/_libs/tslibs/strptime.pyx:564, in pandas._libs.tslibs.strptime.array_strptime()

File pandas/_libs/tslibs/strptime.pyx:512, in pandas._libs.tslibs.strptime.array_strptime()

File pandas/_libs/tslibs/strptime.pyx:618, in pandas._libs.tslibs.strptime._parse_with_format()

ValueError: time data " August 4, 2017" doesn't match format "%B %d, %Y". You might want to try:
    - passing `format` if your strings have a consistent format;
    - passing `format='ISO8601'` if your strings are all ISO8601 but not necessarily in exactly the same format;
    - passing `format='mixed'`, and the format will be inferred for each element individually. You might want to use `dayfirst` alongside this.
netflix["added"] = pd.to_datetime(added.str.strip(),
                                  format="%B %d, %Y")
netflix["added"].dt.year.astype("Int64").value_counts().head(3)
added
2019    2153
2020    2009
2018    1685
Name: count, dtype: Int64

Search text with contains and startswith#

Each search returns True or False per row, ready for filtering.

xmas = netflix["title"].str.contains("christmas", case=False)
print(xmas.sum(), "titles mention Christmas")
netflix.loc[xmas, "title"].head(3).tolist()
77 titles mention Christmas
['48 Christmas Wishes', '5 Star Christmas', 'A Bad Moms Christmas']

Quick check: how many titles come from India? Does == or contains give more?

country = netflix["country"]
print((country == "India").sum(), "with ==")
print(country.str.contains("India").sum(), "with contains")
country[country.str.contains("India") & (country != "India")].head(3)
923 with ==
990 with contains
391     Soviet Union, India
433    United States, India
524          India, Germany
Name: country, dtype: str

Common mistake: searching for a symbol that has a special meaning.

netflix["title"].str.contains("(")
---------------------------------------------------------------------------
ArrowInvalid                              Traceback (most recent call last)
Cell In[14], line 1
----> 1 netflix["title"].str.contains("(")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\strings\accessor.py:142, in forbid_nonstring_types.<locals>._forbid_nonstring_types.<locals>.wrapper(self, *args, **kwargs)
    137     msg = (
    138         f"Cannot use .str.{func_name} with values of "
    139         f"inferred dtype '{self._inferred_dtype}'."
    140     )
    141     raise TypeError(msg)
--> 142 return func(self, *args, **kwargs)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\strings\accessor.py:1499, in StringMethods.contains(self, pat, case, flags, na, regex)
   1491     if has_groups:
   1492         warnings.warn(
   1493             "This pattern is interpreted as a regular expression, and has "
   1494             "match groups. To actually get the groups, use str.extract.",
   1495             UserWarning,
   1496             stacklevel=find_stack_level(),
   1497         )
-> 1499 result = self._data.array._str_contains(pat, case, flags, na, regex)
   1500 return self._wrap_result(result, fill_value=na, returns_string=False)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\string_arrow.py:427, in ArrowStringArray._str_contains(self, pat, case, flags, na, regex)
    424     return super()._str_contains(pat, case, flags, na, regex)
    426 pat, case, flags = self._preprocess_re_pattern(pat, case, flags)
--> 427 return ArrowStringArrayMixin._str_contains(self, pat, case, flags, na, regex)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\_arrow_string_mixins.py:393, in ArrowStringArrayMixin._str_contains(self, pat, case, flags, na, regex)
    391 else:
    392     pa_contains = pc.match_substring
--> 393 result = pa_contains(self._pa_array, pat, ignore_case=not case)
    394 return self._convert_bool_result(result, na=na, method_name="contains")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\compute.py:272, in _make_generic_wrapper.<locals>.wrapper(memory_pool, options, *args, **kwargs)
    270 if args and isinstance(args[0], Expression):
    271     return Expression._call(func_name, list(args), options)
--> 272 return func.call(args, options, memory_pool)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\_compute.pyx:405, in pyarrow._compute.Function.call()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\error.pxi:155, in pyarrow.lib.pyarrow_internal_check_status()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\error.pxi:92, in pyarrow.lib.check_status()

ArrowInvalid: Invalid regular expression: missing ): (
bracket = netflix["title"].str.contains("(", regex=False)
print(bracket.sum(), "titles contain a bracket")
netflix.loc[bracket, "title"].head(3).tolist()
69 titles contain a bracket
['(T)ERROR', '(Un)Well', '7 (Seven)']
rating = netflix["rating"]
print(rating.str.startswith("TV-").mean().round(3))
print(netflix["title"].str.endswith("2").sum(), "titles end in 2")
0.816
93 titles end in 2
war = netflix["title"].str.contains("war|battle", case=False)
print(war.sum(), "matches")
netflix.loc[war, "title"].head(4).tolist()
90 matches
['3 Deewarein', 'Aashik Awara', 'Alien Warfare', 'American Warfighter']
pattern = r"\b(?:war|battle)\b"
war = netflix["title"].str.contains(pattern, case=False)
print(war.sum(), "whole-word matches")
netflix.loc[war, "title"].head(4).tolist()
45 whole-word matches
['Attacking the Devil: Harold Evans and the Last Nazi War Crime',
 'Avengers: Infinity War',
 'Bakugan: Battle Planet',
 'Battle']

Your turn: how many titles mention love, ignoring capitals?

love = netflix["title"].str.contains("love", case=False)
print(love.sum(), "titles")
netflix.loc[love, "type"].value_counts()
175 titles
type
Movie      108
TV Show     67
Name: count, dtype: int64

Split text into pieces#

split breaks text at a separator. expand and explode reshape the pieces.

parts = names.str.split(", ")
print(parts.head(2).tolist())
print(parts.str[0].head(3).tolist())
[['Braund', 'Mr. Owen Harris'], ['Cumings', 'Mrs. John Bradley (Florence Briggs Thayer)']]
['Braund', 'Cumings', 'Heikkinen']
titanic[["surname", "rest"]] = names.str.split(", ", n=1,
                                               expand=True)
titanic[["surname", "rest"]].head(3)
surname rest
0 Braund Mr. Owen Harris
1 Cumings Mrs. John Bradley (Florence Briggs Thayer)
2 Heikkinen Miss. Laina
titanic["surname"].value_counts().head(3)
surname
Andersson    9
Sage         7
Johnson      6
Name: count, dtype: int64
first = titanic["rest"].str.split(". ", n=1, regex=False).str[1]
titanic["short_name"] = first + " " + titanic["surname"]
titanic["short_name"].head(3).tolist()
['Owen Harris Braund',
 'John Bradley (Florence Briggs Thayer) Cumings',
 'Laina Heikkinen']

Your turn: take the car make from each car name. Which are the top 5?

make = cars["name"].str.split().str[0]
make.value_counts().head(5)
name
ford         51
chevrolet    43
plymouth     31
amc          28
dodge        28
Name: count, dtype: int64
print(make[make.str.startswith(("chev", "vw", "volks"))]
      .value_counts())
name
chevrolet     43
volkswagen    15
vw             6
chevy          3
chevroelt      1
Name: count, dtype: int64
genres = netflix["listed_in"].str.split(", ").explode()
print(len(netflix), "titles ->", len(genres), "genre rows")
genres.value_counts().head(5)
7787 titles -> 17071 genre rows
listed_in
International Movies      2437
Dramas                    2106
Comedies                  1471
International TV Shows    1199
Documentaries              786
Name: count, dtype: int64

Extract patterns with regular expressions#

A pattern like (\d+) means "capture one or more digits".

titanic["title"] = names.str.extract(r",\s*([^.]+)\.")[0]
titanic["title"].value_counts().head(6)
title
Mr        517
Miss      182
Mrs       125
Master     40
Dr          7
Rev         6
Name: count, dtype: int64

Common mistake: chopping off text and hoping the rest is a number.

netflix["duration"].str.replace(" min", "").astype(int)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[28], line 1
----> 1 netflix["duration"].str.replace(" min", "").astype(int)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py:6529, in NDFrame.astype(self, dtype, copy, errors)
   6525     results = [ser.astype(dtype, errors=errors) for _, ser in self.items()]
   6527 else:
   6528     # else, only a single dtype is given
-> 6529     new_data = self._mgr.astype(dtype=dtype, errors=errors)
   6530     res = self._constructor_from_mgr(new_data, axes=new_data.axes)
   6531     return res.__finalize__(self, method="astype")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:627, in BaseBlockManager.astype(self, dtype, errors)
    626 def astype(self, dtype, errors: str = "raise") -> Self:
--> 627     return self.apply("astype", dtype=dtype, errors=errors)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:445, in BaseBlockManager.apply(self, f, align_keys, **kwargs)
    443         applied = b.apply(f, **kwargs)
    444     else:
--> 445         applied = getattr(b, f)(**kwargs)
    446     result_blocks = extend_blocks(applied, result_blocks)
    448 out = type(self).from_blocks(result_blocks, [ax.view() for ax in self.axes])

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\blocks.py:607, in Block.astype(self, dtype, errors, squeeze)
    604         raise ValueError("Can not squeeze with more than one column.")
    605     values = values[0, :]  # type: ignore[call-overload]
--> 607 new_values = astype_array_safe(values, dtype, errors=errors)
    609 new_values = maybe_coerce_values(new_values)
    611 refs = None

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:240, in astype_array_safe(values, dtype, copy, errors)
    237     dtype = dtype.numpy_dtype
    239 try:
--> 240     new_values = astype_array(values, dtype, copy=copy)
    241 except (ValueError, TypeError):
    242     # e.g. _astype_nansafe can fail on object-dtype of strings
    243     #  trying to convert to float
    244     if errors == "ignore":

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:182, in astype_array(values, dtype, copy)
    178     return values
    180 if not isinstance(values, np.ndarray):
    181     # i.e. ExtensionArray
--> 182     values = values.astype(dtype, copy=copy)
    184 else:
    185     values = _astype_nansafe(values, dtype, copy=copy)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\string_arrow.py:338, in ArrowStringArray.astype(self, dtype, copy)
    335 elif isinstance(dtype, np.dtype) and np.issubdtype(dtype, np.floating):
    336     return self.to_numpy(dtype=dtype, na_value=np.nan)
--> 338 return super().astype(dtype, copy=copy)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\base.py:881, in ExtensionArray.astype(self, dtype, copy)
    878     return TimedeltaArray._from_sequence(self, dtype=dtype, copy=copy)
    880 if not copy:
--> 881     return np.asarray(self, dtype=dtype)
    882 else:
    883     return np.array(self, dtype=dtype, copy=copy)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:920, in ArrowExtensionArray.__array__(self, dtype, copy)
    916 elif copy is None:
    917     # `to_numpy(copy=False)` has the meaning of NumPy `copy=None`.
    918     copy = False
--> 920 return self.to_numpy(dtype=dtype, copy=copy)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:1803, in ArrowExtensionArray.to_numpy(self, dtype, copy, na_value)
   1801 result = data._pa_array.to_numpy()
   1802 if dtype is not None:
-> 1803     result = result.astype(dtype, copy=False)
   1804 if copy:
   1805     result = result.copy()

ValueError: invalid literal for int() with base 10: '4 Seasons'
dur = netflix["duration"]
netflix["length"] = dur.str.extract(r"(\d+)")[0].astype(int)
netflix["unit"] = dur.str.extract(r"\d+ (\w+)")[0]
netflix[["duration", "length", "unit"]].head(3)
duration length unit
0 4 Seasons 4 Seasons
1 93 min 93 min
2 78 min 78 min
pd.crosstab(netflix["type"], netflix["unit"])
unit Season Seasons min
type
Movie 0 0 5377
TV Show 1608 802 0
pattern = r"(?P<length>\d+) (?P<unit>\w+)"
dur.str.extract(pattern).head(3)
length unit
0 4 Seasons
1 93 min
2 78 min

Your turn: take the deck letter from Cabin. Which deck had the best survival?

deck = titanic["Cabin"].str[0]
titanic.groupby(deck)["Survived"].agg(["mean", "size"]).round(2)
mean size
Cabin
A 0.47 15
B 0.74 47
C 0.59 59
D 0.76 33
E 0.75 32
F 0.62 13
G 0.50 4
T 0.00 1

Quick check: does a passenger's title tell us anything about survival?

common = ["Mr", "Mrs", "Miss", "Master"]
group = titanic["title"].where(titanic["title"].isin(common), "Rare")
titanic.groupby(group)["Survived"].agg(["mean", "size"]).round(2)
mean size
title
Master 0.57 40
Miss 0.70 182
Mr 0.16 517
Mrs 0.79 125
Rare 0.44 27

Replace and count patterns#

replace and count work with plain text or with patterns.

in_brackets = names.str.contains("(", regex=False)
print(in_brackets.sum(), "names include a bracketed name")
short = names.str.replace(r"\s*\(.*?\)", "", regex=True)
print(short[in_brackets].head(2).tolist())
143 names include a bracketed name
['Cumings, Mrs. John Bradley', 'Futrelle, Mrs. Jacques Heath']
fixes = {"Mlle": "Miss", "Ms": "Miss", "Mme": "Mrs"}
titanic["title"] = titanic["title"].replace(fixes)
titanic["title"].value_counts().head(4)
title
Mr        517
Miss      185
Mrs       126
Master     40
Name: count, dtype: int64
cast_size = netflix["cast"].str.count(",") + 1
print(cast_size.median(), "actors listed in the median title")
netflix.loc[cast_size.idxmax(), ["title", "type"]].tolist()
8.0 actors listed in the median title
['Black Mirror', 'TV Show']
n_countries = (netflix["country"].str.count(",") + 1).astype("Int64")
print(n_countries.value_counts().sort_index().head(4))
print(n_countries.isna().sum(), "titles with no country")
country
1    6127
2     763
3     240
4      95
Name: count, dtype: Int64
507 titles with no country

Your turn: how many words are in the longest Netflix title?

words = netflix["title"].str.split().str.len()
print(words.max(), "words")
print(netflix.loc[words.idxmax(), "title"])
17 words
Jim & Andy: The Great Beyond - Featuring a Very Special, Contractually Obligated Mention of Tony Clifton

Mini-project: which film genres run longest?#

Combine extract, split and explode on the Netflix films.

films = netflix[netflix["type"] == "Movie"]
print(len(films), "films")
films["length"].describe().round(1)[["min", "50%", "max"]]
5377 films
min      3.0
50%     98.0
max    312.0
Name: length, dtype: float64
by_genre = (films.assign(genre=films["listed_in"].str.split(", "))
                .explode("genre"))
stats = by_genre.groupby("genre")["length"].agg(["median", "size"])
stats = stats[stats["size"] >= 100].sort_values("median")
print(stats.head(3))
print(stats.tail(3))
                          median  size
genre                                 
Stand-Up Comedy             65.0   329
Documentaries               86.0   786
Children & Family Movies    87.0   532
                    median  size
genre                           
Action & Adventure   108.0   721
Dramas               109.0  2106
Classic Movies       112.0   103
decade = films["release_year"] // 10 * 10
films.groupby(decade)["length"].median().loc[1990:]
release_year
1990    108.0
2000    108.0
2010     96.0
2020     94.0
Name: length, dtype: float64
import matplotlib.pyplot as plt

ax = stats["median"].plot.barh(figsize=(8, 6))
ax.set_title("Median Netflix film length by genre (100+ films)")
ax.set_xlabel("Median length (minutes)")
ax.set_ylabel("Genre")
plt.show()
No description has been provided for this image
import os
os.makedirs("output/pandas", exist_ok=True)
stats.to_csv("output/pandas/l19_genre_lengths.csv")
check = pd.read_csv("output/pandas/l19_genre_lengths.csv")
print(check.shape)
print(check.iloc[[0, -1]].to_string(index=False))
(15, 3)
          genre  median  size
Stand-Up Comedy    65.0   329
 Classic Movies   112.0   103

Finding: stand-up specials run about 65 minutes; dramas and classics 109 to 112. Newer films run shorter too: 108 minutes in the 2000s, 94 in 2020.

Recap#

  • Text methods live behind .str: lower, strip, len
  • Strip spaces before converting text to dates or numbers
  • contains, startswith, endswith give True/False masks
  • Use regex=False when searching for symbols like (
  • split + expand makes columns; split + explode makes rows
  • extract with a pattern pulls out numbers and words

Challenges#

  1. Which Netflix directors have the most titles? Watch for lists.
  2. Find the most common first word in Netflix film titles.
  3. Count TV shows by number of seasons, using length and unit.

What's next#

Lesson 20: Handling Outliers and Data Validation. Spotting extreme values, and checking data follows the rules.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.