Урок курса

Текстовые столбцы: нормализация и разбор через .str

NumPy и pandas: практический тренажёр

В прошлом уроке мы разбирались с пропусками: обнаруживали их, удаляли или заполняли. Там же появился StringDtype — тип, специально заточенный под текстовые столбцы. В этом уроке переходим к следующему шагу: что делать с самим текстом, когда данные пришли грязными — с лишними пробелами, случайным регистром, склеенными подстроками.

Аксессор .str: механизм поэлементной работы со строками

Когда в таблице есть текстовый столбец, первый инстинкт — пройтись по нему циклом и применить нужную строковую функцию к каждой ячейке. Это работает, но требует явного кода и не даёт pandas возможности обрабатывать данные в своём стиле. pandas предлагает стандартный способ — аксессор .str.

Аксессор — это особый «мост» между Series и строковыми методами Python. Обращаясь к series.str, вы получаете объект, который применяет строковые операции ко всем элементам Series поэлементно, без явного цикла в вашем коде.

Механика простая: series.str.method(...) берёт каждый элемент Series, вызывает на нём метод и складывает результаты в новый Series той же длины и с тем же индексом.

import pandas as pd

s = pd.Series(['  Alice ', 'BOB', 'carol'])
print(s.str.lower())
# 0      alice
# 1        bob
# 2      carol
# dtype: object

Это идиоматичный pandas-стиль: вместо for-цикла вы описываете операцию декларативно, и pandas выполняет её внутри себя. Для object-столбцов прирост скорости по сравнению с циклом не гарантирован — он зависит от конкретной операции и версии pandas. Ценность .str прежде всего в читаемости, единообразии и корректной обработке пропусков.

Какие столбцы поддерживают .str. Аксессор работает с Series, у которых dtype равен object или StringDtype. Большинство текстовых столбцов при загрузке через read_csv получают тип object — это стандартный случай. StringDtype (pd.StringDtype()) — более строгий nullable-вариант, который явно обозначает, что в столбце только строки или pd.NA.

Поведение при пропусках. Если элемент в Series равен NaN или pd.NA, аксессор не падает с ошибкой. Он просто пропускает такой элемент и возвращает на его месте тот же NaN/pd.NA.

s_with_na = pd.Series(['Alice', None, 'carol'])
print(s_with_na.str.lower())
# 0    alice
# 1     None
# 2    carol
# dtype: object

Пропуски не превращаются в строку 'none' или пустую строку — они остаются пропусками. Если дальше нужна булева маска, это поведение стоит контролировать отдельно — об этом позже.

Исходный Series не изменяется. Каждый вызов .str.method() возвращает новый Series. Чтобы сохранить результат, нужно явно присвоить его столбцу или переменной.

Аксессор .str — стандартный инструмент для массовой работы с текстом в pandas. Всё, что разбирается в этом уроке — нормализация, разбивка, фильтрация — работает именно через него.

Нормализация текста: .str.lower, .str.strip, .str.replace

Самая частая причина, по которой одинаковые значения не совпадают при сравнении — это лишние пробелы и разный регистр. Строки 'Alice', ' alice ' и 'ALICE' для Python разные, хотя по смыслу одно и то же. Три метода закрывают этот класс проблем.

.str.strip() убирает пробельные символы с обоих концов строки — пробелы, табуляции, переносы строк. Если нужно чистить только одну сторону, есть .str.lstrip() (левый конец) и .str.rstrip() (правый).

.str.lower() приводит все символы к нижнему регистру. Для верхнего — .str.upper(), для заглавной первой буквы — .str.capitalize().

.str.replace(pat, repl) заменяет вхождения pat на repl. В pandas 2.x параметр regex=False по умолчанию, то есть pat трактуется буквально.

На практике эти три метода почти всегда идут цепочкой:

import pandas as pd

df = pd.DataFrame({
    'name': ['  Alice Smith ', '  BOB jones', 'carol BROWN  ']
})

df['name_clean'] = df['name'].str.strip().str.lower()
print(df['name_clean'].tolist())
# ['alice smith', 'bob jones', 'carol brown']

Сначала снимаем пробелы по краям, потом приводим к нижнему регистру. Порядок здесь не критичен для этих двух методов, но привычка ставить strip первым полезна: тогда последующие операции работают с уже чистым текстом.

