блог
← Все посты
15 АПР 2026 · 10 мин

Программисты не пишут код

Оригинал · YouTube Смотреть видео-версию
Содержание

Представьте свой идеальный рабочий день. Вы написали много кода, который сразу заработал как надо. Вы исправили несколько багов, с которыми никто до вас не мог справиться. Вы не ходили на собрания, никто не отвлекал вас от работы.

Если верить статистике 250 тысяч разработчиков, мы описали 52 минуты рабочего дня.

Чужие мысли

У компаний уже есть продукты, которые приносят деньги. Инженеры поддерживают эти продукты и редко пишут что-то с нуля.

Вы будете постепенно разбираться в коде и закрывать задачи: исправлять баги и добавлять новые функции. Время на выполнение задач ограничено, поэтому придется выбирать самый короткий путь, чтобы встроить свои изменения в код. Вы заметите, как чужой код заставляет вас продолжать чужие решения, даже если вы считаете их плохими.

Не все решения однозначно плохие или хорошие — это становится понятно через время. Иногда инженеры пытаются предугадать будущее или наоборот, делают ставку на стабильность.

Рассмотрим пример проекта. Команда пишет код для обучения моделей. Большинство шагов обработки данных и обучения используют одни и те же параметры. Команда решает создать словарь для конфигурации. Все шаги получают конфигурацию из одного источника:

train.py
CONFIG = {
"data_path": "data/train.csv",
"test_size": 0.2,
"random_state": 42,
"n_estimators": 100,
"max_depth": 10,
}
def load_data():
df = pd.read_csv(CONFIG["data_path"])
X, y = df.drop("target", axis=1), df["target"]
return train_test_split(
X, y,
test_size=CONFIG["test_size"],
random_state=CONFIG["random_state"],
)
def train(X_train, y_train):
model = RandomForestClassifier(
n_estimators=CONFIG["n_estimators"],
max_depth=CONFIG["max_depth"],
random_state=CONFIG["random_state"],
)
model.fit(X_train, y_train)
return model

Посмотрим на этот проект через полгода. Моделей стало больше, добавился тюнинг гиперпараметров и ансамбли моделей, инженеры продолжали тот же подход. В словаре для конфигурации теперь 30 ключей. Некоторые функции изменяют ключи или добавляют новые. Каждый шаг тратит 20-30 строчек кода, чтобы проверить, какие ключи доступны, и подставить значения по умолчанию:

train.py
CONFIG = {
"data_path": "data/train.csv",
"data_path_v2": "data/train_cleaned.csv",
"test_size": 0.2,
"val_size": 0.1,
"random_state": 42,
"random_seed": 42,
"seed": 42,
"n_estimators": 100,
"n_estimators_gb": 200,
"max_depth": 10,
"max_depth_v2": 12,
# "max_depth_old": 8,
"cv_folds": 5,
"cv_folds_tuning": 3,
"ensemble_weights": [0.6, 0.4],
"tuning_param_grid": {
"n_estimators": [100, 200],
"max_depth": [8, 10, 12],
},
"use_gb": True,
"use_rf": True,
"early_stopping": True,
"early_stop_rounds": 10,
"scoring": "accuracy",
"refit_best": True,
"n_jobs": -1,
"verbose": 1,
"log_path": "logs/run.log",
"model_output_dir": "models/",
"checkpoint_every": 5,
}
def load_data():
path = CONFIG.get("data_path_v2", CONFIG.get("data_path", "data/train.csv"))
df = pd.read_csv(path)
X, y = df.drop("target", axis=1), df["target"]
test_size = CONFIG.get("test_size", 0.2)
seed = CONFIG.get(
"random_state", CONFIG.get("random_seed", CONFIG.get("seed", 42))
)
return train_test_split(X, y, test_size=test_size, random_state=seed)
def train(X_train, y_train):
if "n_estimators" not in CONFIG:
CONFIG["n_estimators"] = CONFIG.get("n_estimators_gb", 100)
max_depth = CONFIG.get("max_depth_v2", CONFIG.get("max_depth", 10))
seed = CONFIG.get("random_state", CONFIG.get("random_seed", 42))
if CONFIG.get("use_rf", True):
n_est = CONFIG["n_estimators"]
else:
n_est = CONFIG.get("n_estimators_gb", 200)
if "tuning_param_grid" in CONFIG and CONFIG.get("refit_best", False):
grid = CONFIG["tuning_param_grid"]
else:
grid = {"n_estimators": [n_est], "max_depth": [max_depth]}
model = RandomForestClassifier(
n_estimators=n_est,
max_depth=max_depth,
random_state=seed,
)
model.fit(X_train, y_train)
return model

