Урок курса

Текстовые столбцы: нормализация и разбор через .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.replace — regex=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.

Задача 1

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

DataFrame df содержит столбец 'code' со значениями ['RU-001', 'US-RU-042', 'RU-007', 'DE-100']. Какой вызов вернёт булев Series с True только для строк, начинающихся с 'RU'?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Каталог товаров: split, startswith и contains без циклов

Что уже дано

Проверяющая система запускает вашу программу и подаёт данные через stdin. Никаких заранее созданных переменных нет — вы читаете всё сами.

Что нужно сделать

Напишите программу, которая обрабатывает каталог товаров:

  1. Читает из stdin целое число n — количество строк каталога.
  2. Читает следующие n строк; каждая содержит один токен — артикул товара в формате КАТЕГОРИЯ-МОДЕЛЬ-НОМЕР, например Phone-Galaxy-12 или Tablet-iPad-5. Токены не содержат пробелов внутри.
  3. Создаёт pd.Series с именем codes из прочитанных строк.
  4. Разбивает codes по символу '-' через .str.split('-', expand=True) и сохраняет результат в parts. Это DataFrame с тремя столбцами 0, 1, 2.
  5. Создаёт pd.Series с именем category — столбец parts[0], приведённый к нижнему регистру через .str.lower().
  6. Создаёт булев pd.Series с именем is_phone — True там, где category начинается с 'phone', через .str.startswith('phone').
  7. Создаёт булев pd.Series с именем has_digit — True там, где исходный codes содержит хотя бы одну цифру, через .str.contains(r'\d', regex=True).
  8. Выводит в stdout ровно четыре строки:
  • Строка 1: значения category через пробел.
  • Строка 2: значения is_phone через пробел (True/False).
  • Строка 3: значения has_digit через пробел (True/False).
  • Строка 4: артикулы из codes, для которых is_phone равно True, через пробел. Если таких нет — пустая строка.

Запрещено использовать Python-циклы для вычислений в шагах 4–7. Генераторные выражения разрешены только для форматирования строк вывода в шаге 8.

Ввод и вывод

Программа читает stdin и пишет в stdout.

Формат ввода

Первая строка — целое число n (1 ≤ n ≤ 20).
Следующие n строк — артикулы вида КАТЕГОРИЯ-МОДЕЛЬ-НОМЕР без пробелов.

Формат вывода

Ровно четыре строки в порядке, описанном выше. Значения внутри строки разделяются одним пробелом.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 4

Нормализация адресов: strip, lower и replace без циклов

Что уже дано

Проверяющая система запускает вашу программу и подаёт данные через stdin. Никаких заранее созданных переменных нет — вы читаете всё сами.

Что нужно сделать

Напишите программу, которая нормализует таблицу адресов:

  1. Читает из stdin целое число n — количество строк.
  2. Читает следующие n строк; каждая содержит один токен — «грязный» адрес (строка, которая может начинаться и заканчиваться пробелами, содержать буквы в произвольном регистре и дефисы вместо пробелов между словами). Токен считывается через input() — пробелы внутри токена сохраняются.
  3. Создаёт pd.Series с именем raw из прочитанных строк.
  4. Создаёт pd.Series с именем step1 — результат .str.strip() на raw.
  5. Создаёт pd.Series с именем step2 — результат .str.lower() на step1.
  6. Создаёт pd.Series с именем step3 — результат .str.replace('-', ' ', regex=False) на step2.
  7. Выводит в stdout ровно три строки:
  • Строка 1: значения step1 через | (пробел-вертикальная черта-пробел).
  • Строка 2: значения step2 через |.
  • Строка 3: значения step3 через |.

Запрещено использовать Python-циклы для шагов 4–7.

Ввод и вывод

Программа читает stdin и пишет в stdout.

Формат ввода

Первая строка — целое число n (1 ≤ n ≤ 10).
Следующие n строк — адреса; каждый адрес — одна строка, возможно с пробелами по краям и дефисами внутри.

Формат вывода

Ровно три строки. Значения внутри строки разделяются |.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 5

Найди и исправь: три ошибки в .str.replace, .str.contains и .str.split

Что уже дано

Проверяющая система запускает вашу программу и подаёт данные через stdin. Никаких заранее созданных переменных нет.