Для замены, например, дефисов на подчёркивания:

df['name_underscored'] = df['name_clean'].str.replace(' ', '_', regex=False)
print(df['name_underscored'].tolist())
# ['alice_smith', 'bob_jones', 'carol_brown']

Ловушка с regex в .str.replace и .str.contains. Здесь легко ошибиться, потому что у двух методов разные дефолты.

В .str.replaceregex=False по умолчанию. Это значит, что строка '.' будет искаться буквально как точка, а не как «любой символ» из регулярных выражений. Если вы хотите использовать паттерн вроде r'\s+', нужно явно написать regex=True.

В .str.contains — наоборот, regex=True по умолчанию. Паттерн '.' там найдёт что угодно, потому что точка в регулярных выражениях означает любой символ.

Антипример:

s = pd.Series(['3.14', '314', 'abc'])

# Хотим найти строки с буквальной точкой:
print(s.str.contains('.'))          # regex=True по умолчанию — найдёт всё!
# 0    True
# 1    True
# 2    True

print(s.str.contains('.', regex=False))  # правильно — только '3.14'
# 0     True
# 1    False
# 2    False

Правило простое: для буквального поиска в .str.contains всегда передавайте regex=False. Для .str.replace с простой подстрокой regex=False уже стоит по умолчанию и менять его не нужно.

Каждый вызов возвращает новый Series — исходный столбец не затрагивается. Если результат нужен в таблице, присваивайте явно: df['col'] = df['col'].str.strip().

Разбивка строк через .str.split и извлечение подстрок

Нормализация приводит строки к единому виду, но иногда нужно пойти дальше — разрезать строку на части и положить каждую в отдельный столбец. Именно это делает .str.split.

Базовый вызов принимает строку-разделитель и возвращает Series, где каждый элемент — список подстрок:

import pandas as pd

s = pd.Series(['Иван Петров', 'Анна Смирнова', 'Дмитрий Орлов'])
print(s.str.split(' '))
# 0       [Иван, Петров]
# 1    [Анна, Смирнова]
# 2    [Дмитрий, Орлов]
# dtype: object

Списки внутри Series неудобны для дальнейшей работы. Чтобы сразу получить нормальный DataFrame с отдельными столбцами, передаём expand=True:

split_df = s.str.split(' ', expand=True)
print(split_df)
#          0          1
# 0      Иван     Петров
# 1      Анна   Смирнова
# 2   Дмитрий      Орлов

Столбцы получают целочисленные имена — 0, 1, 2 и так далее. Их сразу можно присвоить обратно в таблицу:

df = pd.DataFrame({'full_name': ['Иван Петров', 'Анна Смирнова', 'Дмитрий Орлов']})

split_df = df['full_name'].str.split(' ', expand=True)
df['first'] = split_df[0]
df['last']  = split_df[1]
print(df)
#        full_name    first      last
# 0    Иван Петров     Иван    Петров
# 1  Анна Смирнова     Анна  Смирнова
# 2  Дмитрий Орлов  Дмитрий     Орлов

Параметр n ограничивает максимальное число разбиений. Это полезно, когда разделитель может встречаться несколько раз, а нужно отрезать только первую часть:

s2 = pd.Series(['2024-01-15', '2023-12-03', '2024-07-22'])
split2 = s2.str.split('-', n=1, expand=True)
print(split2)
#       0      1
# 0  2024  01-15
# 1  2023  12-03
# 2  2024  07-22

С n=1 строка разбивается ровно один раз: до первого дефиса и всё остальное. Без n были бы три столбца.

Извлечение подстроки по позиции. Когда не нужно разбивать строку, а нужно вырезать конкретный диапазон символов, используют .str.slice(start, stop) или краткую форму — индексацию через .str[start:stop]:

s3 = pd.Series(['2024-01-15', '2023-12-03', '2024-07-22'])

print(s3.str.slice(0, 4))   # только год
# 0    2024
# 1    2023
# 2    2024
# dtype: object

print(s3.str[5:7])          # то же через краткую форму — месяц
# 0    01
# 1    12
# 2    07
# dtype: object

Обе формы эквивалентны. .str[i] без среза извлекает один символ по индексу — первый символ строки это .str[0].