Ошибки теперь невозможно отслеживать, потому что причиной могла быть опечатка в ключе, неправильный порядок вызова функций или изменение ключа где угодно выше по стеку.

Рассмотрим другой пример. Команда создает пайплайн для обработки данных. Инженеры протестировали пайплайн в Jupyter ноутбуках. Он состоит из обычных шагов: очистка от выбросов, заполнение пропусков, удаление дубликатов, кластеризация и запись:

pipeline.py
# ---- preprocessing ----
def preprocess(df):
# remove outliers via IQR
q1 = df.quantile(0.25)
q3 = df.quantile(0.75)
iqr = q3 - q1
mask = ~((df < q1 - 1.5 * iqr) | (df > q3 + 1.5 * iqr)).any(axis=1)
df = df[mask]
# fill nulls with column median
df = df.fillna(df.median(numeric_only=True))
return df
# ---- dedup ----
def deduplicate(df, tol=1e-6):
# normalize strings, round numerics to tolerance, then drop
norm = df.copy()
for col in norm.select_dtypes(include="object"):
norm[col] = norm[col].str.strip().str.lower()
for col in norm.select_dtypes(include="number"):
norm[col] = (norm[col] / tol).round() * tol
keep = ~norm.duplicated()
return df[keep]
# ---- run ----
def run_pipeline(df):
df = preprocess(df)
df = deduplicate(df)
labels = KMeans(n_clusters=5).fit_predict(df)
df["cluster"] = labels
df.to_parquet("output.parquet")
return df

Один из инженеров настаивает на том, чтобы доработать код и реализовать шаги пайплайна на классах с методами fit и transform. Это решение выглядит избыточным — у команды уже есть почти готовый код. Решение принимают, большие функции preprocess и deduplicate разбивают на части и превращают в отдельные шаги. Пайплайн выглядит как вызов цепочки трансформаций:

pipeline.py
def run_pipeline(df):
return Pipeline([
OutlierRemover(),
NullFiller(),
Deduplicator(),
KMeansClusterer(n_clusters=5),
ParquetWriter("output.parquet"),
]).fit_transform(df)

Через полгода библиотека трансформаций расширяется, в проекте появляется несколько пайплайнов. Инженеры включают и выключают некоторые трансформации по флагу. Ни один из шагов не превратился в спагетти — каждый класс делает только одну операцию.

Три шага назад

При обновлении программ нам обычно показывают список изменений. Мы видим только две версии — старую и новую — но в реальности между ними десятки промежуточных состояний. Команда использует их, чтобы разбить большое изменение на цепочку простых.

Каждое изменение может быть опасным. Стандартное обновление конфигурационных файлов антивируса CrowdStrike Falcon вывело из строя 8.5 миллионов компьютеров в 2024 году.

Чтобы найти и исправить проблему, нужно изолировать каждое изменение. Одна задача может затрагивать несколько файлов, несколько задач могут затрагивать один и тот же файл. Сформировать историю можно только если определить границы до начала работы.

Изменения в проектах отслеживают с помощью Git. Git сохраняет промежуточные версии проекта — их называют коммитами. Каждый коммит содержит список изменений и название, которое выбрал разработчик.

Название коммита можно использовать как проверку. “Обновить Python до версии 3.15” или “Добавить тайм-аут на чтение данных” — примеры хороших изменений. Если название сложно придумать или в него хочется вставить “и” — изменений слишком много. “Улучшить модель” или “Обновить тесты и документацию” — примеры плохих изменений. Разработчик, знакомый с кодом проекта, должен угадать изменение по названию.

Рассмотрим пример. Нам дали задачу исправить пайплайн обработки данных для модели — ее точность упала. Мы изучили код и нашли несколько проблем:

