Фундамент
~20 мин

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 vs Multiprocessing vs Asyncio
Потоки делят один GIL. Процессы — каждый со своим Python-интерпретатором и GIL. Asyncio — один поток, но переключается на I/O.

Три инструмента параллелизма

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)

Важный нюанс

NumPy, PyTorch, scikit-learn отпускают GIL внутри C/CUDA-вычислений. Матричное умножение NumPy работает параллельно на всех ядрах через BLAS без multiprocessing. Поэтому «GIL делает Python медленным» — упрощение. GIL мешает только чистому Python-коду.

Генераторы и итераторы — ленивые вычисления

Обычный список — ты распечатал все 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

Mutable vs immutable — в чём разница? list, dict, set — мутабельные (можно менять in-place). int, str, tuple — нет (при «изменении» создаётся новый объект). tuple можно использовать как ключ dict, list — нельзя. • Что будет с def f(x, lst=[])? lst создаётся один раз при определении. Повторные вызовы мутируют тот же list. • Генератор vs list comprehension? Генератор ленивый — не держит всё в памяти. (x2 for x in range(107)) — ~120 байт, [...] — ~80 МБ. • Что такое декоратор? Функция, принимающая функцию и возвращающая функцию. @dec = func = dec(func). Напиши @timer.

Middle

GIL — что это и как обойти? Global Interpreter Lock, один поток = один байт-код. multiprocessing для CPU-bound, asyncio/threading для I/O. NumPy/PyTorch отпускают GIL в C-коде. • Как работает yield? Функция с yield возвращает генератор. Каждый next() выполняет код до yield, замораживает состояние, отдаёт значение. При следующем next() — продолжает. • __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 связывает всё вместе — и делает это элегантно, если знаешь инструменты.