Словари Python (dict) как инструмент частотного анализа текста

Кто из нас не сталкивался с задачей «посчитай, сколько раз встречается каждое слово в логе»? Или «выведи топ-10 доменов из почтового дампа»? Мы в команде решаем такое регулярно, и каждый раз основной рабочий инструмент - обычный Python-словарь. Тип dict реализует ассоциативный массив на основе хеш-таблицы и закрывает целый класс задач, где нужно связать произвольный ключ с произвольным значением. Частотный анализ текста - подсчёт слов, символов, доменов - каноническое применение этой структуры.
Сегодня разберём внутреннюю механику dict, паттерны подсчёта, работу с файлами, продвинутые инструменты стандартной библиотеки и типичные грабли. С примерами кода и объяснениями, почему оно работает именно так.
Внутренняя механика dict: что под капотом
Python реализует словарь как хеш-таблицу. При добавлении пары ключ-значение интерпретатор вычисляет хеш ключа через встроенную hash(), определяет позицию в массиве слотов и помещает туда значение. Это обеспечивает амортизированную сложность O(1) на вставку и поиск. Быстро, предсказуемо, надёжно.
Требования к ключам
Ключом может быть только хешируемый (неизменяемый) объект: строка, число, кортеж из неизменяемых элементов. Списки и словари использовать нельзя - попытка приведёт к TypeError: unhashable type. Значения могут быть любого типа, включая вложенные структуры.
# Допустимые ключи
d = {
'name': 'Alice',
42: 'число',
(1, 2): 'кортеж',
}
# Ошибка: список не хешируется
# d = {[1, 2]: 'список'} # TypeError: unhashable type: 'list'
Порядок элементов
Начиная с CPython 3.7 (и гарантированно в спецификации с Python 3.7+) словарь сохраняет порядок вставки. В более ранних версиях порядок ключей не определён. Если пишете переносимый код, полагаться на порядок стоит только для 3.7+.
Потребление памяти
Хеш-таблица ест заметно больше памяти, чем список аналогичного размера - внутренний массив слотов выделяется с запасом для минимизации коллизий. Для словаря из 1 000 000 записей потребление может составлять 70-100 МБ. При работе с очень большими наборами данных стоит рассмотреть потоковую обработку или специализированные структуры.
Словарь как набор счётчиков: базовый паттерн
Центральная идея - использование словаря для построения гистограммы. Пример подсчёта символов:
word = 'brontosaurus'
d = dict()
for c in word:
if c not in d:
d[c] = 1
else:
d[c] = d[c] + 1
print(d)
# {'b': 1, 'r': 2, 'o': 2, 'n': 1, 't': 1, 's': 2, 'a': 1, 'u': 2}
Шаблон из трёх элементов: инициализация пустого dict(), проверка наличия ключа оператором in, условное создание или инкремент значения. Оператор not in выполняет поиск в хеш-таблице за O(1).
Идиома dict.get(): убираем лишние if-ы
Метод get(key, default) возвращает значение по ключу, если он существует, иначе - значение по умолчанию. Сворачивает блок if/else в одну строку:
word = 'brontosaurus'
d = dict()
for c in word:
d[c] = d.get(c, 0) + 1
print(d)
Сравнение подходов
Характеристика
if/in
get()
Строк в цикле
4
1
Читаемость для новичка
Выше (явная логика)
Ниже (нужно знать API)
Производительность
Идентичная
Идентичная
Идиоматичность
Базовая
Продвинутая
setdefault(): когда значение по умолчанию - мутабельный объект
Метод setdefault(key, default) возвращает значение по ключу, а если ключ отсутствует - устанавливает его со значением default и возвращает это значение. Удобен для группировки:
# Группировка слов по первой букве
words = ['apple', 'avocado', 'banana', 'blueberry', 'cherry']
groups = {}
for w in words:
groups.setdefault(w[0], []).append(w)
print(groups)
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
Для простого подсчёта get() остаётся предпочтительным вариантом.
collections.Counter: промышленный подсчёт
Модуль collections содержит класс Counter, который инкапсулирует весь паттерн подсчёта в одну конструкцию:
from collections import Counter
word = 'brontosaurus'
counter = Counter(word)
print(counter)
# Counter({'r': 2, 'o': 2, 's': 2, 'u': 2, 'b': 1, 'n': 1, 't': 1, 'a': 1})
# Три самых частых символа
print(counter.most_common(3))
# [('r', 2), ('o', 2), ('s', 2)]
Counter наследуется от dict, поэтому поддерживает все стандартные операции. Плюс даёт бонусы:
- most_common(n) - топ-N элементов по частоте.
- Арифметика счётчиков: сложение, вычитание, пересечение (+, -, &, |).
- elements() - итератор, повторяющий каждый элемент столько раз, сколько указано.
# Подсчёт слов в тексте - одна строка
from collections import Counter
with open('romeo.txt') as f:
word_counts = Counter(f.read().lower().split())
for word, count in word_counts.most_common(10):
print(f'{word:15} {count}')
Для учебных целей важно понимать ручной паттерн, но в рабочем коде Counter - стандартный инструмент.
collections.defaultdict: автоинициализация значений
Когда значение по умолчанию сложнее нуля (список, множество, вложенный словарь), defaultdict автоматически создаёт его при первом обращении:
from collections import defaultdict
# Группировка без setdefault
groups = defaultdict(list)
words = ['apple', 'avocado', 'banana', 'blueberry', 'cherry']
for w in words:
groups[w[0]].append(w)
# Подсчёт (аналог Counter)
counts = defaultdict(int)
for c in 'brontosaurus':
counts[c] += 1
Подсчёт слов в файле: вложенные циклы и парсинг
Реальная задача частотного анализа - чтение данных из файла и разделение строк на слова:
fname = input('Enter the file name: ')
try:
fhand = open(fname)
except FileNotFoundError:
print('File cannot be opened:', fname)
exit()
counts = dict()
for line in fhand:
words = line.split()
for word in words:
counts[word] = counts.get(word, 0) + 1
print(counts)
Важный момент: оригинальный пример из учебника использует голый except:, который ловит все исключения, включая KeyboardInterrupt и SystemExit. Всегда указывайте конкретный тип: FileNotFoundError для отсутствующих файлов или OSError для общих ошибок ввода-вывода.
Почему split() без аргументов
split() без аргументов разбивает строку по любому пробельному символу и автоматически удаляет пустые элементы. Если использовать split(' '), при двойных пробелах в результате появятся пустые строки. Мелочь, а ломает подсчёт.
Архитектура вложенных циклов
Внешний цикл for line in fhand читает файл построчно - это эффективно по памяти, поскольку в RAM загружена только текущая строка. Внутренний цикл for word in words проходит по результату split() и полностью завершается на каждой итерации внешнего.
Итерация по словарю: keys(), values(), items()
Словарь предоставляет три представления (views) для итерации:
counts = {'chuck': 1, 'annie': 42, 'jan': 100}
# Итерация по ключам (по умолчанию)
for key in counts:
print(key)
# Явное получение ключей
list(counts.keys()) # ['chuck', 'annie', 'jan']
# Только значения
list(counts.values()) # [1, 42, 100]
total = sum(counts.values()) # 143
# Пары ключ-значение
for key, value in counts.items():
print(f'{key}: {value}')
Все три метода возвращают view-объекты, которые отражают текущее состояние словаря и не создают отдельную копию в памяти.
Фильтрация по значению
for key, count in counts.items():
if count > 10:
print(key, count)
# annie 42
# jan 100
Этот паттерн пригождается при анализе логов, когда нужно выделить аномально частые события.
Словарные включения (dict comprehension)
Аналогично списковым включениям, словари поддерживают компактный синтаксис создания:
# Квадраты чисел
squares = {x: x**2 for x in range(6)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Фильтрация словаря
counts = {'chuck': 1, 'annie': 42, 'jan': 100}
frequent = {k: v for k, v in counts.items() if v > 10}
# {'annie': 42, 'jan': 100}
# Инверсия словаря (значения <-> ключи)
inverted = {v: k for k, v in counts.items()}
# {1: 'chuck', 42: 'annie', 100: 'jan'}
Dict comprehension читается лучше, чем эквивалентный цикл, и работает с такой же скоростью.
Нормализация и очистка текста: без этого подсчёт врёт
Реальный текст содержит знаки препинания и смешанный регистр. Слова «Who» и «who» будут считаться разными ключами. Для корректного анализа нужна нормализация:
import string
line = 'Hello, World! Hello...'
line = line.translate(str.maketrans('', '', string.punctuation))
line = line.lower()
# 'hello world hello'
Для более сложных правил очистки (удаление URL, извлечение email-доменов) подходит модуль re с регулярными выражениями. Словарь при этом остаётся структурой хранения результатов.
Практическое упражнение: подсчёт доменов
Задача - извлечь почтовый домен из строк, начинающихся с From , и подсчитать частоту:
from collections import Counter
fname = input('Enter file name: ')
try:
fhand = open(fname)
except FileNotFoundError:
print('File not found:', fname)
exit()
domains = Counter()
for line in fhand:
if line.startswith('From '):
email = line.split()[1]
domain = email.split('@')[1]
domains[domain] += 1
for domain, count in domains.most_common():
print(f'{domain}: {count}')
Поиск максимального значения в словаре
После построения гистограммы часто нужно определить элемент с наибольшей частотой. Учебный вариант с явным циклом:
bigcount = None
bigword = None
for word, count in counts.items():
if bigcount is None or count > bigcount:
bigword = word
bigcount = count
print(bigword, bigcount)
Конструкция for word, count in counts.items() использует распаковку кортежей (tuple unpacking) для одновременного извлечения обоих элементов.
Продакшен-альтернативы покороче:
# Через max() с ключевой функцией
most_common_word = max(counts, key=counts.get)
print(most_common_word, counts[most_common_word])
# Через Counter
from collections import Counter
c = Counter(counts)
print(c.most_common(1))
# [('jan', 100)]
Вложенные словари: иерархические данные
Значения словаря могут быть любыми объектами, включая другие словари. Удобно для представления структурированных данных:
users = {
'alice': {'email': 'alice@example.com', 'posts': 42},
'bob': {'email': 'bob@example.com', 'posts': 17},
}
print(users['alice']['posts']) # 42
# Безопасный доступ к вложенным данным
posts = users.get('charlie', {}).get('posts', 0) # 0
Типичные грабли: на что наступают все
KeyError при прямом обращении
Обращение counts[word] к несуществующему ключу вызывает KeyError. Именно поэтому паттерн if word not in counts или метод get() обязательны. Проверка in выполняется за O(1) благодаря хеш-таблице.
Мутация словаря во время итерации
Добавление или удаление ключей внутри for key in d приводит к RuntimeError: dictionary changed size during iteration. Безопасный способ - итерация по копии:
for key in list(d.keys()):
if d[key] < 2:
del d[key]
Голый except
# Плохо - ловит KeyboardInterrupt, SystemExit и всё остальное
try:
fhand = open(fname)
except:
print('Error')
# Правильно - конкретный тип исключения
try:
fhand = open(fname)
except FileNotFoundError:
print('File not found:', fname)
Сравнение словарей
Два словаря равны, если содержат одинаковые пары ключ-значение, независимо от порядка вставки:
{'a': 1, 'b': 2} == {'b': 2, 'a': 1} # True
А у вас есть любимые паттерны работы со словарями? Может, история, когда KeyError в проде всё уронил, или хитрый трюк с defaultdict? Делитесь в комментах, обсудим!