loader.py
def load_data(path, columns):
df = pd.read_csv(path)
df = df[columns]
return df
preprocess.py
def normalize(columns, df):
for col in columns:
mean = df[col].mean()
std = df[col].std()
df[col] = (df[col] - mean) / std
return df
pipeline.py
COLUMNS = ["price", "volume", "spread"]
def run(path):
df = load_data(path, COLUMNS)
df = normalize(COLUMNS, df)
return df

Мы исправили краевой случай в функции для запроса данных: в первых строчках иногда появлялись пропуски. Заодно мы решили поменять порядок аргументов в функциях, чтобы он везде был одинаковым. Чтобы сделать модель устойчивее к выбросам, мы добавили фильтр выбросов и включили его в пайплайн обработки.

Каждый файл содержит изменения от разных шагов. Эти изменения придется объединить в один коммит:

Terminal window
git commit -am "Fix data pipeline"
[main a3f2c1b] Fix data pipeline
3 files changed, 9 insertions(+), 2 deletions(-)

Если метрики модели ухудшатся после обновлений, мы не сможем откатить только одно из них. Нам придется вернуться к началу и вспомнить логику своих действий.

Продумаем изменения заново. Мы обнаружили три проблемы: необработанный краевой случай, слабую фильтрацию выбросов и разный порядок аргументов. К задаче относятся только первые две, начнем с них. Обработаем краевой случай и сделаем коммит “Заполнить начальные пропуски в данных нулями”. Добавим фильтр выбросов и сделаем коммит “Добавить фильтр выбросов по квантилям”. Сделаем рефакторинг и финальный коммит “Унифицировать порядок аргументов функций обработки данных”:

Terminal window
git commit -am "Fill leading NaNs"
git commit -am "Add outlier filter"
git commit -am "Unify argument order"

Первый пользователь

Поведение продукта диктуют пользователи. Sketch был десктопным редактором — дизайнеры не могли работать над макетом одновременно. Figma перенесла редактор в браузер и вытеснила Sketch с рынка.

Нужное поведение закрепляют тестами. Тесты работают не только с продуктом, но и с его частями вплоть до функций. Кнопка отмены будет работать только если редактор сохраняет изменения в истории и может применить их в обратном порядке.

Тесты принимают роль пользователя. Каждый тест имитирует конкретный сценарий поведения. Запуск этого сценария должен выглядеть так же просто, как взаимодействие пользователя с продуктом: подготовка аргументов, вызов функции и получение ожидаемого результата.

Иногда это сделать неудобно: результат функции неявно зависит от состояния других объектов, часть аргументов ни на что не влияет, важные промежуточные результаты никуда не записываются. Это говорит о плохом коде.

Тесты усиливают проблемы. Исходный код может вызывать функцию один раз. Тесту нужно вызвать функцию несколько раз, чтобы проверить каждый сценарий. Проблемы умножаются на количество проверок.

Рассмотрим пример. Мы написали модуль для обработки данных. Функции prepare_training_features и prepare_inference_features частично повторяются: обработка пропусков нужна и для обучения, и для предсказания. Мы могли этого не заметить: несколько одинаковых строк не выглядят как проблема:

features.py
def prepare_training_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.dropna(axis=1, how="all")
df = df.fillna(df.median())
df["target"] = (df["target"] > 0).astype(int)
return df
def prepare_inference_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.dropna(axis=1, how="all")
df = df.fillna(df.median())
df = df.reindex(columns=FEATURE_COLUMNS, fill_value=0)
return df

Продумаем тесты для функции prepare_training_features:

  • Если пропусков нет, значения не должны измениться.
  • Если пропуски есть, их нужно заполнить медианными значениями.
  • Если в колонке нет значений, ее нужно сбросить.
  • Значения в колонке target должны измениться на нули и единицы.

Первые три теста придется повторить в тестах функции prepare_inference_features. Если логика работы с пропусками изменится, код придется обновлять в четырех местах. Из-за этих проблем нам придется выделить общую логику в отдельную функцию. В тестах каждой функции останутся только специфические для них проверки.

Рассмотрим другую функцию — prepare_feature_row. Она работает со строками: валидирует числовые и категориальные признаки, заполняет пропуски и делает преобразования:

