Python для ML
NumPy, pandas, matplotlib — основной стек для работы с данными.
Python для ML — эффективный код, а не просто «работающий»
Python стал языком ML не потому что быстрый — а потому что вокруг него выросла экосистема: NumPy, pandas, PyTorch, scikit-learn. Но «знаю Python» и «пишу эффективный Python» — это разные вещи. На собесе ты покажешь не знание синтаксиса, а понимание того, что происходит под капотом: почему генератор экономит гигабайты памяти, зачем нужен GIL, как декоратор меняет поведение функции.
Эта нода — не учебник по основам. Ты уже умеешь писать циклы и функции. Здесь — продвинутые концепции, которые отличают джуна от мидла и которые реально спрашивают на собеседованиях в ML.
Большая картина: от основ к мастерству
Продвинутый Python для ML-инженера — это пять уровней глубины: Уровень 1. Структуры данных — list, dict, set: не просто «контейнеры», а конкретные структуры с разной алгоритмической сложностью. Выбор между list и set может ускорить код в 1000×. Уровень 2. Генераторы и итераторы — ленивые вычисления. Датасет на 10 ГБ не загружаешь в память, а обрабатываешь по батчам через yield. Уровень 3. Функциональные паттерны — декораторы, comprehensions, map/filter. Код становится короче, читабельнее и pythonic. Уровень 4. OOP для практики — dataclasses, __slots__, context managers. Не академический ООП, а инструменты для чистого кода. Уровень 5. Concurrency — GIL, threading, multiprocessing, asyncio. Как параллелить обработку данных и не застрять на одном ядре.
GIL — почему Python «однопоточный»
GIL (Global Interpreter Lock) — самая обсуждаемая особенность CPython. Это мьютекс, который разрешает выполнять байт-код только одному потоку в каждый момент времени. Представь: в комнате один микрофон, и говорить может только тот, у кого он в руках. Потоки передают «микрофон» каждые 5 мс (по умолчанию), но одновременно двух говорящих не бывает.
Зачем GIL нужен? CPython использует подсчёт ссылок (reference counting) для управления памятью. Без GIL два потока могли бы одновременно менять счётчик одного объекта → гонка данных → утечки памяти или segfault. GIL — это простое решение: один замок вместо тысяч мелких блокировок на каждый объект.
Как GIL влияет на ML-код? Если задача CPU-bound (обучение модели, обработка массивов) — threading не даёт реального параллелизма. Два потока с тяжёлыми вычислениями будут работать даже медленнее одного из-за overhead на переключение GIL.

Три инструмента параллелизма
threading — настоящие потоки ОС, но ограничены GIL. Подходят для I/O-задач: пока один поток ждёт ответа от сервера (GIL отпускается!), другой работает. Для CPU — бесполезно.
multiprocessing — отдельные процессы, каждый со своим интерпретатором и GIL. Настоящий параллелизм на всех ядрах. Но обмен данными между процессами дорогой (pickle/unpickle). Используй для CPU-bound задач: обработка данных, feature engineering на больших таблицах.
asyncio — один поток, но не простаивает. Аналогия: ты варишь макароны и пока ждёшь кипения — режешь салат. Event loop переключает корутины: пока одна ждёт ответа от сети, другая работает. Идеально для тысяч мелких I/O-операций (API-запросы, чтение из БД).
# CPU-bound → multiprocessing
from multiprocessing import Pool
def process_chunk(chunk):
return chunk.apply(heavy_transform)
with Pool(8) as pool:
results = pool.map(process_chunk, data_chunks)
# I/O-bound → asyncio
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as s:
tasks = [fetch(s, url) for url in urls]
return await asyncio.gather(*tasks)Важный нюанс
Генераторы и итераторы — ленивые вычисления
Обычный список — ты распечатал все 10 000 страниц книги разом. Генератор — читаешь по одной странице, и следующая появляется только когда попросишь. В ML это критично: датасет на 10 ГБ не влезет в память целиком, а генератор выдаёт батчи по одному.
Протокол итератора
В Python любой объект, у которого есть __iter__() и __next__(), является итератором. for x in obj — это просто синтаксический сахар: Python вызывает iter(obj), потом next() до StopIteration. Список создаёт итератор из себя, файл — итератор по строкам, range — итератор по числам.
yield — создаём генератор
Функция с yield — это генератор. При вызове она не выполняется, а возвращает объект-генератор. Каждый вызов next() выполняет код до следующего yield, возвращает значение и замораживает состояние. При следующем next() — продолжает с того же места.
# Генератор батчей для обучения
def batch_generator(data, batch_size=32):
for i in range(0, len(data), batch_size):
yield data[i:i + batch_size] # отдаём один батч, замораживаемся
# Использование — в памяти только один батч
for batch in batch_generator(huge_dataset, batch_size=64):
model.train_on_batch(batch)
# Generator expression — аналог list comprehension, но ленивый
squares = (x**2 for x in range()) # ~120 байт
squares_list = [x**2 for x in range()] # ~80 МБКогда использовать генераторы: обработка больших файлов построчно, DataLoader в PyTorch (под капотом — генератор), любые пайплайны, где данных больше, чем памяти. Когда НЕ нужны: если данные маленькие и нужен произвольный доступ (индексация) — обычный список проще.
Декораторы — функции, которые меняют функции
Декоратор — это функция, которая принимает функцию и возвращает новую функцию (обычно с расширенным поведением). @decorator перед определением — синтаксический сахар для func = decorator(func). Это один из самых мощных паттернов Python и один из самых частых вопросов на собесе.
Анатомия декоратора
Каждый декоратор — это три уровня: внешняя функция (принимает оригинал), wrapper (обёртка, которая вызывает оригинал + делает что-то ещё), возврат wrapper. Ключевой момент — @functools.wraps(func): без него обёрнутая функция теряет имя, docstring и другие метаданные.
import time
from functools import wraps
# 1. @timer — замер времени выполнения
def timer(func):
@wraps(func) # сохраняет __name__, __doc__
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.3f}s")
return result
return wrapper
# 2. @retry — повторить при ошибке
def retry(attempts=3, delay=1):
def decorator(func): # декоратор с параметрами — ещё один уровень
@wraps(func)
def wrapper(*args, **kwargs):
for i in range(attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if i == attempts - 1:
raise
time.sleep(delay)
return wrapper
return decorator
@timer
def train_model(epochs):
time.sleep(2) # имитация обучения
@retry(attempts=5, delay=2)
def fetch_data(url):
... # HTTP-запрос, может упастьЧасто используемые встроенные декораторы:
• @property — превращает метод в атрибут (геттер без скобок)
• @staticmethod, @classmethod — методы без self / с cls
• @functools.lru_cache(maxsize=128) — кеширует результаты (мемоизация). Идеально для рекурсии или дорогих чистых функций
• @dataclass — автоматическая генерация __init__, __repr__, __eq__
Порядок декораторов
@A @B def f() → f = A(B(f)). Сначала B оборачивает f, потом A оборачивает результат. Это важно: @lru_cache должен быть ближе к функции (внизу), чтобы кешировать вызов, а не обёртку.Context managers — гарантия очистки ресурсов
Каждый раз, когда ты открываешь файл, соединение с БД, блокировку — нужно гарантировать закрытие. Можно писать try/finally, но with делает то же самое элегантно и безопасно. Даже если внутри вылетит исключение — ресурс будет освобождён.
Протокол: объект с методами __enter__() (вызывается при входе в with, возвращает ресурс) и __exit__() (вызывается при выходе — даже при ошибке). Если __exit__ возвращает True, исключение подавляется.
# Свой контекстный менеджер — класс
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, tb):
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False # не подавляем исключения
with Timer() as t:
train_model(epochs=10)
# Тот же результат через contextlib — проще
from contextlib import contextmanager
@contextmanager
def timer(name="block"):
start = time.perf_counter()
yield # тут выполняется тело with-блока
print(f"{name}: {time.perf_counter() - start:.3f}s")
with timer("training"):
train_model(epochs=10)Где используются в ML: torch.no_grad() — отключает вычисление градиентов при инференсе. open() — чтение данных. tempfile.NamedTemporaryFile() — временные файлы. torch.cuda.amp.autocast() — mixed precision. Все — контекстные менеджеры.
Гибкие сигнатуры, type hints и dataclasses
*args и kwargs — механизм передачи произвольного числа аргументов. *args собирает позиционные аргументы в tuple, kwargs — именованные в dict. Без этого невозможно написать декоратор, который работает с любой функцией.
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs): # принимает ЧТО УГОДНО
print(f"Calling {func.__name__}")
return func(*args, **kwargs) # передаёт оригиналу
return wrapper
# Type hints — подсказки типов (не проверяются в рантайме!)
def train(
model: nn.Module,
data: DataLoader,
epochs: int = 10,
lr: float = 1e-3,
) -> dict[str, float]: # возвращает словарь метрик
...Type hints не влияют на выполнение — Python их игнорирует. Но они критичны для: IDE (автодополнение, предупреждения), mypy (статическая проверка типов), читаемости кода. В ML-проектах type hints экономят часы отладки.
dataclass — конфиги без шаблонного кода
В ML-коде постоянно нужны структуры для конфигов экспериментов. С Python 3.7 есть @dataclass — пишешь поля, а __init__, __repr__, __eq__ генерируются автоматически. frozen=True делает объект неизменяемым (и hashable — можно использовать как ключ dict).
from dataclasses import dataclass, field
@dataclass
class TrainConfig:
model_name: str = "catboost"
lr: float = 1e-3
epochs: int = 10
tags: list[str] = field(default_factory=list) # мутабельный default!
config = TrainConfig(lr=0.01)
print(config) # TrainConfig(model_name='catboost', lr=0.01, epochs=10, tags=[])__slots__ — когда объектов миллионы
Каждый обычный объект Python хранит свои атрибуты в __dict__ — словаре. Один dict занимает ~100-200 байт overhead. Если у тебя миллион объектов — это сотни мегабайт впустую.
__slots__ заменяет dict на фиксированный массив — как struct в C. Атрибуты определяются заранее, добавлять новые нельзя. Экономия памяти — 30-50% на объект.
class Point: # обычный класс — с __dict__
def __init__(self, x, y):
self.x = x
self.y = y
class SlotPoint: # со __slots__ — без __dict__
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# SlotPoint занимает ~56 байт vs ~152 байта у Point
# На 10M объектов: 560 МБ vs 1.5 ГБКогда использовать: классы с миллионами экземпляров (DataPoint, Token, Node в графе). В dataclass: @dataclass(slots=True) (Python 3.10+). Когда НЕ нужны: обычные классы с десятками экземпляров — экономия незаметна, а гибкость теряется.
Comprehensions vs map/filter — pythonic code
Python предлагает два стиля функциональной обработки коллекций. List comprehension — стандарт де-факто в Python-сообществе: читается как английский, быстрее map/filter на простых операциях.
# Comprehension — читается как текст
even_squares = [x**2 for x in range(100) if x % 2 == 0]
# Dict comprehension
name_to_len = {name: len(name) for name in names}
# Set comprehension
unique_words = {word.lower() for word in text.split()}
# Эквивалент через map/filter — менее читаемо
even_squares = list(map(lambda x: x**2, filter(lambda x: x % 2 == 0, range(100))))
# Когда map лучше: с готовой функцией (без lambda)
numbers = list(map(int, string_list)) # чище, чем [int(s) for s in string_list]Правило: если трансформация простая (1 операция) — comprehension. Если нужна готовая функция без lambda — map() нормально. Вложенные comprehensions глубже 2 уровней — разбей на цикл, читаемость важнее.
Мутабельность и передача аргументов — классические ловушки
В Python всё — объект, и переменные — это ссылки на объекты. Когда ты передаёшь list в функцию, передаётся ссылка. Изменения внутри функции видны снаружи. Это не «передача по ссылке» как в C++ — это «передача ссылки по значению».
Ловушка #1: мутабельный default-аргумент
def add(x, lst=[]) — lst создаётся ОДИН раз при определении функции, и при повторных вызовах растёт. Это спрашивают на каждом втором собесе. Решение: lst=None, внутри if lst is None: lst = [].# ❌ Баг: один list на все вызовы
def add_item(x, lst=[]):
lst.append(x)
return lst
add_item(1) # [1]
add_item(2) # [1, 2] — неожиданно!
# ✅ Правильно
def add_item(x, lst=None):
if lst is None:
lst = []
lst.append(x)
return lst🎯 На собеседовании
Junior
def f(x, lst=[])? lst создаётся один раз при определении. Повторные вызовы мутируют тот же list.
• Генератор vs list comprehension? Генератор ленивый — не держит всё в памяти. (x2 for x in range(107)) — ~120 байт, [...] — ~80 МБ.
• Что такое декоратор? Функция, принимающая функцию и возвращающая функцию. @dec = func = dec(func). Напиши @timer.Middle
__enter__ / __exit__ — зачем? Протокол контекстного менеджера. Гарантирует очистку ресурсов при выходе из with, даже при исключении. contextlib.contextmanager — через yield.
• *args, kwargs — что это? *args собирает позиционные аргументы в tuple, kwargs — именованные в dict. Нужно для декораторов, оберточных функций.
• Зачем @functools.wraps? Без него обёрнутая функция теряет __name__, __doc__, __module__. Это ломает отладку и интроспекцию.Senior
__slots__ — зачем? Заменяет __dict__ на фиксированный массив. Экономит 30-50% памяти на объект. Используй когда миллионы экземпляров.
• asyncio — event loop, как работает? Один поток, один event loop. Корутины (async def) регистрируются в loop. await отдаёт управление loop-у, который переключается на готовую корутину. Не подходит для CPU-bound.
• GIL и free-threading (PEP 703)? Python 3.13+ экспериментально поддерживает no-GIL режим. Пока за флагом --disable-gil. В 3.14+ — ожидается стабилизация.
• Декоратор с аргументами — как устроен? Три уровня вложенности: внешняя функция (принимает аргументы) → декоратор (принимает функцию) → wrapper (вызывает функцию). Пример: @retry(attempts=3).Собираем всё вместе
Продвинутый Python для ML — это не про «красивый код», а про эффективность и понимание. GIL определяет, как параллелить вычисления. Генераторы позволяют обрабатывать данные, которые не влезают в память. Декораторы убирают дублирование (логирование, кеширование, retry). Context managers гарантируют, что ресурсы не утекут. Type hints и dataclasses делают код самодокументируемым.
Если запомнить одну вещь: Python медленный для циклов, но быстрый для оркестрации. Тяжёлые вычисления уходят в NumPy/PyTorch (C/CUDA), а Python связывает всё вместе — и делает это элегантно, если знаешь инструменты.
Материалы
Детальный разбор GIL: как работает, зачем нужен, как обходить.
Официальная документация по корутинам, event loop и задачам.
Пошаговое руководство: от простого декоратора до декораторов с параметрами и классами.
Глубокий доклад: генераторы, декораторы, контекстные менеджеры, метаклассы.
Сложность всех операций для list, dict, set, deque — шпаргалка.
90 конкретных рецептов: от генераторов до concurrency. Лучшая книга для «следующего уровня».