В редакторе уже есть сломанный код. В нём ровно три ошибки в трёх независимых строках:

  1. slug вычисляется через .str.replace('-', '_', regex=False) — заменяется дефис, хотя по условию нужно заменить пробел на подчёркивание через .str.replace(' ', '_', regex=False).
  2. mask_digit строится через .str.contains(r'\d', regex=False) — из-за regex=False паттерн \d ищется буквально как строка \d, а не как цифра. Нужно убрать regex=False или явно написать regex=True.
  3. parts получается через .str.split('-', expand=False) — возвращается Series из списков вместо DataFrame. Нужно expand=True.

Каждая ошибка — в отдельной строке. Исправьте все три.

Что нужно сделать

Программа должна:

  1. Читать из stdin целое число n — количество строк.
  2. Читать следующие n строк; каждая содержит одну строку вида СЛОВО МЕТКА-СУФФИКС, например hello 3-pro или world x-lite. Токен читается через input() — пробел внутри токена сохраняется.
  3. Создавать pd.Series с именем raw из прочитанных строк.
  4. Создавать pd.Series с именем slug — результат .str.replace(' ', '_', regex=False) на raw (заменить пробел на подчёркивание).
  5. Создавать булев pd.Series с именем mask_digit — True там, где raw содержит хотя бы одну цифру, через .str.contains(r'\d', regex=True).
  6. Создавать pd.DataFrame с именем parts через .str.split('-', expand=True) на slug — столбцы 0 и 1.
  7. Выводить в stdout ровно три строки:
  • Строка 1: значения slug через пробел.
  • Строка 2: значения mask_digit через пробел (True/False).
  • Строка 3: значения столбца parts[1] через пробел.

Ввод и вывод

Программа читает stdin и пишет в stdout.

Формат ввода

Первая строка — целое число n (1 ≤ n ≤ 10).
Следующие n строк — строки вида СЛОВО МЕТКА-СУФФИКС, например hello 3-pro или world x-lite.

Формат вывода

Ровно три строки в порядке, описанном выше. Значения внутри строки разделяются одним пробелом.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 6

Журнал регистраций: домен через .str.split и фильтрация по домену через .str.contains

Что уже дано

Проверяющая система запускает вашу программу и подаёт данные через stdin. Никаких заранее созданных переменных нет — вы читаете всё сами.

Что нужно сделать

Напишите программу, которая анализирует журнал регистраций пользователей:

  1. Читает из stdin целое число n — количество строк журнала.
  2. Читает следующие n строк; каждая содержит адрес электронной почты в формате имя@домен, например alice@gmail.com или BOB@MAIL.RU. Вокруг адреса могут быть пробелы, но внутри самого адреса пробелов нет.
  3. Создаёт pd.Series с именем emails из прочитанных строк.
  4. Создаёт pd.Series с именем emails_clean — результат .str.strip().str.lower() на emails.
  5. Разбивает emails_clean по символу '@' через .str.split('@', expand=True) и сохраняет результат в parts (DataFrame с двумя столбцами 0 и 1).
  6. Создаёт pd.Series с именем domain — столбец parts[1] (доменная часть адреса).
  7. Создаёт булев pd.Series с именем is_gmail — True там, где domain содержит подстроку 'gmail', через .str.contains('gmail', regex=False).
  8. Создаёт булев pd.Series с именем is_ru — True там, где domain оканчивается на '.ru', через .str.endswith('.ru').
  9. Выводит в stdout ровно четыре строки:
  • Строка 1: значения emails_clean через пробел.
  • Строка 2: значения domain через пробел.
  • Строка 3: значения is_gmail через пробел (True/False).
  • Строка 4: адреса из emails_clean, для которых is_ru равно True, через пробел. Если таких нет — пустая строка.

Запрещено использовать Python-циклы для шагов 4–9.

Ввод и вывод

Программа читает stdin и пишет в stdout.

Формат ввода

Первая строка — целое число n (1 ≤ n ≤ 15).
Следующие n строк — адреса электронной почты; вокруг адреса допустимы пробелы; после .str.strip() внутри адреса пробелов нет.

Формат вывода

Ровно четыре строки в порядке, описанном выше. Значения внутри строки разделяются одним пробелом.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

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