row.py
def prepare_feature_row(row: dict, schema: dict) -> dict:
cleaned = {}
for col, rules in schema.items():
if col not in row:
cleaned[col] = rules.get("default")
continue
value = row[col]
if not isinstance(value, (int, float)):
raise TypeError(f"{col}: expected numeric")
if "clip_range" in rules:
lo, hi = rules["clip_range"]
value = max(lo, min(hi, value))
if rules.get("log_transform"):
if value <= 0:
raise ValueError(f"{col}: log requires positive value")
value = math.log(value)
cleaned[col] = value
return cleaned

Продумаем возможные варианты работы с числовыми признаками:

  • Числа без предобработки возвращают число.
  • Нечисловые типы вызывают ошибку.
  • Пропуски со значением по умолчанию возвращают значение по умолчанию.
  • Признаки с указанным диапазоном значений подрезаются к нижней и верхней границе.
  • Если признак с логарифмическим преобразованием меньше нуля, функция вызывает ошибку.

Это не все случаи, но их уже тяжело удержать в голове. Проблема в том, что некоторые случаи отвечают за валидацию, а другие — за преобразование. Если их разделить, тесты станут проще.

Функция для валидации данных ничего не возвращает. Достаточно проверить, что она вызывает исключения для всех неверных значений признаков. Функция для преобразования гарантированно получит правильные значения. Нам останется проверить только логарифмирование и подрезку к границам диапазона:

row.py
def validate_feature_row(row: dict, schema: dict) -> None:
for col, rules in schema.items():
if col not in row:
continue
value = row[col]
if not isinstance(value, (int, float)):
raise TypeError(f"{col}: expected numeric")
if rules.get("log_transform") and value <= 0:
raise ValueError(f"{col}: log requires positive value")
def transform_feature_row(row: dict, schema: dict) -> dict:
cleaned = {}
for col, rules in schema.items():
value = row.get(col, rules.get("default"))
if "clip_range" in rules:
lo, hi = rules["clip_range"]
value = max(lo, min(hi, value))
if rules.get("log_transform"):
value = math.log(value)
cleaned[col] = value
return cleaned

Этот прием называют разделением ответственности.

Новый язык

В “Прибытии” Вильнева люди вступают в контакт с инопланетной расой. Инопланетяне записывают свои сообщения целиком. Если существо может записать все слова одновременно, оно не воспринимает время как последовательность. Лингвист Луиза расшифровывает инопланетный язык и начинает видеть будущее.

Мы разобрали шаги, которые сопровождают разработку. Каждый из них заставляет нас обдумать решение до перехода в редактор. Код работает как механический перевод этого решения на другой язык. Если мы сделали подготовку хорошо, перевод сделать просто.

Мы повторяем этот выбор в разных задачах и проектах. Привычка обдумывать решения остается даже если нам не нужно переводить их в код. Это меняет мышление за пределами кода — так же, как язык пришельцев меняет восприятие времени.

Рассмотрим пример. Нам дали задачу создать автоматический отчет. Он должен включать 100 пользователей с самым высоким риском отмены премиум подписки. Отчет формируется раз в неделю. Специалисты из отдела удержания будут обзванивать каждого клиента из этого отчета.

Мы знаем, как устроена модель оттока. Это бинарный классификатор с порогом, подобранным под соотношение точности и охвата. Если выбирать фиксированное число клиентов, модель будет работать вне оптимального порога. Что делать, если у нас нет хороших кандидатов? Стоит включить пользователей с вероятностью ухода в 50/50? Что если пользователей с высоким риском ухода больше ста? Можно ли беспокоить одних и тех же пользователей две недели подряд? Каждый вопрос изменяет реализацию, но отдел удержания об этом не задумывался.

Рассмотрим другой пример. Нас попросили разработать систему мониторинга для модели оттока. Она должна сигнализировать о том, что метрики модели упали и модель нужно переобучить.

Мы начали с пошагового плана. Из других проектов мы знаем о дрейфе данных, который приводит к снижению метрик моделей. Проверка на дрейф проще, чем проверка метрик — мы включаем ее как первый шаг. Перед проверкой метрик нужно выгрузить данные об оттоке клиентов. На этом шаге мы обнаружили, что эти данные появляются с задержкой в месяц — когда подписка пользователя подходит к концу. Сообщать о проблемах с задержкой в месяц бесполезно. Мы решили остановиться на первом шаге.

Конечная точка изменилась: система будет отслеживать не метрики модели, а стабильность входных данных. Мы увидели результат до того, как открыли редактор.