Результат .str.split(expand=True) — полноценный DataFrame, с которым можно работать как с любой другой таблицей: переименовывать столбцы, фильтровать, склеивать обратно. Это основной способ структурировать «сжатые» данные, когда несколько значений хранятся в одной ячейке через разделитель.

Фильтрация строк: .str.contains и .str.startswith

После нормализации и разбивки текста следующий практический шаг — отобрать строки по текстовому условию. Для этого в pandas есть два метода: .str.contains и .str.startswith. Оба возвращают булевый Series, который сразу можно передать в df.loc.

.str.startswith(prefix) проверяет, начинается ли каждая строка с заданного префикса. Всегда буквальный — без регулярных выражений.

.str.contains(pat) проверяет вхождение подстроки или паттерна. По умолчанию regex=True, то есть pat интерпретируется как регулярное выражение. Для буквального поиска нужно явно передать regex=False.

import pandas as pd

df = pd.DataFrame({
    'name': ['alice smith', 'bob jones', 'carol brown', 'alice johnson']
})

# Все строки, начинающиеся с 'alice'
mask_alice = df['name'].str.startswith('alice')
print(df.loc[mask_alice])
#            name
# 0   alice smith
# 3  alice johnson

# Все строки, содержащие 'jones' (буквально)
mask_jones = df['name'].str.contains('jones', regex=False)
print(df.loc[mask_jones])
#         name
# 1  bob jones

Маска работает ровно так же, как любая другая булева маска в df.loc — механизм тот же, что использовался для числовых условий.

Пропуски в исходном столбце: поведение зависит от dtype.

Если столбец имеет тип object (стандартный при загрузке через read_csv), то при наличии None в строке результат .str.contains для этой строки будет None — и передача такой маски в df.loc вызовет ValueError:

df_obj = pd.DataFrame({'name': ['alice smith', None, 'carol brown']})
# dtype: object

print(df_obj['name'].str.contains('alice', regex=False))
# 0     True
# 1     None
# 2    False
# dtype: object

# df_obj.loc[mask]  — вызовет ValueError из-за None в маске

Если столбец явно создан с dtype='string' (StringDtype), пропуск даёт pd.NA в nullable boolean-маске. В pandas 2.x df.loc принимает такую маску, трактуя pd.NA как «строка не выбрана» — ошибки нет.

Однако полагаться на это различие в коде неудобно: поведение зависит от dtype столбца, который не всегда очевиден. Универсальное решение — передавать na=False. Тогда пропуск явно трактуется как «не совпало», и маска всегда двухзначная (True/False) независимо от dtype:

# Работает одинаково для object и StringDtype:
mask = df_obj['name'].str.contains('alice', regex=False, na=False)
print(df_obj.loc[mask])
#           name
# 0  alice smith

na=False — стандартная страховка всякий раз, когда столбец мог прийти с пропусками.

Пример: нормализация → разбивка → фильтрация. Все три шага урока на одном наборе данных:

import pandas as pd

df = pd.DataFrame({
    'raw': ['  Alice Smith ', '  BOB jones', 'carol BROWN  ', '  Alice Johnson']
})

# Шаг 1: нормализация
df['clean'] = df['raw'].str.strip().str.lower()

# Шаг 2: разбивка на имя и фамилию
split_df = df['clean'].str.split(' ', expand=True)
df['first'] = split_df[0]
df['last']  = split_df[1]

# Шаг 3: фильтрация — только записи с именем 'alice'
mask = df['first'].str.startswith('alice')
result = df.loc[mask, ['first', 'last']]
print(result)
#    first     last
# 0  alice    smith
# 3  alice  johnson

После нормализации фильтрация работает предсказуемо: нет случайных промахов из-за разного регистра или пробелов. Именно поэтому strip + lower делают первыми, а .str.contains / .str.startswith применяют уже к чистым данным.

Если нужно составное условие — например, имя начинается с 'alice' и фамилия содержит 'sm' — маски комбинируют через &:

mask2 = (df['first'].str.startswith('alice') &
         df['last'].str.contains('sm', regex=False))
print(df.loc[mask2, ['first', 'last']])
#    first   last
# 0  alice  smith

Обе маски — обычные булевы Series, и правила их комбинирования те же, что для любых других условий фильтрации в pandas.

Попробуйте решить

Series s = pd.Series(['Hello World', 'foo bar', 'PYTHON']). Что вернёт s.str.lower().tolist()?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку