Семестр 3 · Модуль 3 · Неделя 9

Regex advanced и основы NLP

Группы и named groups · lookahead/lookbehind · флаги · жадность · токенизация · стемминг vs лемматизация · Bag-of-Words · TF-IDF

2 ч теории. Данные — access.log (80 строк), contact_text.txt, news_headlines.csv.

Прежде чем начать: откройте access.log и посмотрите на одну строку — сколько полей «зашито» в ней?

Логи сервера — где живёт regex

192.168.1.10 - - [01/Sep/2026:08:00:58 +0000] "POST /images/logo.png HTTP/1.1" 200 22093 "-" "python-requests/2.31.0"

Одна строка = уже данные: IP, дата, метод, URL, статус, байты. Регулярные выражения — способ их достать за один проход.

Какие поля вы видите в этой строке? Как бы вы извлекли IP и статус, если бы не знали regex?

Retrieval по неделе 8: p0 и экстраполяция

  1. Не открывая конспект: что будет с curve_fit, если не задать p0? (застрянет в локальном минимуме — параметры «мусорные»).
  2. Почему экстраполяция interp1d опасна? (правдоподобные, но бессмысленные значения).
Что из недели 8 войдёт в проект модуля?

К концу лекции вы сможете…

Что из сегодняшней лекции попадёт в проект модуля?

Повторение базы: метасимволы и классы

КонструкцияСмыслПример
.любой символa.c → «abc»
^ $начало / конец строки^GET
* + ?0+, 1+, 0..1 повторов\d+
[...]класс символов[0-9]+
\d \w \sцифра / буква / пробел\d{4}-\d{2}-\d{2}
{n,m}от n до m повторов\d{1,3}
Какой паттерн найдёт все даты вида 2026-09-01?

Квантификаторы и классы на датах и IP

import re re.findall(r'\d{4}-\d{2}-\d{2}', 'Конференция 2026-11-15, выезд 2026-11-16') ['2026-11-15', '2026-11-16'] re.findall(r'\d{1,3}(?:\.\d{1,3}){3}', 'IP: 192.168.1.10 и 172.16.5.3') ['192.168.1.10', '172.16.5.3']

{n,m} задаёт число повторов; класс [...] — перечень допустимых символов; скобки (?:...) — группировка без захвата (разберём далее).

Зачем в паттерне IP нужны скобки (?:...)? Что изменится, если их убрать?

Группы (...): захват частей строки лога

line = '192.168.1.10 - - [01/Sep/2026:08:00:58 +0000] "POST /images/logo.png HTTP/1.1" 200 22093' m = re.search(r'(\d{1,3}(?:\.\d{1,3}){3}) .*? (\d{3}) (\d+)', line) m.groups() ('192.168.1.10', '200', '22093')

(...) — захватывающая группа: то, что совпало внутри, можно достать по номеру через m.groups(). Скобки делают из «найти» — «извлечь»: три поля одной маской.

Почему в IP используется (?:...), а не (...)?

Named groups (?P<name>...): читаемый доступ к полям

pattern = re.compile( r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3}) - - \[(?P<date>[^\]]+)\] ' r'"(?P<method>[A-Z]+) (?P<url>\S+) HTTP/\d\.\d" ' r'(?P<status>\d{3}) (?P<bytes>\d+)') m = pattern.search(line) m.group('ip'); m.group('status') '192.168.1.10' '200'

Named groups (?P<name>...) — обращение по имени (m.group('ip')), а не по номеру; m.groupdict() — словарь всех полей. Это «интерфейс» парсера: поля именованы и не зависят от порядка в паттерне.

Чем group('ip') лучше group(1)?

Построчный разбор паттерна access.log

(?P<ip>\d{1,3}(?:\.\d{1,3}){3}) # IP: 1–3 цифры, трижды «точка + 1–3 цифры» - - \[(?P<date>[^\]]+)\] # литералы " - - ["; дата = всё, кроме ']' (в ней пробел!) "(?P<method>[A-Z]+) # метод — только заглавные буквы (POST/GET/...) (?P<url>\S+) HTTP/\d\.\d" # URL — непробельные; литерал "HTTP/1.1" (?P<status>\d{3}) # статус — 3 цифры (?P<bytes>\d+) # байты — 1+ цифр

Ключевые решения: [^\]]+ для даты — внутри неё пробел, поэтому \S+ не подойдёт, а .* жадный; \S+ для URL — путь без пробелов; HTTP/\d\.\d — литерал с экранированной точкой.

Почему для даты нужен [^\]]+, а не \S+?

Predict: что вернёт groupdict() на строке лога

line = '192.168.1.10 - - [01/Sep/2026:08:00:58 +0000] "POST /images/logo.png HTTP/1.1" 200 22093 "-" "python-requests/2.31.0"' m = pattern.search(line) print(m.group('url')); print(m.group('date'))
Не запуская: что выведется? Почему url не захватил HTTP/1.1, а date содержит пробел и +0000?

re.match vs re.search

re.match(pattern, line) # ищет СОВПАДЕНИЕ С НАЧАЛА строки re.search(pattern, line) # ищет ПЕРВОЕ совпадение в любом месте
re.match(pattern, ' ' + line) # ведущие пробелы в начале None # match привязан к началу — «ничего не нашлось» re.search(pattern, ' ' + line) Match # search находит и не с начала

Правило: для поиска по тексту используем search; match — только когда нужна привязка к началу.

На какой строке лога match и search дадут разный результат?

finditer → DataFrame: лог становится таблицей

import pandas as pd log_text = open('access.log', encoding='utf-8').read() rows = [m.groupdict() for m in pattern.finditer(log_text)] df = pd.DataFrame(rows) df.head()
ipdatemethodurlstatusbytes
192.168.1.1001/Sep/2026:08:00:58 +0000POST/images/logo.png20022093
198.51.100.201/Sep/2026:08:06:36 +0000PUT/static/style.css500264
192.168.1.1001/Sep/2026:08:06:20 +0000DELETE/api/users20065134
192.168.1.4501/Sep/2026:08:08:49 +0000PUT/download/report.pdf20080511
192.168.1.2201/Sep/2026:08:09:33 +0000DELETE/api/orders/42200402421

finditer перебирает все совпадения; каждая группа — колонка DataFrame. Дальше — обычный pandas. Всё 80 строк файла распарсены (len(df) == 80).

Как из этого DataFrame посчитать распределение статусов по классам 2xx/4xx/5xx?

Агрегации: топ-10 IP, топ-10 URL, статусы 2xx/5xx

df['status'] = df['status'].astype(int) top_ip = df['ip'].value_counts().head(10) top_url = df['url'].value_counts().head(10) def bucket(s): if s < 300: return '2xx' if s < 400: return '3xx' if s < 500: return '4xx' return '5xx' statuses = df['status'].apply(bucket).value_counts()
60 40 20 0 2xx 55 3xx 5 4xx 9 5xx 11 число ответов
«Распределение статусов (2xx/3xx/4xx/5xx)»: ось Y с нуля, классы по порядку. Источник: access.log, 80 строк.

Числа: топ-1 IP — 192.168.1.10 (17 запросов; в логе 9 уникальных IP); топ-1 URL — /images/logo.png (10, в паре с /api/items — 10); статусы: 2xx = 55, 3xx = 5, 4xx = 9, 5xx = 11. 11 ответов 5xx ≈ 14% — сервер «отвечает внутренней ошибкой» на заметной доле запросов.

Почему value_counts() по сырому статусу (200, 301, 404, 500) — не то, что нужно?

Live-coding: parse_access_log → DataFrame + графики

  1. def parse_access_log(path) -> pd.DataFrame — сигнатура из autograder-спеки;
  2. читать файл, скомпилировать паттерн, finditer → rows → DataFrame;
  3. astype(int) для status и bytes;
  4. топ-10 IP, топ-10 URL, распределение статусов;
  5. подписанные bar-графики → figures/logs_top10.png, figures/logs_status.png.
Зачем приводить типы status/bytes к int до агрегации? Почему finditer, а не match?

Lookahead (?=...): число перед %

re.findall(r'\d+(?=%)', 'ВВП вырос на 4%, инфляция 3%') ['4', '3'] # (?=...) — позиция, после которой идёт %

(?=X) — lookahead: совпадение происходит, только если после него идёт X; X в результат не включается. Контраст: \d+% вернул бы ['4%', '3%'] — с символом.

Чем \d+(?=%) отличается от \d+%? Что из них попадёт в результат?

Lookbehind (?<=...): фиксированная длина, иначе re.error

re.findall(r'(?<=£)\d+', '£12 и £45') ['12', '45'] # (?<=X) — перед совпадением X
re.findall(r'(?<=\d+)\w', 'abc123') # переменная длина! re.error: look-behind requires fixed-width pattern

(?<=X) — lookbehind: перед совпадением должно стоять X. В Python lookbehind — только фиксированной длины: (?<=\d+) даёт re.error. Это ограничение движка, а не ваша ошибка.

Почему (?<=\d+) даёт ошибку, а (?<=£) — нет?

Отрицательные lookaround (?!...), (?<!...)

re.findall(r'\d+(?!%)', 'ВВП 4%, а курс 75 руб') ['75'] # '4' отброшено: после него идёт %; '75' целиком: после '5' нет %

(?!X) — отрицательный lookahead: совпадение возможно, только если после него нет X. Неочевидность: '4' исключено (после него %), а '75' совпало целиком — жадный \d+ берёт всё число, и проверка смотрит за его конец, а не за каждую цифру.

Почему '4' не попало в результат, а '75' попало целиком?

Жадный .* «съедает» два тега — ленивый .*? спасает

re.findall(r'<p>.*</p>', text) ['<p>a</p><p>b</p>'] # жадный * «съел» два тега
re.findall(r'<p>.*?</p>', text) ['<p>a</p>', '<p>b</p>'] # ленивый *?

* и + жадные: захватывают максимум до последнего возможного совпадения; *? и +? ленивые: минимум до первого. Для парных тегов/кавычек почти всегда нужен ленивый вариант.

Что искал жадный .* — первый или последний </p>? Почему получился один «тег» на всю строку?

Флаги: IGNORECASE / MULTILINE / DOTALL / VERBOSE

ФлагЭффект
re.IGNORECASEбез учёта регистра (get = GET)
re.MULTILINE^/$ — на каждой строке, не только в начале/конце текста
re.DOTALLточка совпадает и с переносом строки
re.VERBOSEпробелы и #-комментарии в паттерне игнорируются
re.compile(r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3})', re.IGNORECASE | re.VERBOSE)

Флаги передаются в re.compile (или как 4-й аргумент функций) и комбинируются через |. re.VERBOSE полезен для длинных паттернов — их можно писать с комментариями.

Зачем нужен re.VERBOSE для паттерна access.log?

re.compile: методы findall/finditer/search/match/fullmatch/sub

pat = re.compile(r'(\d{4})-(\d{2})-(\d{2})') pat.findall('2026-11-15 и 2026-11-16') [('2026', '11', '15'), ('2026', '11', '16')] pat.sub(r'\3.\2.\1', 'конференция 2026-11-15') 'конференция 15.11.2026'
МетодЧто делает
findallвсе совпадения
finditerитератор с позициями (match.start(), match.end())
searchпервое совпадение в любом месте
matchс начала строки
fullmatchвся строка целиком
subзамена с обратными ссылками \1
Чем search отличается от match? А fullmatch от search?

re.escape: пользовательский ввод — не «регулярное выражение»

user_input = 'search.me?' re.findall(user_input, text) # '.' и '?' — метасимволы! re.findall(re.escape(user_input), text) # 'search\.me\?'

Если подставляете в паттерн строку от пользователя/из файла — метасимволы интерпретируются. re.escape экранирует все спецсимволы — ищется буквально. Данные ≠ код; чужая строка — это данные.

Что найдёт re.findall('a.b', 'a.b') без escape? А с re.escape('a.b')?

Токенизация word_tokenize и стоп-слова

from nltk.tokenize import word_tokenize from nltk.corpus import stopwords sentence = ("The researchers were running machine learning experiments " "and analyzed the better results.") tokens = word_tokenize(sentence.lower()) ['the', 'researchers', 'were', 'running', 'machine', 'learning', 'experiments', 'and', 'analyzed', 'the', 'better', 'results', '.'] words = [w for w in tokens if w.isalpha() and w not in stopwords.words('english')] ['researchers', 'running', 'machine', 'learning', 'experiments', 'analyzed', 'better', 'results']

Токенизация — разбиение текста на токены (слова, знаки); стоп-слова (the, and, was…) — частые, но не несущие смысла; удаляются перед анализом. Важны .lower() (регистр) и .isalpha() (убрать пунктуацию).

Почему перед векторизацией удаляют стоп-слова?

Стемминг vs лемматизация: «better» → «good»

from nltk.stem import PorterStemmer, WordNetLemmatizer ps = PorterStemmer(); lm = WordNetLemmatizer() ps.stem('running'); lm.lemmatize('running', pos='v') 'run' 'run' ps.stem('better'); lm.lemmatize('better', pos='a') 'better' 'good'

Стеммер — грубое обрезание суффиксов по правилам: быстро, но running→run, а better не меняет. Лемматизатор — приведение к словарной форме по словарю (WordNet): better→good, was→be. Точнее, медленнее, требует словарь. Для русского — SnowballStemmer('russian').

Что вернёт lm.lemmatize('better') без pos='a'?

Predict: что сделает стеммер/лемматизатор с 5 словами

for w in ['running', 'researchers', 'analyzed', 'better', 'experiments']: print(w, '→', ps.stem(w), '|', lm.lemmatize(w, pos='v'))
Не запуская: что напечатается? Помните: стеммер режет грубо, лемматизатор зависит от pos.

Bag-of-Words: CountVectorizer, матрица «документ × термин»

from sklearn.feature_extraction.text import CountVectorizer docs = ['the cat sat on the mat', 'the dog sat on the log'] cv = CountVectorizer() X = cv.fit_transform(docs) X.toarray() [[1 0 0 1 1 1 2] [0 1 1 0 1 1 2]] cv.get_feature_names_out() ['cat' 'dog' 'log' 'mat' 'on' 'sat' 'the']

Bag-of-Words — матрица «документ × термин», ячейка = частота термина в документе; порядок слов теряется («мешок слов»). Проблема: частые термины («the» — 2 в каждом документе) доминируют.

В какой ячейке матрицы стоит «2» и почему?

TF-IDF на новостных заголовках: топ-термины

from sklearn.feature_extraction.text import TfidfVectorizer headlines = pd.read_csv('news_headlines.csv')['headline'].tolist() tv = TfidfVectorizer(stop_words='english') Xt = tv.fit_transform(headlines) # топ-3 термина каждого заголовка (news_headlines.csv, 6 док., 38 терм.): doc1: chess 0.403 · better 0.403 · learns 0.403 doc2: weather 0.397 · train 0.397 · predict 0.397 doc3: language 0.408 · data 0.408 · python 0.408 doc6: use 0.393 · analyze 0.393 · papers 0.393

TF-IDF выделил «chess», «python» — редкие термины, которые отличают документ от остальных. Обычные слова вроде «model», «neural» весят меньше. Внутри топ-3 веса равны — привязка к версии sklearn.

Почему «chess» поднялся в топ, хотя встречается один раз?

Зачем IDF: редкие поднимаются, частые опускаются

idf(t) = ln(N / df(t) + 1) TF-IDF = TF × IDF

Редкие «специфичные» слова («chess», «xray») поднимаются, общие («model», «data») — опускаются. Стоп-слова удаляются до расчёта — иначе топ забит союзами. TF-IDF находит не «главные слова текста», а слова, отличающие документ от остальных.

Почему «the» почти не влияет на TF-IDF?

Извлечение сущностей из contact_text.txt

text = open('contact_text.txt', encoding='utf-8').read() emails = re.findall(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+', text) phones = re.findall(r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', text) dates = re.findall(r'\d{4}-\d{2}-\d{2}', text) urls = [u.rstrip('.,;') for u in re.findall(r'https?://[\w./-]+', text)] 2 email: org@dataconf.ru, sponsor@ml-lab.org 3 телефона: +7 (495) 123-45-67, 8-800-555-35-35, +7 916 987-65-43 5 дат: 2026-11-15 … 2 ссылки: https://dataconf.ru/program, https://dataconf.ru/tickets

Типичная задача парсинга объявлений/писем: телефон — (?:\+7|8) (два формата); URL — https?://[\w./-]+ + rstrip('.,;') (точка в конце предложения).

Почему email-паттерн заканчивается на (?:\.[\w-]+)+, а не \.[a-z]{2,3}?

Вопросы для проверки понимания

  1. Как извлечь все email-адреса из текста (паттерн + named groups)?
  2. Чем (?<=...) отличается от (?=...)?
  3. Какой квантификатор ленивый и зачем он нужен?
  4. Чем TF-IDF отличается от Bag-of-Words и зачем IDF?
  5. Чем стемминг отличается от лемматизации (пример «better»)?
Можете ли вы ответить на все пять письменно за 3 минуты? Соберём 2–3 ответа.

Типичные ошибки недели 9

  1. re.match вместо re.search: match ищет только с начала строки; на логе «ничего не нашлось».
  2. Жадный квантификатор «съедает» лишнее — нужен ленивый *?/+?.
  3. Lookbehind переменной длины — re.error в Python; это ограничение движка, не ваша ошибка.
  4. Забыт re.escape для пользовательского ввода.
  5. Стемминг и лемматизация перепутаны («better» → «good» делает только лемматизатор с pos='a').
  6. TF-IDF без удаления стоп-слов — топ-термины забиты союзами.
  7. Паттерн без (?:...) — в groupdict() лезут лишние группы.
Студент пишет re.match(pattern, line) на логе и получает None. Что он перепутал и как чинить?

Проект модуля: парсер логов — блок 3 и сборка

Что входит в каждый из трёх блоков проекта модуля?

Итоги: 6 навыков; семинар; проект; one-minute paper

Готовы? Откройте семинар-9.md и распарсите первую строку access.log с named groups.