Разработка и применение программного обеспечения в физических исследованиях

Вячеслав Федоров, ИЯФ СО РАН

Учебное пособие для студентов второго курса, обучающихся по направлению «Прикладная математика и физика».

Предмет книги

Программирование стало основным инструментом работы физика. Установка управляется кодом, данные обрабатываются кодом, результат в статье получен кодом. Качество этого кода определяет, можно ли доверять результату.

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

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

От общего курса программирования пособие отличается материалом, подобранным под задачи физического эксперимента: в нём разбираются работающие коды установок, а не учебные примеры.

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

Результаты изучения пособия

В результате изучения пособия студент должен уметь:

  • Пользоваться ИИ-помощниками и контролем версий. Языковые модели с пониманием того, где они помогают, а где вредят; Git как рабочий инструмент.
  • Работать в GNU/Linux. Устройство системы, файловые системы, процесс загрузки, инструменты командной строки и диагностика неполадок: без этого минимума работа на вычислительном кластере невозможна.
  • Владеть Python. Устройство объектов и коллекций, функции, декораторы, классы, генераторы и цена, скрытая за каждой операцией.
  • Понимать алгоритмы и структуры данных. Это позволяет оценивать, справится ли расчёт с заданным объёмом данных, и выбирать подходящую структуру.
  • Применять инженерные практики. Жизненный цикл программы, тестирование, превращение скрипта, написанного за вечер, в приложение, поддерживаемое годами, сети и базы данных.
  • Обрабатывать и анализировать данные. NumPy, pandas, визуализация, классические алгоритмы машинного обучения и нейронные сети.
  • Ускорять программы. Профилирование, многопоточность и GIL, асинхронность, вычисления на графических ускорителях.
  • Читать чужой код. Разбор реальных программ, с помощью которых выполняются физические расчёты в институтах СО РАН, и одного веб-сервиса, взятого без физики, чтобы за ней не скрывалась архитектура, — вместе с заложенными в них компромиссами и с решениями, которые сегодня были бы приняты иначе.

Структура книги

Порядок частей продиктован зависимостями между ними.

Сначала рассматриваются инструменты: помощники на языковых моделях и контроль версий. Затем операционная система, от загрузки до устройства ядра, и контейнеры, основанные на его механизмах. Далее сам язык, его объекты, коллекции и классы, а следом алгоритмы и структуры данных, примеры для которых на этом языке и написаны. Инженерные практики идут после: жизненный цикл программы, требования к коду, сети, базы данных. Завершается пособие работой с данными эксперимента, ускорением расчётов, разбором реальных программ, написанных в институтах СО РАН, и заданиями для самостоятельной работы.

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

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

Интерпретация замеров времени

В пособии приведено много измерений вида «эта версия быстрее в сто раз» или «на миллионе элементов уходит две секунды».

Рассматривать следует отношения, а не секунды. Абсолютное время ничего не значит без указания машины, на которой оно получено: тот же код на ноутбуке пятилетней давности и на серверном процессоре даёт числа, отличающиеся в разы. Утверждение «Numba ускоряет этот цикл в сто раз» от процессора не зависит, а утверждение «эта функция считается 14 секунд» зависит. Все выводы в пособии построены на отношениях, и именно их целесообразно проверять, запуская код на собственной машине.

Одно и то же измерение, повторённое подряд, даёт разные числа, поскольку на результат влияют кеш процессора, сборщик мусора, фоновые процессы и троттлинг, снижающий частоту при нагреве. Поэтому замер повторяется многократно: %timeit в IPython показывает среднее и стандартное отклонение по семи прогонам, а timeit.repeat из стандартной библиотеки возвращает серию замеров, из которой берётся минимум. Единичного замера недостаточно: если два варианта различаются на десять процентов, а разброс составляет двадцать, измерение не даёт никакой информации. В экспериментальной физике число не приводят без погрешности и условий опыта; то же относится к производительности программ.

Электронная версия

Пособие поддерживается в актуальном состоянии в электронном виде, где собраны полные листинги, интерактивные графики и исправленные опечатки:

  • Текст книги: https://phys-dev.github.io/soft-dev-book/
  • Исходники и задания: https://github.com/phys-dev

Там же размещено приложение с экспресс-курсом по основам языка, NumPy и Matplotlib, в печатное издание не вошедшее: пересказывать документацию на бумаге нецелесообразно.

Замеченные ошибки и предложения направляются в issues репозитория; они будут учтены в следующем издании.

О себе

Меня зовут Федоров Вячеслав Васильевич. С 2018 года я работаю в Институте ядерной физики им. Г. И. Будкера СО РАН, с 2026 года — младшим научным сотрудником в секторе 5-13. Область моих научных интересов — моделирование динамики заряженных частиц в электромагнитных полях и разработка программ, обеспечивающих настройку ускорительных комплексов. Окончил НГУ по направлению «Физика».

В последние годы я участвую в пуско-наладочных работах на линейном ускорителе инжектора ЦКП «СКИФ»: измерение энергии и эмиттанса пучка, коррекция орбиты, автоматизация настройки оптики. Многие примеры пособия представляют собой упрощённые версии задач, решавшихся на этой установке.

Программы, разработанные в рамках этой работы, разобраны в пособии:

  • REDPIC рассчитывает динамику пучка методом макрочастиц по релятивистской разностной схеме (№ 2023688768 от 25.12.2023);
  • KENV осуществляет быстрый расчёт огибающей пучка по уравнению Капчинского—Владимирского (№ 2024611244 от 18.01.2024);
  • SCAUT оркеструет эксперименты на ускорителях, объединяя управление, диагностику и моделирование в единой системе (№ 2025619977 от 21.04.2025); её слои разобраны в главе «От скрипта к приложению»;
  • ACCUMULATOR является цифровым двойником установки, неотличимым от неё для остальных программ (№ 2026668488 от 07.07.2026);
  • SAVA обеспечивает поддержку оператора в пультовой, связывая приборы, документацию и журнал смены на естественном языке (свидетельство пока не оформлено).

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

В 2017 и 2018 годах я разрабатывал на C++ программу инфракрасного датчика горизонта для сверхмалого космического аппарата НГУ «Норби», запущенного в 2020 году.

В НГУ я читаю лекции и веду семинары по применению Python в физических исследованиях и по основам искусственного интеллекта. Настоящее пособие выросло из этого курса.

Дополнительные сведения об авторе приведены на странице автора.

Применение ИИ для разработки ПО

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

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

Поэтому изложение строится на измерениях. Рассматривается, откуда эти инструменты появились и почему появились только сейчас; что говорят об их влиянии на скорость работы данные, полученные в аккуратно поставленных экспериментах; как модель устроена внутри, в объёме, достаточном для понимания природы её ошибок; какие формы инструментов существуют и чем они отличаются; как одна такая форма выглядит вблизи, на примере самого распространённого плагина к редактору. Завершают раздел ответы на вопросы, задаваемые студентами чаще всего, включая вопрос, считается ли это списыванием, и глава про Git, без которого работа с ассистентом, переписывающим по десятку файлов за раз, представляет опасность.

Инструмент усиливает того, кто понимает, что делает, и подводит того, кто не понимает. Код, который студент не может объяснить, нельзя сдавать независимо от того, кем он написан: ответственность за строку, сгенерированную моделью, остаётся на подписавшем работу.

Слайды к главе. Материал главы и её разделов изложен также в первой половине лекции «ИИ и Git» с демонстрациями в терминале и в редакторе кода; слайды лекции доступны на сайте книги и в PDF.

Контекст и скорость изменений

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

Маховик раскручивался давно

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

  • 2012 г. AlexNet выигрывает соревнование ImageNet, показав, что глубокие свёрточные сети, обученные на графических ускорителях, решают задачи компьютерного зрения лучше методов, накопленных за предыдущее десятилетие. Отсюда начинается современная эпоха глубокого обучения.
  • 2014 г. Ян Гудфеллоу предлагает генеративно-состязательные сети (GAN), устроенные как пара соперников; впервые нейросети начинают убедительно порождать данные, а не только раскладывать готовые по классам.
  • 2017 г. Статья «Attention Is All You Need» вводит архитектуру трансформера. Трансформер лежит в основе практически всех больших языковых моделей, выпущенных с тех пор, а попытки обойтись без него редки.
  • 2018–2020 гг. Обнаруживаются законы масштабирования, связывающие качество модели с числом параметров, объёмом данных и вычислительным бюджетом: качество предсказуемо улучшается при их согласованном росте. Появляются GPT-2 и GPT-3.
  • 2022 г. Выходит ChatGPT. Прорыв произошёл не в архитектуре, а в дообучении под диалог и в удобном интерфейсе, добавленном к модели, существовавшей и раньше. Этого оказалось достаточно, чтобы технология вышла за пределы сообщества, следившего за ней десять лет.

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

Скорость распространения

Миллион пользователей ChatGPT набрал за пять дней. Instagram потребовалось около двух с половиной месяцев, Airbnb и Netflix — годы. Этой скоростью объясняется и объём инвестиций: сотни миллиардов долларов, вложенных в дата-центры и обучение моделей.

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

Оговорка о сингулярности

В обсуждениях ИИ часто встречается слово «сингулярность», обозначающее гипотетическую точку, после которой технологический рост становится неуправляемо быстрым. Руководители индустрии поддерживают эту риторику; Сэм Альтман в июне 2025 года написал:

Мы прошли горизонт событий; взлёт начался.

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

Полезные ссылки

  • Vaswani A. et al. Attention Is All You Need. Статья, с которой начались большие языковые модели.
  • Блог Сэма Альтмана. Взгляд индустрии изнутри, с поправкой на интерес автора.
  • Epoch AI. Независимая аналитика по темпам развития ИИ и вычислительным затратам, собранная по открытым источникам.

ИИ и разработчик

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

Насколько это распространено

По опросу Stack Overflow 2024 года, охватившему десятки тысяч человек, около двух третей разработчиков уже применяли ИИ-инструменты в работе, и ещё примерно каждый седьмой собирался начать. В следующих выпусках опроса доля продолжает расти.

Тот же опрос показывает: инструментами пользуются охотно, однако коду, предлагаемому моделью, не доверяют и перепроверяют его.

Расхождение ощущений и измерений

В 2025 году организация METR провела эксперимент, поставленный со строгим контролем. Опытным разработчикам, годами ведущим проекты с открытым кодом, давали задачи в их собственных, хорошо знакомых им репозиториях, причём часть задач, выбранная случайным образом, решалась с ИИ-ассистентом, а часть без него. С ассистентом задачи выполнялись примерно на 19% медленнее. Сама цифра значит немного; важен знак, противоположный тому, чего ожидали и авторы, и участники.

Те же участники, опрошенные после эксперимента, были уверены, что ИИ ускорил их работу примерно на 20%. Ощущение разошлось с замером почти на 40 процентных пунктов.

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

Отсюда следуют два практических вывода:

  1. Ощущению ускорения верить нельзя. Чтобы узнать, помогает ли инструмент, необходимо провести измерение. Та же мысль приведена в главе про профилирование программ: интуиция, подсказывающая, где тратится время, ошибается систематически.
  2. Выигрыш зависит от того, насколько разработчик сам силён в задаче. Чем лучше он знает предметную область и написанный им же код, тем меньше даёт ассистент и тем важнее становится умение отличить дельное предложение от правдоподобного мусора, выданного с той же уверенностью.

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

Полезные ссылки

Технические основы

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

Принцип работы языковой модели

Большая языковая модель (LLM) решает одну задачу: предсказывает по имеющемуся тексту, какой фрагмент будет следующим. Всё остальное (диалог, генерация кода, объяснения) надстроено над этим механизмом.

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

Токены преобразуются в векторы и проходят через десятки слоёв трансформера. Ключевым механизмом внутри является внимание (attention): при обработке каждого токена модель взвешивает, насколько для него важен каждый из остальных. Это позволяет ей связывать определение функции, объявленной в начале файла, с её вызовом в конце.

Обучение состоит из двух этапов. Сначала модель в течение месяцев обрабатывает корпус текстов, собранных со всего интернета, и учится предсказывать продолжение, усваивая язык, факты и типовые приёмы программирования. Затем её дообучают следовать инструкциям и быть полезной в диалоге. Отсюда следуют два ограничения: модель не «знает» ничего после даты, на которой обрывается её обучающий корпус, и не имеет доступа к файлам пользователя, пока они не переданы ей явно.

Причины уверенных ошибок модели

Здесь заключена главная причина, по которой ИИ-ассистент не заменяет понимания.

Модель обучена порождать правдоподобное продолжение, а не истинное. Механизма проверки фактов у неё нет. Если правильный ответ ей неизвестен, она не сообщит об этом, а сгенерирует то, что выглядит как правильный ответ. Такие выдумки называют галлюцинациями; в программировании они принимают узнаваемые формы:

  • аргумент функции, придуманный по аналогии: в документации его нет, однако «логично было бы иметь»;
  • правдоподобная, но выдуманная ссылка на статью или страницу документации;
  • формула с перепутанным коэффициентом, выглядящая уверенно;
  • библиотека с осмысленным названием, которой не существует.

Несуществующая библиотека обнаруживается на первом же импорте. Перепутанный коэффициент в научном коде может остаться незамеченным. Программа запускается, выдаёт числа, числа попадают в отчёт, и никто не замечает, что формула была не та. Всё, что ассистент выдал про физику, численные методы и API библиотек, проверяется по документации и здравому смыслу. Код, в котором разработчик не разобрался, в проект не включается, кем бы он ни был написан.

Из чего складываются характеристики модели

  • Размер. Число обучаемых параметров, от единиц миллиардов до триллионов. Чем модель больше, тем более тонкие зависимости она способна уловить, но тем дороже и медленнее её работа. Для простых задач небольшая модель отвечает быстрее и дешевле при том же результате.
  • Контекстное окно. Число токенов, которое модель видит за раз, включая вопрос пользователя, приложенные файлы и собственный ответ. Это главный практический предел: в окно должен поместиться весь код, о котором задаётся вопрос, вместе с историей разговора. Чем ближе к пределу, тем хуже модель удерживает детали из середины контекста.
  • Способность к рассуждению. Часть моделей обучена перед ответом порождать промежуточные шаги рассуждения. Такие модели сильнее в задачах, требующих многошагового вывода (математика, отладка), но отвечают дольше и стоят дороже.

По причине, оговорённой в начале раздела, названия моделей и цены здесь не приводятся. Сравнение по качеству, скорости и стоимости ведут независимые площадки, Artificial Analysis и OpenRouter.

Формулировка запросов к модели

Способы формулировать запрос называют промтингом:

  • Zero-shot. Задать вопрос. Работает для типовых задач.
  • Few-shot. Приложить один-два примера в нужном формате. Повышает шансы получить ответ по образцу.
  • Chain-of-thought. Попросить рассуждать по шагам. Помогает там, где нужен многошаговый вывод; в «рассуждающих» моделях встроено по умолчанию.

Качество ответа определяется не изяществом формулировки, а тем, что модель видит. Если ей предоставлены нужный файл, сообщение об ошибке целиком, фрагмент документации, то и посредственный запрос даёт результат. Без них модель начинает додумывать недостающее. Этот подход, названный контекст-инжинирингом, важнее промтинга.

Агенты

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

Цикл устроен следующим образом: модель получает задачу, выбирает инструмент, анализирует результат и по нему решает, что делать дальше, пока задача не решена. Этот цикл превращает чат в инструмент, способный самостоятельно найти ошибку в проекте, исправить код и запустить тесты.

Главный риск состоит в том, что агент выполняет команды в системе пользователя. Первое правило работы с агентом, к которому мы вернёмся в следующей главе: допускать его только в репозиторий под контролем версий.

Полезные ссылки

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

Инструментарий

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

Три формы инструментов

Первая, наиболее простая для входа, — плагин к редактору. Он устанавливается в привычную разработчику среду (VS Code, JetBrains, Neovim) и работает внутри неё, ничего не нарушая. Наиболее известный — GitHub Copilot; бесплатный тариф у него предусмотрен для всех, а для студентов бесплатен полный, о чём подробнее говорится в следующей главе.

Вторая форма — редактор, спроектированный вокруг ассистента: отдельная среда разработки, в которой модель видит проект целиком, а не только открытый файл. Большинство таких редакторов выросло из VS Code, поэтому переход почти не требует привыкания, а настройки и расширения переносятся. Примерами являются Cursor и Windsurf.

Третья форма — терминальный агент, работающий в командной строке в каталоге проекта. Он читает файлы, правит их, запускает тесты и разбирает вывод. Эта форма лучше остальных подходит для больших проектов и удалённых машин без графической оболочки — обычная ситуация для физика, работающего на кластере по SSH. Так работают Claude Code, Aider и Gemini CLI.

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

Возможности инструментов

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

  • Автодополнение. Модель предлагает продолжение по ходу набора. Подсказка серым текстом выглядит правдоподобно, и её легко принять без чтения.
  • Правка выделенного. Разработчик выделяет фрагмент кода и указывает, что с ним сделать, например «добавь обработку ошибок», «перепиши через генератор», «векторизуй этот цикл».
  • Чат по проекту. Диалог, позволяющий сослаться на файлы. Подходит для вопроса «почему это падает» с приложенным трейсбеком.
  • Агентный режим. Задача ставится целиком, а агент сам читает нужные файлы, вносит правки, запускает тесты и исправляется по упавшим.

Чем ниже по этому списку, тем больше инструмент делает сам, тем меньше контроля остаётся у разработчика и тем внимательнее необходимо проверять результат.

Правила проекта

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

Сюда записываются договорённости, принятые в группе:

- Код на Python 3.12, стиль — PEP 8, длина строки 79 символов.
- Единицы СИ везде, кроме энергии частиц: она в МэВ.
- Массивы обрабатываем векторизованно, циклов по элементам не пишем.
- Новый код сопровождается тестом в tests/.
- Данные экспериментов в репозиторий не коммитим.

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

MCP: подключение собственных инструментов к агенту

Model Context Protocol (MCP) — открытый протокол, предложенный Anthropic в конце 2024 года и принятый остальными участниками отрасли.

Инструментов, необходимых агенту, много: база данных, система управления задачами, архив системы управления установкой и документация, размещённая в вики. Клиентов, которым такой доступ нужен, тоже много. Без общего протокола пришлось бы писать интеграцию для каждой пары «клиент — инструмент», а число таких пар растёт как произведение двух растущих списков. MCP вводит единый интерфейс: инструмент, написанный один раз в виде MCP-сервера, доступен любому клиенту, поддерживающему протокол.

Сценарий для научной группы: поднять MCP-сервер, обращающийся к архиву телеметрии установки, и спрашивать у агента, что происходило с током в магнитах в ночь неудачного запуска, вместо того чтобы каждый раз писать разбор логов заново.

Меры предосторожности

Ниже приведены несколько правил, которых рекомендуется придерживаться с первого дня работы с агентами:

  1. Только под контролем версий. Всё, сделанное агентом, должно быть видно в git diff и откатываться одной командой; о Git речь пойдёт в конце этой части.
  2. Чтение перед принятием. Отказаться от непонятной правки дешевле, чем впоследствии искать в ней ошибку.
  3. Мелкими шагами. Задача «перепиши мне весь модуль» даёт результат, который невозможно вычитать в разумное время. Задача «вынеси этот кусок в функцию и добавь тест» проверяется легко.
  4. Запрет на разрушительные команды. Удаление файлов, git push и отправка чего-либо наружу остаются решением, принимаемым человеком, а не агентом.
  5. Ответственность на разработчике. Автором кода считается человек, поставивший под ним коммит. Ссылка на то, что «так предложил ассистент», не работает ни в ревью, ни в статье.

Полезные ссылки

  • Документация Model Context Protocol. Спецификация и серверы, написанные сообществом.
  • Anthropic. Building Effective Agents. Когда агент оправдан, а когда достаточно запроса в чате.
  • Aider. Терминальный агент с открытым исходным кодом, удобный для знакомства с этой формой работы.

GitHub Copilot

Для студента наиболее простым способом начала работы является плагин к редактору. Ниже рассматривается самый распространённый из таких плагинов, выбранный по двум причинам: он бесплатен без подписки (студенту предоставляется полная версия) и устанавливается в среду, уже используемую для работы.

Назначение GitHub Copilot

GitHub Copilot — плагин к редактору, дописывающий код за разработчиком. Он был создан компанией GitHub совместно с OpenAI; первая версия, выпущенная в 2021 году, опередила ChatGPT на полтора года.

Проще говоря. Copilot работает как автодополнение, видящее контекст всего файла, а не только набираемую строку.

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

Польза для студентов

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

Польза от него на этапе обучения заключается в трёх вещах. Во-первых, не требуется каждый раз обращаться к документации за порядком аргументов: по контексту он предлагает правильный вызов, который запоминается после нескольких показов. Во-вторых, он помогает сдвинуться с мёртвой точки при работе с пустым файлом, когда неясно, с чего начать; даже небрежно набросанная заготовка даёт точку опоры. В-третьих, его предложения показывают, как принято писать на плохо знакомом языке. Для физика, раз в год правящего чужой код на C++, это заметное подспорье.

Всё сказанное работает только при условии, что предложенное читается. Принятое без чтения предложение представляет собой отложенную отладку.

Начало работы

Установка и настройка

  1. Требования:

    • Аккаунт на GitHub
    • Тариф Copilot: бесплатный включается на любом аккаунте, полный активируется студенческим пакетом
    • Поддерживаемая среда: VS Code, JetBrains, Visual Studio, Neovim и другие
  2. Установка (на примере VS Code):

    • установить расширение «GitHub Copilot» из встроенного магазина;
    • авторизоваться через GitHub по запросу расширения;
    • при желании активировать студенческий пакет в настройках аккаунта, чтобы снять ограничения бесплатного тарифа.

Активация студенческой подписки

  1. Перейти на education.github.com
  2. Подать заявку на Student Developer Pack
  3. Активировать Copilot в настройках аккаунта

Эффективное использование Copilot

Базовые приёмы работы

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

  1. Комментирование намерений:

    # Напиши функцию для расчёта среднего значения списка
    # Copilot предложит:
    def calculate_average(numbers):
        return sum(numbers) / len(numbers) if numbers else 0
    
  2. Создание шаблонов функций:

    function calculateCircleArea(radius) {
      // Copilot предложит:
      return Math.PI * radius * radius;
    }
    
  3. Работа с библиотеками:

    import numpy as np
    # Создать массив из 10 случайных чисел от 1 до 100
    # Copilot предложит:
    arr = np.random.randint(1, 101, 10)
    

Продвинутые сценарии

Copilot справляется с однообразной работой вокруг уже написанного кода — тестами и переписыванием фрагментов, написанных наспех.

Генерация тестов:

# Функция сложения двух чисел
def add(a, b):
    return a + b

# Напиши тесты для этой функции
# Copilot предложит:
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

Рефакторинг кода:

// Старый код
function processData(data) {
    let result = [];
    for (let i = 0; i < data.length; i++) {
        if (data[i].active) {
            result.push(data[i].value * 2);
        }
    }
    return result;
}

// Добавь комментарий: "Рефакторинг с использованием map и filter"
// Copilot предложит:
function processData(data) {
    return data
        .filter(item => item.active)
        .map(item => item.value * 2);
}

Примеры для учебных задач

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

Для курса по алгоритмам

# Реализуй быструю сортировку на Python
# Copilot предложит:
def quicksort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    return quicksort(left) + middle + quicksort(right)

Для курса по физике

# Рассчитать траекторию движения тела под углом к горизонту
# Copilot предложит:
def calculate_trajectory(initial_velocity, angle_degrees, times):
    import math
    g = 9.81
    angle_rad = math.radians(angle_degrees)
    vx = initial_velocity * math.cos(angle_rad)
    vy = initial_velocity * math.sin(angle_rad)
    
    trajectory = []
    for t in times:
        x = vx * t
        y = vy * t - 0.5 * g * t**2
        if y < 0:
            break
        trajectory.append((x, y))
    return trajectory

Важные ограничения и предостережения

Типичные ошибки Copilot

Ошибки делятся на три вида, и все три обнаруживаются чтением.

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

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

Академическая честность

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

Лучшие практики для студентов

Пять рабочих привычек:

  1. Точная формулировка задачи. Чем конкретнее комментарий, тем ближе предложение к задуманному
  2. Чтение предложения целиком. Прежде всего рассматриваются граничные случаи: пустой список, деление на ноль, отсутствующий файл
  3. Проверка тестом, а не взглядом. Короткий тест на паре значений отвечает надёжнее самого внимательного чтения
  4. Передача шаблонной работы. Разбор аргументов командной строки, чтение форматов данных, однотипные графики
  5. Рассмотрение не только первого варианта. Copilot показывает одно решение из многих, и не всегда лучшее

Заключение

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

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

Вопросы и ответы

Ниже разобраны вопросы, задаваемые студентами чаще всего.

Какую модель выбрать для работы с кодом?

Имеет смысл посмотреть свежее сравнение на независимой площадке наподобие Artificial Analysis, отобрать две-три модели и прогнать на них собственные типичные задачи. Разница между лидерами рейтинга обычно меньше, чем разница между задачами пользователя и теми, на которых рейтинг составлялся.

Выбор определяют три параметра: качество на рассматриваемом типе задач, размер контекстного окна (поместится ли в него проект) и стоимость. Для рутины обычно достаточно модели поменьше и подешевле, тяжёлая нужна только для сложной отладки.

Сложно ли внедрить это в старый большой проект?

Сложность растёт с размером кодовой базы. Чем больше проект, тем меньшая его часть помещается в контекстное окно и тем важнее уметь показать модели нужные файлы. Помогают инструменты, умеющие искать по репозиторию самостоятельно; терминальные агенты, запущенные из корня проекта, справляются с этим обычно лучше, чем автодополнение в редакторе.

Начинать лучше не с кода, написанного предшественниками, а с того, чего в проекте не хватает, поручив ассистенту тесты к уже работающим функциям, docstring'и и README. Так становится видно, насколько модель понимает проект.

Может ли ИИ придумать что-то принципиально новое?

Модель сильна настолько, насколько задача похожа на то, что встречалось в обучающих данных. Она может неожиданно скомбинировать известные приёмы и подсказать подход, неизвестный исследователю, но нового метода не изобретёт. Её сильные стороны — скорость, объём и знание решений, накопленных сообществом, но не оригинальность.

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

Как начать применять это в научной работе?

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

  1. Шаблонный код. Разбор форматов данных, переименование файлов, конвертеры. Ошибка проявится сразу.
  2. Объяснение чужого кода. Ассистенту поручается разобрать скрипт, доставшийся от предшественника. Проверяется чтением исходника.
  3. Документация и тесты к собственному уже работающему коду. Тесты заодно проверят и сам код.
  4. Отладка. Прикладывается трейсбек целиком, а не пересказ, записанный по памяти.
  5. Автоматизация рутины вокруг расчётов: запуск серий, сбор результатов, сводные графики.

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

Можно ли пользоваться ИИ при выполнении заданий курса?

Можно и нужно, с одним условием. Студент обязан понимать каждую сдаваемую строку и уметь её объяснить. На защите задания будет задан вопрос, почему здесь сделано так. Отсутствие объяснения означает, что задание не засчитано, кем бы ни была написана сданная строка.

Git

Git — распределённая система контроля версий (version control system, VCS): она хранит историю изменений файлов, позволяет вернуться к любой прошлой версии и объединяет работу нескольких человек над одним проектом. Для научной работы Git является таким же обязательным инструментом, как Python или LaTeX.

Слайды к главе. Материал главы изложен также во второй половине лекции «ИИ и Git» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Назначение контроля версий

Рассмотрим типичную курсовую работу с расчётами:

processing.py
processing_new.py
processing_final.py
processing_final_v2.py
thesis_final_v2_FINAL(2).py
thesis_final_v2_FINAL(2)_fixed_mass.py

Через месяц требуется перегенерировать третий рисунок для статьи, и никто, включая автора, не помнит, каким из файлов он построен. При этом «финальная» версия даёт другой хвост распределения, поскольку правка константы, сделанная в процессе работы, осталась неучтённой.

Контроль версий решает три проблемы:

  • История вместо множества файлов. В проекте хранится один processing.py, а все его прошлые версии остаются в Git. К любой из них можно вернуться, любые две можно сравнить построчно.
  • Воспроизводимость расчётов. Каждое состояние проекта имеет уникальный идентификатор, хеш коммита. Если записать его рядом с результатом, через год код, построивший график, восстанавливается дословно. Запись «Рисунок 3 построен на коммите a3f2c17» и является воспроизводимостью.
  • Совместная работа. Когда над обработкой данных работают три человека, Git объединяет их изменения и показывает, кто, что и зачем изменил. Пересылать архивы code_v7_new.zip по почте не требуется.

Репозиторий на сервере служит и резервной копией: после вечерней отправки изменений на сервер выход из строя диска ноутбука перестаёт быть катастрофой.

Устройство Git

Устройство Git основано на трёх идеях, из которых выводится почти всё поведение команд.

Снимки, а не различия

Git хранит не «патчи к предыдущей версии», а снимки (snapshots). Каждый коммит представляет собой состояние всего проекта в момент фиксации плюс метаданные: автор, дата, сообщение и ссылка на родительский коммит. Неизменившиеся файлы не копируются, Git ссылается на уже сохранённые объекты, и репозиторий не разрастается.

Каждый объект адресуется хешем SHA-1, строкой вида a3f2c17b... из git log; в командах достаточно первых 6–8 символов.

Три состояния

Файлы проекта находятся в трёх зонах:

рабочая директория  ──►  индекс (staging area)  ──►  репозиторий (.git)
  правишь файлы          git add: отбираешь           git commit:
                         изменения для коммита        фиксируешь снимок
  • Рабочая директория (working directory) содержит обычные файлы, которые редактирует разработчик.
  • Индекс (staging area) — промежуточная область перед коммитом. Командой git add в неё помещаются изменения, которые войдут в следующий снимок.
  • Репозиторий находится в скрытой папке .git, хранящей все коммиты. Коммит, недостижимый ни через ветку, ни через тег, сохраняется ещё около месяца записью в git reflog, а затем удаляется сборщиком мусора, поэтому спасательный приём из справочной таблицы в конце главы действует ограниченное время.

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

Коммиты образуют граф

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

A ─── B ─── C ─────── F      main
       └── D ─── E ───┘      fft-filter

Ветка — подвижный указатель на коммит, файл размером 41 байт внутри .git; копия проекта не создаётся, поэтому ветки создаются мгновенно и без затрат. HEAD указывает на текущее положение в графе.

Базовый цикл работы

Разовая настройка

# имя и почта автора попадут в каждый коммит
git config --global user.name "Lev Landau"
git config --global user.email "l.landau@g.nsu.ru"

# называть главную ветку main, как принято на GitHub
git config --global init.defaultBranch main

Создать или склонировать репозиторий

# превратить текущую папку в репозиторий (появится скрытая папка .git)
git init

# или склонировать существующий проект с сервера
git clone https://github.com/nsu-phys/beam-dynamics.git

Путь правки: рабочий каталог, индекс, коммит

В цикле, описанном ниже, проходит почти вся повседневная работа.

git status                  # что изменилось? (спрашивай постоянно)

git add processing.py       # добавить файл в индекс
git add plots/              # можно папку целиком
git add -p                  # интерактивно, по кускам — очень полезно

git commit -m "Учтена поправка на температуру датчика"

git status                  # убедиться, что всё зафиксировано

git status показывает состояние всех трёх зон и подсказывает следующий шаг. На первых порах эту команду имеет смысл вызывать до и после каждой операции.

Просмотр истории и различий

git log                     # полная история с авторами и датами
git log --oneline           # по строчке на коммит
git log --oneline --graph --all   # весь граф с ветками, псевдографикой

git diff                    # что поменялось, но ещё не добавлено в индекс
git diff --staged           # что уже в индексе и войдёт в коммит
git diff a3f2c17 HEAD -- processing.py  # как файл изменился со старого коммита

git show a3f2c17            # что именно сделал этот коммит

.gitignore: исключение файлов из репозитория

Git отслеживает только явно добавленные файлы, однако git status перечисляет всё содержимое каталога, от кешей Python до гигабайтных файлов с данными. Файл .gitignore в корне проекта задаёт, что нужно игнорировать. Заготовка для научного проекта приведена ниже:

# байткод и кеши Python
__pycache__/
*.pyc
.ipynb_checkpoints/

# виртуальное окружение
.venv/

# данные измерений и результаты расчётов — им не место в git
data/
*.h5
*.root
*.npz

# тяжёлые артефакты: чекпойнты моделей, дампы
checkpoints/
*.ckpt

# мусор редакторов и ОС
.idea/
.vscode/
.DS_Store

# LaTeX-мусор
*.aux
*.synctex.gz

В репозитории хранится написанное вручную (код, тексты, конфигурации) и не хранится измеренное на установке или полученное расчётом. Сам .gitignore фиксируется в репозитории: правила исключений являются общими для всей группы.

Ветки

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

git branch                    # список веток, звёздочка — текущая
git switch -c fft-filter      # создать ветку и переключиться на неё
# ...правишь и коммитишь сколько угодно...
git switch main               # вернуться на main: файлы мгновенно
                              # станут такими, какими были там

git switch появился в Git 2.23; в старых руководствах те же действия выполняются командами git checkout -b fft-filter и git checkout main.

Слияние

Когда эксперимент в ветке завершён успешно, ветка вливается обратно:

git switch main
git merge fft-filter          # влить коммиты fft-filter в main
git branch -d fft-filter      # удалить ненужный больше указатель

Если main с момента ветвления не изменялся, Git передвинет указатель вперёд (fast-forward). Если изменялся, будет создан коммит слияния с двумя родителями, и граф из приведённого выше рисунка сходится в одну линию.

Конфликты

Если в двух ветках изменены одни и те же строки, Git не будет выбирать версию самостоятельно и вставит в файл маркеры, размечающие оба варианта:

<<<<<<< HEAD
dt = 1e-12  # шаг интегрирования для жёсткой задачи
=======
dt = 5e-12  # компромисс между точностью и скоростью
>>>>>>> fft-filter

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

git add integrator.py         # пометить конфликт разрешённым
git commit                    # завершить merge

Если принято решение отказаться от слияния, вместо этих двух команд требуется одна:

git merge --abort             # откатить слияние целиком

После git commit сливать уже нечего, и git merge --abort сообщит, что отменять нечего.

Чем мельче коммиты и чем чаще выполняется слияние веток, тем реже возникают конфликты и тем проще они разрешаются.

Удалённые репозитории

Пока коммиты находятся только на ноутбуке разработчика, нет ни резервной копии, ни совместной работы. Удалённый репозиторий (remote) хранит копию проекта на GitHub, GitLab или на институтской машине группы.

git remote -v                  # список удалённых репозиториев
# после git clone там уже есть origin — адрес, откуда клонировал

# привязать локальный репозиторий к созданному на GitHub
git remote add origin git@github.com:username/beam-dynamics.git

git push -u origin main        # отправить ветку main на сервер;
                               # -u запоминает связку, дальше просто git push

git fetch                      # скачать чужие коммиты, НЕ трогая рабочие файлы
git pull                       # fetch + merge: скачать и влить в текущую ветку
git push                       # отправить свои коммиты

git fetch безопасен всегда: он лишь обновляет локальные сведения о сервере, после чего можно просмотреть git log origin/main и изучить коммиты коллег. git pull объединяет fetch с немедленным слиянием. Типовой ритм работы: в начале сеанса выполняется git pull, по окончании — git push. Чем чаще выполняется синхронизация, тем меньше конфликтов.

Fork + pull request

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

  1. Fork. Кнопкой на GitHub создаётся личная копия репозитория в аккаунте разработчика.
  2. Форк клонируется, создаётся ветка, фиксируется исправление.
  3. Ветка отправляется в форк.
  4. Открывается pull request (PR) — предложение включить коммиты в основной репозиторий, сопровождаемое описанием и дифом.
  5. Владельцы читают код, пишут замечания (code review), автор дополняет ветку новыми коммитами, и PR обновляется автоматически.
  6. Одобренный PR вливается в основной репозиторий.

По этой схеме работает весь open source и заметная часть открытой науки; исправление одной строки в документации scipy проходит тот же путь. Внутри своей группы обычно отправляют коммиты в общий репозиторий, однако правки всё равно оформляются ветками и PR ради ревью.

Хорошие практики

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

Не менее важно сообщение коммита. Слово fix не сообщает, что исправлено, где и почему; через полгода git log из fix, fix2 и wip превращается в длительные археологические раскопки. Диф и так покажет, что изменилось. Следует писать короткий заголовок с сутью изменения, не «поменял коэффициент», а «Увеличен шаг сетки: старый не помещался в память на кластере». По общепринятому соглашению заголовок укладывается в 50 символов и не превышает 72. Сообщение должно объяснять, зачем сделано изменение.

Git создан для текста и плохо работает с большими бинарными файлами. Каждый попавший в историю гигабайт останется в ней навсегда, и клонирование станет неприемлемо долгим. Сырые данные с установки, HDF5/ROOT-файлы, видео, обученные модели в репозиторий не помещают. Для версионирования больших файлов рядом с кодом существуют Git LFS (в git хранятся лишь ссылки, а содержимое находится на отдельном сервере) и DVC (Data Version Control, версионирующий данные и пайплайны обработки, а сами файлы размещающий в любом хранилище, будь то облако или сетевой диск группы). Пароли, токены и ключи фиксировать в репозитории недопустимо: удалить секрет из истории почти невозможно, а клоны коллег успевают разойтись.

Исправление ошибок

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

СитуацияКоманда
Файл испорчен, требуется версия из последнего коммитаgit restore file.py (незакоммиченные правки исчезнут!)
В индекс добавлено лишнееgit restore --staged file.py (убирает из индекса, правки останутся)
Опечатка в сообщении последнего коммита или забыт файлgit commit --amend (только пока не запушил)
Требуется «рассыпать» последний коммит обратно в правкиgit reset --soft HEAD~1 (коммит исчез, изменения остались в индексе)
Удалить последний коммит вместе с изменениямиgit reset --hard HEAD~1 (опасно, правки будут уничтожены)
Отменить уже запушенный коммитgit revert a3f2c17 (новый коммит с обратным изменением, история не переписывается)
Срочно нужна другая ветка, а правки не законченыgit stash прячет изменения, git stash pop возвращает
«Всё сломано, коммиты пропали»git reflog, журнал последних положений HEAD; достаточно найти нужный хеш и вернуться командой git reset --hard <хеш>

git reset --hard — одна из немногих команд, без предупреждения и необратимо уничтожающих незакоммиченную работу. Перед её выполнением необходимо вызвать git status и убедиться, что терять нечего. Историю переписывает и git rebase, переносящий коммиты ветки поверх другой так, как если бы они были сделаны там изначально: граф получается линейным и читаемым, однако перенесённые коммиты получают новые хеши, то есть прежних коммитов больше нет. Не следует переписывать историю (reset, --amend, rebase) в ветках, уже отправленных на сервер и полученных коллегами: им придётся разбираться с разошедшимся графом.

Полезные ссылки

Основы Linux

Под управлением Linux работают вычислительные кластеры, серверы установок и машины сбора данных, то есть практически всё оборудование, на котором выполняются физические расчёты. Независимо от того, ведётся ли разработка на ноутбуке с Windows или macOS, расчёт в итоге выполняется на такой машине. Рано или поздно наступает момент, когда он не запускается, заканчивается место на диске или процесс перестаёт отвечать, и разбираться с этим приходится самому исследователю.

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

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

Инструменты Linux

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

Глава выстроена по нарастающей: сначала рассматривается обустройство рабочего места, затем основы командной строки, далее утилиты, необходимые каждый день, и, наконец, диагностика в ситуации, когда система работает не так, как ожидалось. Запоминать всё подряд не требуется: достаточно знать, что подходящий инструмент существует, а подробности всегда приведены в man.

Слайды к главе. Материал главы изложен также в лекции «Инструменты Linux» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Подготовка и окружение

Слепая печать

Первым навыком является умение печатать, не глядя на клавиатуру. Две вложенные недели окупаются годами: растёт скорость, растёт точность, а внимание постоянно остаётся на экране. В первые месяцы после переучивания скорость, набранная годами, снижается, и это является нормальным.

Инструменты для тренировки:

  • GNU Typist (gtypist), консольный тренажёр. Устанавливается через apt-get install gtypist или brew install gtypist.
  • monkeytype, современный онлайн-тренажёр.
  • www.keybr.com, ещё один популярный онлайн-тренажёр.

Эмулятор терминала

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

  • вкладки;
  • разделение окна на панели;
  • копирование в буфер обмена простым выделением текста;
  • ссылки, открывающиеся по щелчку;
  • плагины;
  • темы, прозрачность и прочие мелочи, повышающие удобство работы.

Рекомендуемые терминалы:

  • macOS: iTerm2
  • Linux: Terminator, Kitty, Konsole
  • Windows 10/11: Windows Terminal
  • Windows (условно-бесплатный): MobaXterm (также включает SSH-клиент, X-сервер и сетевые инструменты).

Моноширинные шрифты

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

  • Source Code Pro (Adobe)
  • Inconsolata (Google)
  • Anonymous Pro

Все три распространяются свободно и в большинстве дистрибутивов доступны в подключённых репозиториях.

Командные оболочки (Shell)

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

  • bash (Bourne-Again SHell): универсальный стандарт. Присутствует на любой машине, доступной по ssh, поэтому переносимые скрипты пишутся под него.
  • zsh (Z Shell): те же возможности плюс интеллектуальное автодополнение и богатая экосистема плагинов; настройку упрощает фреймворк Oh My Zsh. Разумный выбор для собственной рабочей машины.
  • fish (Friendly Interactive SHell): подсказки по истории, подсветка и автодополнение работают сразу, без настройки. Платой является несовместимость с синтаксисом POSIX-оболочек: скрипт, написанный для bash, в fish не запустится.

Основы командной строки

Структура команд

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

  • Оболочка ищет исполняемый файл по каталогам из переменной окружения $PATH и запускает его системным вызовом exec.
  • which <команда> или type <команда> показывают полный путь к исполняемому файлу команды. Флаг -a показывает все возможные пути в $PATH.
  • Флаги команд не стандартизированы, но существуют соглашения:
    • -v / --version показывают версию.
    • -h / --help показывают справку.
    • -y / -f пропускают подтверждение (yes/force).

Справка man

Основным источником информации является man (manual). Обращаться к нему следует раньше, чем к поисковой системе: в man описана версия утилиты, установленная в системе, а не та, о которой три года назад писали на форуме.

  • Пейджер: По умолчанию для просмотра используется less. Его можно изменить переменной окружения $MANPAGER (например, MANPAGER=cat).
  • Основные клавиши в less:
    • h вызывает помощь.
    • PgUp, PgDown, ↓, ↑ служат для навигации.
    • /KEYWORD запускает поиск.
    • n переходит к следующему совпадению, N к предыдущему.
    • g переносит в начало документа.
    • G переносит в конец документа.
  • man <раздел> <команда>, например man 1 bash (справка по bash из раздела «пользовательские команды»).
  • man -P 'less +/KEYWORD' bash открывает man с поиском по ключевому слову.

Управление процессами

Запущенной программой можно управлять из того же терминала. Все сочетания, перечисленные ниже, посылают процессу сигналы, устройство которых рассматривается в главе про внутреннее устройство Linux, а команды fg, bg и jobs определяют, на переднем плане процесс выполняется или в фоне.

  • Ctrl + C посылает сигнал SIGINT (завершение процесса).
  • Ctrl + \ посылает сигнал SIGQUIT (завершение с дампом памяти).
  • Ctrl + Z посылает сигнал SIGTSTP (приостановка процесса).
  • fg возвращает приостановленный процесс на передний план.
  • bg запускает приостановленный процесс в фоновом режиме.
  • jobs выводит список задач текущей оболочки с их номерами.
  • stty -a показывает настройки терминала и позволяет их изменить.

Управление вводом/выводом (I/O Redirection)

У каждой запущенной программы имеются три открытых потока, а перенаправление подменяет то, куда они ведут. На нём основаны конвейеры: вывод одной программы становится входом другой, и ни одна из них об этом не знает.

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

  • Потоки данных:
    • stdin (0), стандартный вход.
    • stdout (1), стандартный выход.
    • stderr (2), стандартный вывод ошибок.
  • Перенаправление:
    • команда > файл записывает stdout в файл, перезаписывая его.
    • команда >> файл дописывает stdout в конец файла.
    • команда 2> файл записывает stderr в файл.
    • команда &> файл записывает stdout и stderr в файл.
    • команда 2>&1 перенаправляет stderr в stdout.
    • команда | другая_команда передаёт stdout первой команды на stdin второй.
  • Специальные файлы:
    • /dev/null, «чёрная дыра».
    • /dev/zero, источник нулей.
    • /dev/urandom, источник псевдослучайных чисел.

Полезные помощники

Ни один из этих приёмов не требует специального изучения: достаточно однократного применения.

  • Автодополнение (completion): клавиша TAB.
    • Одно нажатие дополняет команду или файл.
    • Двойное нажатие показывает все возможные варианты.
  • История команд (~/.bash_history, ~/.zsh_history):
    • Ctrl + R запускает реверсивный поиск по истории.
    • history | grep <ключевое_слово> ищет по истории.
    • sudo !! выполняет предыдущую команду с sudo.
    • !$ подставляет последний аргумент предыдущей команды.

Ветвления, циклы и подоболочки (Subshell)

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

  • ; разделяет команды, выполняемые последовательно.
  • Циклы и условия:
    while true; do ...; done
    for i in $(seq 1 10); do echo $i; done
    if [ "$(wc -l < file.txt)" -gt 3 ]; then ...; fi
    
  • read, встроенная команда для чтения ввода в переменные.
  • Подстановка команд (command substitution): $(команда) подставляет вывод команды в строку; сама команда выполняется в подоболочке, поэтому присвоенные в ней переменные наружу не передаются.
  • Подоболочка (subshell): ( команда; команда ) — дочерняя копия оболочки со своими переменными и текущим каталогом, так что cd внутри скобок родительский каталог не меняет.
  • [ является не элементом синтаксиса оболочки, а командой: в /usr/bin расположена одноимённая программа, хотя bash и zsh выполняют собственную встроенную. Отсюда и требование ставить пробелы вокруг скобок: без них это будет другое имя команды. Подробности: man [ или man test.

Полезные команды и утилиты

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

Find и многопоточность

Связка find и xargs применяет команду ко всем файлам, удовлетворяющим условию. Флаг -P у xargs превращает её в простейший параллельный запуск с заданным числом одновременных процессов. Выигрыш растёт с числом процессов, пока задача ограничена процессором; на обработке, ограниченной диском, он выходит на насыщение заметно раньше.

  • find осуществляет мощный поиск файлов и директорий.
    • Пример: find /etc -type f -name "nginx*"
  • xargs передаёт результаты из stdin в аргументы другой команды.
    • -P <N> запускает до N процессов параллельно.
    • -n1 передаёт по одному аргументу за раз.
    • -0 читает имена, разделённые нулевым байтом, — пара к find -print0; без неё имена с пробелами будут разбиты на части.
    • Пример: find /data -type f -print0 | xargs -0 -P10 -n1 md5sum
  • GNU parallel служит более мощной альтернативой xargs для параллельного выполнения.

Управление сессиями: screen и tmux

Расчёт, запущенный по ssh, завершается вместе с обрывом связи, а связь с кластером обрывается регулярно. screen и tmux решают эту проблему: сессия, отвязанная от терминала, продолжает работать на сервере, а пользователь подключается к ней и отключается в любой момент, в том числе с другой машины.

  • screen:

    • screen создаёт новую сессию.
    • Ctrl + A, D отключает от сессии (detach).
    • screen -ls выводит список сессий.
    • screen -r <session> подключает к сессии.
  • tmux (рекомендуется):

    • tmux создаёт сессию.
    • Ctrl + B, D отключает от сессии.
    • tmux ls выводит список сессий.
    • tmux a -t <session> подключает к сессии.
    • Интеграция с iTerm2: tmux -CC.
  • watch -d -n1 "команда" выполняет команду раз в секунду и подсвечивает меняющиеся значения.

Редактирование текста

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

  • Vim:
    • Мощный редактор, присутствующий на любой машине, но с непривычным управлением, которое требует отдельного освоения.
    • vimtutor, встроенный интерактивный учебник на полчаса.
    • Основы: i включает режим вставки, ESC возвращает в нормальный режим, :wq сохраняет и выходит, :q! выходит без сохранения.
    • vimdiff file1 file2 сравнивает файлы.
  • Emacs: другой мощный редактор со своей философией и своими сторонниками.
  • Git: система контроля версий, без которой сегодня не обходится ни один проект; ей посвящена отдельная глава. Для сообщения коммита git открывает редактор, заданный настройкой core.editor или переменными окружения VISUAL и EDITOR. Если ничего не задано, в Ubuntu и Debian открывается системный редактор по умолчанию, обычно nano (подсказки клавиш показаны внизу экрана: Ctrl+O сохраняет файл, Ctrl+X выходит), а в macOS и большинстве других систем — vi. Привычный редактор задаётся явно: git config --global core.editor vim.

Потоковая обработка текста

Каждая из этих утилит выполняет над текстовым потоком одну операцию, а соединяются они конвейером. Разбор журнала, извлечение колонки чисел, подсчёт уникальных значений укладываются в одну строку, набранную по ходу работы. Как только разбор перестаёт быть разовым и становится частью повторяемого конвейера обработки данных, его лучше переписать на Python: цепочку из пяти awk и sed через месяц невозможно ни прочитать, ни отладить.

  • grep фильтрует строки по регулярному выражению.
  • awk, целый язык для обработки текста, основанный на колонках.
  • sed, потоковый редактор, правящий текст на лету (замена, удаление строк и т.д.).
  • cut извлекает конкретные колонки.
  • head и tail выводят начало и конец файла, а tail -f показывает строки, дописываемые в настоящий момент.
  • tr заменяет или удаляет символы.
  • wc считает строки, слова и символы.
  • sort сортирует строки.
  • uniq отфильтровывает повторяющиеся строки.

Тренажёры: grepexercises, sedexercises, awkexercises (устанавливаются через pip).

Практика: The Command Line Murders, детективный квест, расследуемый исключительно командами в терминале: https://github.com/veltman/clmystery


Диагностика и поиск проблем

Последняя часть посвящена ситуации, когда система работает не так, как ожидалось: расчёт стал необъяснимо медленным, место на диске закончилось, сервис не отвечает.

Общий принцип диагностики состоит в движении сверху вниз по подсистемам (процессор, память, диск, сеть) с принятием на каждом шаге решения, здесь ли узкое место или дальше. Каждое предположение необходимо подтверждать измерением на работающей системе.

Методология и ресурсы

Карта инструментов, охватывающая всю систему целиком, составлена Бренданом Греггом (Brendan Gregg). На его сайте https://www.brendangregg.com/linuxperf.html приведены схемы, где каждая подсистема ядра подписана инструментами, которыми она измеряется. Схем четыре:

  • Linux Observability Tools для наблюдения за работающей системой;
  • Linux Static Performance Tools для сбора статической конфигурации, заданной до всякой нагрузки;
  • Linux Benchmarking Tools для нагрузочного тестирования;
  • Linux Tuning Tools для подстройки параметров ядра.

Первую, самую насыщенную, рекомендуется распечатать и разместить рядом с рабочим местом. У того же автора имеется книга «Systems Performance: Enterprise and the Cloud», справочник по всему, что в Linux измеримо.

Мини-топ полезных утилит

  • Мониторинг процессов и системы:
    • top и htop показывают процессы в интерактивном режиме.
    • atop ведёт расширенный мониторинг и позволяет записывать данные в файл.
    • pidof находит PID по имени процесса.
    • ps fauxw | less даёт подробный список запущенных процессов.
  • Диски и файловая система:
    • df -h и df -ih показывают место на дисках и inodes.
    • du -sh /path/* показывает размер директорий.
    • iostat -x 1 выводит статистику ввода-вывода.
  • Сеть:
    • ss -s и netstat -ntlp выводят статистику сокетов и открытые порты.
    • ping, traceroute и mtr диагностируют сетевую связность.
    • tcpdump работает «сниффером», перехватывающим сетевые пакеты.
    • ethtool eth0 показывает настройки и статистику сетевого интерфейса.
  • Прочее:
    • lsof -i:ПОРТ показывает, какой процесс держит порт, а lsof -p $PID — какие файлы и сокеты открыл процесс.
    • strace -fp $PID и strace $cmd трассируют системные вызовы.
    • dmesg -T показывает сообщения ядра с проставленными временными метками.
    • perf top -F99 профилирует систему в реальном времени.
    • curl — универсальный инструмент для работы с HTTP и сетью.

Заключение

Осваивать всё сразу не требуется. Рекомендуемый порядок следующий: сначала слепая печать и полноценный терминал, затем привычка обращаться к man вместо поисковой системы, затем tmux, позволяющий не терять расчёты на кластере при обрыве связи, и уже после этого grep, awk и sed. Каждый следующий инструмент осваивается на реальной задаче, а не заранее и впрок.

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

Задание. Долгий расчёт, переживающий закрытие сессии: «Работа с долгоиграющими процессами».

Задание. Обустройство рабочего места в терминале и выполнение набора упражнений: «Настройка окружения и работа с утилитами».

Процесс загрузки

Микропрограмма, записанная в чип материнской платы, инициализирует оборудование. Загрузчик отыскивает ядро и запускает его. Ядро разворачивает временную файловую систему, монтирует настоящую и передаёт управление первому процессу. Монтированием называется подключение файловой системы в общее дерево каталогов; подробно этот вопрос рассматривается в следующей главе. Каждое звено цепочки передаёт управление следующему и завершается.

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

Слайды к главе. Материал главы изложен также в первой части лекции «Как организован Linux» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

1. BIOS (Basic Input/Output System)

BIOS — микропрограмма, хранящаяся на чипе материнской платы (во FLASH-памяти). При включении питания процессор выполняет первую инструкцию по фиксированному адресу 0xFFFFFFF0 (reset vector), где и расположен код BIOS.

Первым делом BIOS проводит самотестирование, называемое POST (Power-On Self-Test): проверяет целостность собственной микропрограммы, инициализирует процессор, установленную память и чипсет, а затем запускает прошивки остальных устройств, видеокарты и сетевой карты. Пока видеокарта не инициализирована, выводить сообщение об ошибке некуда, поэтому о результатах POST сообщает встроенный в плату динамик. Один короткий сигнал означает успех, комбинации длинных и коротких указывают на конкретную неисправность; расшифровка, приведённая в документации к плате, у каждого производителя своя.

2. Загрузчик (Bootloader)

Задача загрузчика — найти, загрузить в оперативную память и передать управление ядру операционной системы.

Схемы загрузки

Схем две, и различаются они возможностями прошивки.

Старую схему составляют BIOS + MBR (Master Boot Record). BIOS способен только прочитать первые 512 байт диска и передать им управление. В них размещаются 446 байт первичного кода загрузчика, 64 байта таблицы разделов (не более четырёх первичных) и два байта сигнатуры 55 AA. Код из MBR отыскивает активный раздел, помеченный особым флагом, и загружает оттуда следующую ступень загрузчика, например GRUB2. Из размера полей в таблице разделов следует и главное ограничение схемы: диски больше 2 ТБ ей недоступны.

Современную схему составляют UEFI + GPT (GUID Partition Table). UEFI читает файловые системы, в первую очередь FAT32, и проверяет цифровую подпись, поставленную на загрузчик (Secure Boot). Размещать код в первых секторах диска не требуется, поскольку прошивка обращается к обычному FAT32-разделу, EFI System Partition, где загрузчики хранятся обычными файлами с расширением .efi наподобие grubx64.efi, а список загрузочных записей хранится в энергонезависимой памяти материнской платы (NVRAM). Таблица разделов GPT ограничений MBR не наследует.

Полезные команды:

  • Просмотр UEFI-записей: efibootmgr -v
  • Просмотр разделов: fdisk -l

GRUB2 (Grand Unified Bootloader 2)

Самый распространённый загрузчик в Linux. Он показывает меню, в котором выбирается ядро или другая установленная система, загружает модули для работы с нужным оборудованием и файловыми системами и затем запускает выбранное ядро. Настройки хранятся в /boot/grub/grub.cfg, однако править этот файл вручную бессмысленно. Он собирается заново командой grub-mkconfig из /etc/default/grub и скриптов в /etc/grub.d/, и обновление ядра уничтожит ручную правку.

3. Ядро (Kernel)

Ядро Linux представляет собой сжатый исполняемый файл, расположенный обычно в каталоге /boot под именем vmlinuz-<версия>. Запуск осуществляется в три шага.

  1. GRUB загружает в оперативную память два файла: сжатый образ ядра и образ initramfs.
  2. Ядро распаковывается и запускается с параметрами командной строки, переданными загрузчиком (просмотреть их можно в /proc/cmdline).
  3. Ядро разворачивает в памяти временную корневую файловую систему, называемую initramfs.

Initramfs (Initial RAM File System)

Внутри содержатся драйверы, модули ядра, микрокод процессора и скрипты ранней загрузки, собранные в один архив, то есть всё, без чего до настоящего диска не добраться. Задача initramfs — смонтировать настоящий корень (/) и передать управление процессу init.

Промежуточная ступень необходима из-за замкнутого круга. Ядро должно смонтировать корень, но драйвер нужного контроллера или файловой системы может быть собран отдельным модулем, а модули, вынесенные из ядра, хранятся на этом корне. Замкнутый круг разрывает initramfs, содержащий всё необходимое, чтобы добраться до настоящего диска, даже если тот зашифрован или собран в RAID.

4. Init и systemd

Процесс с PID 1 (init или systemd) является прародителем всего, что работает в системе; ему же достаются осиротевшие процессы. Он запускает службы в правильном порядке, монтирует файловые системы по описанию, заданному в /etc/fstab, и следит за тем, что уже запущено.

systemd — самая распространённая сегодня реализация init. Всё, чем он управляет, описано однотипными объектами, называемыми юнитами (units). Юнитом может быть служба, точка монтирования, сокет или таймер, запускающий задачу по расписанию. Зависимости между ними показывает systemctl list-dependencies default.target, а повседневная работа сводится к двум утилитам: systemctl управляет юнитами, journalctl показывает их журналы.

Задание. Пройти загрузку вручную, от разметки диска до записи в efibootmgr: «Установка Arch Linux вручную с RAID1 и UEFI».

Файловые системы

Диск выполняет всего две операции — чтение блока байтов по номеру и запись блока байтов по номеру. Ни файлов, ни каталогов, ни прав доступа, ни имён на нём нет; всё это является надстройкой, возводимой сверху файловой системой, которая знает, где что расположено.

Для физика, у которого данные эксперимента измеряются терабайтами, от выбора файловой системы и схемы дисков зависит, переживёт ли архив, собранный за годы, отказ оборудования.

Слайды к главе. Материал главы изложен также во второй части лекции «Как организован Linux» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

1. Немного о дисках

Классический жёсткий диск состоит из пластин, разделённых на дорожки и секторы (обычно по 512 байт или 4 КБ). У SSD пластин нет, но наружу он предоставляет всё тот же интерфейс из пронумерованных секторов одинакового размера. Файловая система этой разницы почти не замечает. Располагается она внутри раздела, описанного в таблице MBR или GPT.

2. Задачи файловой системы

ФС решает несколько задач:

  • Индексация и поиск данных (через структуры наподобие inode).
  • Управление свободным пространством, то есть учёт блоков, остающихся незанятыми.
  • Контроль доступа (права, ACL).
  • Оптимизация производительности (кеширование, дефрагментация*).
  • Надёжность (журналирование).

*Дефрагментация менее критична для современных ФС (ext4, XFS, btrfs) и SSD.

3. Виды файловых систем

Все файловые системы Linux скрывает за единым интерфейсом, называемым VFS (Virtual File System). Благодаря ему open и read работают одинаково и на локальном ext4, и на сетевом NFS, и на псевдофайлах из /proc, а программе, вызывающей их, не требуется знать, с чем она имеет дело.

FAT (File Allocation Table)

FAT происходит из эпохи дискет; сегодня она применяется для флеш-накопителей, которые должны открываться на любом компьютере, и для EFI-раздела, с которого загружается установленная система.

  • Плюсы: простая, читаемая всеми операционными системами.
  • Минусы: журналирования нет, а размеры, заложенные в формат, ограничены: в FAT32 файл не больше 4 ГБ, а раздел штатными средствами Windows не отформатировать больше 32 ГБ.
  • Происхождение чисел в названии: 12, 16 и 32 обозначают разрядность номера кластера в таблице размещения. Предельный размер тома получается умножением числа кластеров на размер кластера, но самих кластеров несколько меньше, чем даёт разрядность: несколько старших номеров занято служебными метками, зарезервированными под «конец файла» и «сбойный кластер». У FAT12 их 4084, а не 4096. У FAT32 под номер отведено 28 бит из 32, а старшие четыре не используются.

ext4 (Fourth Extended Filesystem)

Если файловая система не выбиралась сознательно, почти наверняка используется ext4: она устанавливается по умолчанию в большинстве дистрибутивов и подходит для расчётного узла общего назначения.

  • Стандарт для Ubuntu, Debian и многих других дистрибутивов.
  • Журналируемая, то есть повышающая надёжность записью метаданных операций в специальный журнал перед их выполнением.
  • Использует экстенты (extents), описывающие непрерывный диапазон блоков, для более эффективного хранения больших файлов (вместо прямых и косвенных блоков).
  • Иноды (inodes) хранят метаданные о файле (права, владелец, указатели, ведущие к данным). Их количество ограничено числом, заданным при создании ФС.

XFS

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

  • Высокопроизводительная ФС, подходящая для работы с большими файлами.
  • Использует B+-tree для индексации.
  • Отложенная аллокация, улучшающая производительность.
  • Иноды, создаваемые динамически, по мере необходимости.
  • Стандарт для Red Hat Enterprise Linux.

btrfs (B-Tree File System)

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

  • Современная ФС с продвинутыми функциями.
  • Copy-on-Write (CoW): при изменении файла данные записываются в новое место, а не перезаписываются старые. Это и обеспечивает дешёвые снапшоты (snapshots).
  • Встроенная поддержка RAID и LVM-подобных функций.
  • Субаллокация, обеспечивающая эффективную работу с маленькими файлами.
  • Поддержка TRIM, сообщающего SSD об освободившихся блоках.
  • Стандарт для openSUSE.

4. Конфигурирование файловой системы

Сильнее всего на скорость дисковых операций влияют кеширование и порядок, в котором накопленные запросы передаются на устройство.

  • Кеширование: прочитанные с диска страницы ядро хранит в page cache, откуда повторное чтение того же файла выполняется уже из памяти. Изменённые, но ещё не записанные на диск страницы называют «грязными» (dirty). Отдельного кеша для них нет: это состояние страниц внутри того же page cache. Сбрасывает их фоновый механизм, работающий по таймеру, а принудительно — вызов sync.

  • Планировщики ввода-вывода: определяют, в каком порядке запросы передаются на диск. Текущий и доступные приведены в /sys/block/<device>/queue/scheduler. Начиная с ядра 5.0 остались только многоочередные планировщики:

    • none работает без переупорядочивания, как обычный FIFO. Обычно является лучшим выбором для NVMe-накопителей, которым перестановка запросов ничего не даёт.
    • mq-deadline следит за сроками выполнения запросов, не позволяя им зависать. Разумное значение по умолчанию для SATA-дисков и SSD.
    • bfq «честно» распределяет полосу между процессами; полезен на настольной системе, где важна отзывчивость.
    • kyber является простым планировщиком для быстрых устройств, ограничивающим задержки.

    Старые одноочередные планировщики cfq, deadline и noop из ядра удалены, но их названия ещё встречаются в статьях.

Утилиты для работы с ФС:

  • mkfs, mke2fs создают ФС.
  • tune2fs изменяет параметры ФС ext*.
  • debugfs отлаживает ФС ext*.
  • fsck проверяет ФС и восстанавливает структуры, повреждённые сбоем.

5. Монтирование

В Linux нет букв дисков: любая файловая система подключается в общее дерево каталогов, растущее из корня, в выбранную точку монтирования наподобие /mnt, /home или /data. Пока раздел не смонтирован, данные на нём недоступны.

  • Команды: mount, umount.
  • /etc/fstab хранит описания файловых систем, монтируемых при загрузке автоматически.
    # Пример строки в /etc/fstab
    UUID=ed465c6e-949a-41c6-8e8b-c8da348a3577 / ext4 defaults 0 1
    
  • Параметры монтирования: rw/ro (чтение и запись или только чтение), noatime (не обновлять время последнего доступа, что на архиве, состоящем из миллионов файлов, заметно экономит записи), acl (поддержка списков контроля доступа) и другие.
  • systemd.mount является альтернативой fstab, построенной на юнитах systemd.

6. RAID (Redundant Array of Independent Disks)

RAID объединяет несколько физических дисков в один логический массив, видимый системе как обычный диск. Цель — либо пережить отказ диска без потери данных, либо сложить скорости нескольких дисков, работающих одновременно, а в некоторых уровнях и то и другое.

УровеньПринципНадёжностьПроизводительностьЁмкость
RAID 0Striping (чередование)НизкаяВысокая (чт/зап)N
RAID 1Mirroring (зеркалирование)ВысокаяВысокая (чт)1 диск
RAID 5ЧётностьСредняяВысокая (чт)N-1
RAID 6Двойная чётностьВысокаяВысокая (чт)N-2
RAID 10Зеркалирование + чередованиеВысокаяВысокая (чт/зап)N/2

В колонке «Ёмкость» \(N\) обозначает число дисков в массиве (все одинакового размера). У RAID 1, сколько бы дисков ни было собрано в зеркало, полезная ёмкость равна ёмкости одного диска: остальные хранят копии записанного. Поэтому зеркало почти всегда собирают из двух дисков.

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

Создание программного RAID в Linux: Аппаратный контроллер не требуется: массив собирает ядро, а управляет им утилита mdadm.

# Пример создания RAID 1
mdadm --create /dev/md0 --level=1 --raid-devices=2 /dev/sdb /dev/sdc
mkfs.ext4 /dev/md0
mount /dev/md0 /mnt/raid
# Сохранение конфигурации
mdadm --detail --scan >> /etc/mdadm.conf   # в Debian/Ubuntu: /etc/mdadm/mdadm.conf

7. LVM (Logical Volume Manager)

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

Понятий три, и они вкладываются друг в друга. Физическим томом (physical volume, PV) называют диск или раздел, отданный под управление LVM. Несколько физических томов объединяются в группу томов (volume group, VG), общий пул пространства всех дисков. Из группы, в свою очередь, выделяются логические тома (logical volume, LV), на которых и создаются файловые системы.

Порядок команд при создании:

  • pvcreate /dev/sdX1 /dev/sdY1 передаёт разделы под управление LVM;
  • vgcreate my_vg /dev/sdX1 /dev/sdY1 собирает из этих физических томов группу;
  • lvcreate -n my_vol -L 10G my_vg выделяет из группы логический том на 10 ГБ;
  • mkfs.ext4 /dev/my_vg/my_vol создаёт на нём файловую систему.

Заключение

Файловая система выбирается под ожидаемую нагрузку. ext4 остаётся разумным значением по умолчанию, XFS применяют для больших файлов, Btrfs — там, где нужны снимки, откатывающие систему к сохранённому состоянию. Схема дисков продумывается до того, как на них будут записаны данные; переразмечать массив, заполненный терабайтами, значительно дороже, чем заранее потратить полчаса на планирование. Ни RAID, ни снимки не заменяют резервную копию, хранимую в другом месте.

Задание. Собрать зеркало из двух дисков и проверить, что система переживёт отказ любого из них: «Установка Arch Linux вручную с RAID1 и UEFI».

Установка Arch Linux

Сборку системы с нуля удобнее всего рассматривать на примере Arch Linux: мастер установки в нём отсутствует, и все действия, скрытые другими дистрибутивами за полосой прогресса, выполняются вручную.

Установка осуществляется на виртуальную машину в VirtualBox, где ошибки ничего не стоят, а результат воспроизводим. Сначала система собирается в режиме Legacy BIOS, затем рассматривается вариант с зеркальным RAID-массивом, собранным из двух дисков, а в конце машина переключается на UEFI и загрузчик перенастраивается.

Создание виртуальной машины

  1. В VirtualBox создаётся новая виртуальная машина.
  2. Вводится имя виртуальной машины, выбирается тип операционной системы Linux и версия Arch Linux (64-bit).
  3. Выделяется необходимое количество оперативной памяти (рекомендуется не менее 2 ГБ).
  4. Создаётся новый виртуальный жёсткий диск в формате VDI.
  5. Выделяется место на диске (рекомендуется не менее 20 ГБ).
  6. В настройках виртуальной машины выбирается Legacy BIOS (или SeaBIOS).
  7. Подключается ISO образ Arch Linux.

Установка Arch Linux с использованием Legacy BIOS

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

  1. Запустим виртуальную машину и загрузимся с ISO образа Arch Linux.

  2. С помощью cfdisk выполним разметку диска (/dev/sda). Выберем таблицу разделов GPT и создадим три раздела:

    • /dev/sda1: 1M, тип BIOS boot (код EF02)
    • /dev/sda2: 1G, тип EFI System (код EF00), то есть ESP
    • /dev/sda3: остальное пространство для корневой файловой системы

    Первый раздел выглядит лишним, однако является обязательным. На диске с MBR GRUB размещает свою вторую ступень в промежутке, оставленном после загрузочной записи, а на GPT такого промежутка нет, и grub-install --target=i386-pc откажется работать с сообщением «embedding is not possible». В раздел BIOS boot GRUB и записывает вторую ступень; файловая система на нём не создаётся, он остаётся неразмеченным.

    Второй раздел на этом этапе не потребуется: при загрузке через BIOS прошивка не имеет представления об ESP. Он заведён на будущее: в конце главы эта машина будет переключена на UEFI, и переразмечать диск не придётся.

  3. Создадим файловую систему на ESP разделе.

    mkfs.fat -F32 /dev/sda2
    
  4. Создадим файловую систему на корневом разделе.

    mkfs.ext4 /dev/sda3
    
  5. Смонтируем корневую файловую систему.

    mount /dev/sda3 /mnt
    
  6. Создадим точку монтирования и смонтируем ESP.

    mkdir /mnt/boot
    mount /dev/sda2 /mnt/boot
    
  7. Установим базовую систему.

    pacstrap /mnt base linux linux-firmware
    
  8. Сгенерируем файл fstab.

    genfstab -U /mnt >> /mnt/etc/fstab
    
  9. Перейдём в новую систему. Команда arch-chroot подменяет корень файловой системы на /mnt, после чего все команды выполняются так, как если бы загрузка уже была произведена в устанавливаемую систему. Механика подмены корня рассматривается в главе про внутреннее устройство Linux.

    arch-chroot /mnt
    
  10. Установим загрузчик GRUB.

    pacman -S grub
    grub-install --target=i386-pc /dev/sda
    grub-mkconfig -o /boot/grub/grub.cfg
    
  11. Установим пароль root.

    passwd
    
  12. Выйдем из chroot-окружения и размонтируем файловые системы.

    exit
    umount -R /mnt
    reboot
    

Установка Arch Linux на RAID1

Последовательность та же, но на зеркале из двух дисков. Отличий два: массив необходимо собрать до создания файловой системы, а initramfs — научить этот массив находить, иначе при первой же загрузке система не найдёт корень.

  1. Добавим виртуальной машине второй диск такого же размера и загрузимся с установочного образа Arch Linux.

  2. Подготовим диски.

    cfdisk /dev/sda
    cfdisk /dev/sdb
    

    На каждом диске выберем таблицу GPT и создадим два раздела: /dev/sda1 размером 1M с типом BIOS boot (EF02) и /dev/sda2 на всё остальное с типом Linux RAID (FD00); то же самое на /dev/sdb. Раздел BIOS boot необходим по той же причине, что и в предыдущем разделе, причём на обоих дисках, иначе после отказа первого диска машина со второго не загрузится.

  3. Создадим RAID1 массив.

    mdadm --create --verbose /dev/md0 --level=1 --raid-devices=2 /dev/sda2 /dev/sdb2
    
  4. Отформатируем RAID-массив.

    mkfs.ext4 /dev/md0
    
  5. Смонтируем файловую систему.

    mount /dev/md0 /mnt
    
  6. Установим базовую систему.

    pacstrap /mnt base linux linux-firmware mdadm
    
  7. Сгенерируем fstab.

    genfstab -U /mnt >> /mnt/etc/fstab
    
  8. Выполним chroot в новую систему.

    arch-chroot /mnt
    
  9. Настроим mdadm.

    mdadm --detail --scan >> /etc/mdadm.conf
    
  10. Настроим mkinitcpio:

    В файле /etc/mkinitcpio.conf необходимо добавить mdadm_udev в список HOOKS, обязательно раньше filesystems, иначе к моменту монтирования корня массив ещё не будет собран.

    HOOKS=(base udev autodetect modconf block mdadm_udev filesystems keyboard fsck)
    
  11. Пересоберём начальный RAM-диск.

    mkinitcpio -P
    
  12. Установим GRUB.

    pacman -S grub
    
  13. Установим GRUB на оба диска.

    grub-install --target=i386-pc /dev/sda
    grub-install --target=i386-pc /dev/sdb
    
  14. Создадим конфигурацию GRUB.

    grub-mkconfig -o /boot/grub/grub.cfg
    
  15. Перезагрузим систему.

    exit
    umount -R /mnt
    reboot
    

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

Переключение на UEFI и настройка загрузчика

Вернёмся к машине из первой установки, размеченной на sda1, sda2 и sda3: EFI-раздел там уже заведён. Зеркало из предыдущего раздела для этого шага не подходит, поскольку ESP в нём отсутствует; связка RAID1 с UEFI собирается самостоятельно в задании.

Переустанавливать систему не потребуется: UEFI отличается от BIOS только тем, где расположен загрузчик и как прошивка его находит, поэтому достаточно разместить .efi-файл на EFI-разделе и зарегистрировать запись в NVRAM.

  1. Остановим виртуальную машину.

  2. В настройках виртуальной машины изменим BIOS на UEFI.

  3. Запустим виртуальную машину и загрузимся с ISO образа Arch Linux.

  4. Смонтируем корневую файловую систему и ESP.

    mount /dev/sda3 /mnt
    mount /dev/sda2 /mnt/boot
    arch-chroot /mnt
    
  5. Установим пакеты, необходимые для загрузки с UEFI.

    pacman -S grub efibootmgr dosfstools os-prober mtools
    
  6. Установим загрузчик GRUB с поддержкой UEFI.

    grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB
    grub-mkconfig -o /boot/grub/grub.cfg
    efibootmgr -v
    

В выводе efibootmgr -v должна появиться запись GRUB. Если она отсутствует, необходимо убедиться, что раздел с .efi-файлом смонтирован в /boot и отформатирован в FAT32.

Задание. Установка повторяется самостоятельно, но с зеркалированием диска и загрузкой через UEFI: «Установка Arch Linux вручную с RAID1 и UEFI».

Внутреннее устройство Linux

Без понимания того, что происходит внутри системы, первый же зависший расчёт или необъяснимо медленный ввод-вывод превращается в гадание.

Слайды к главе. Материал главы изложен также в лекции «Внутренности Linux» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

Значение внутреннего устройства

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

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


Практическая ценность глубоких знаний

Быстрая диагностика проблем

Когда программа ведёт себя странно, вопрос состоит не в том, «что делать», а в том, «что спросить у системы»: чем процесс сейчас занят, куда уходит его время и в каком состоянии он застрял.

# Вместо случайного тыкания
strace -p <pid>                    # что делает процесс?
perf record -g <command>          # где тратится время?
cat /proc/<pid>/status            # в каком состоянии?

Служба периодически «зависала» на несколько минут и восстанавливалась сама; в top при этом не наблюдалось ни загрузки процессора, ни повышенного расхода памяти. Разгадка заключалась в состоянии процесса: ps показывал D, непрерываемое ожидание ввода-вывода. Служба писала на сетевой диск NFS, тот время от времени переставал отвечать, и процесс, пробуждаемый лишь ответом, ждал. Проблема устранялась не в коде, а таймаутами монтирования и числом попыток, разрешённых на повтор.

Эффективное программирование

Пока нет представления о цене обращения к ядру, оптимизировать нечего. Порядки величин следующие:

  • системный вызов стоит около тысячи тактов процессора;
  • переключение контекста, происходящее между процессами, стоит от тысячи до десяти тысяч тактов;
  • промах по странице памяти занимает доли микросекунды, если страница уже загружена в память, и от сотен микросекунд на NVMe до единиц миллисекунд на диске, если её приходится читать.

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

Облачные технологии

Контейнеры, оркестраторы, бессерверные вычисления представляют собой надстройки над механизмами ядра, которым посвящена глава. Docker использует те же cgroups и пространства имён, только удобно упакованные. Kubernetes управляет ими же, но в масштабе кластера из сотен машин, работающего годами. AWS Lambda — та же изоляция, доведённая до очень быстрого запуска.


1. Процессы

Детальное понимание процессов

Процесс как учётная единица

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

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

Ядро знает о процессе следующее: идентификаторы самого процесса и его родителя (PID и PPID), от какого пользователя и группы он запущен (UID и GID), с каким приоритетом его планировать, в каком состоянии он сейчас находится и сколько ресурсов уже израсходовано. Всё это расположено в файловой системе /proc, открытой для чтения.

Структура процесса в ядре

// Упрощенная task_struct (include/linux/sched.h)
struct task_struct {
    volatile long state;                    // состояние процесса
    void *stack;                           // указатель на стек
    struct mm_struct *mm;                  // память процесса
    struct files_struct *files;            // открытые файлы
    struct signal_struct *signal;          // сигналы
    // ... сотни полей
};

Всё перечисленное ядро предоставляет наружу через файловую систему /proc, позволяющую исследовать любой работающий процесс:

# Анализ конкретного процесса
ls -la /proc/1234/
cat /proc/1234/maps    # память процесса
cat /proc/1234/status  # состояние и лимиты
ls /proc/1234/fd/      # открытые файлы

Создание процессов: fork() и exec()

Механизм Copy-on-Write (CoW)

Системный вызов fork(), создающий копию текущего процесса, в наивной реализации копировал всю память родителя. Для расчёта, занявшего десять гигабайт, это означало те же десять гигабайт, скопированных впустую, притом что сразу после fork() дочерний процесс обычно вызывает exec() и вся скопированная память немедленно уничтожается.

Отсюда copy-on-write, копирование при записи. При fork() память не копируется. Страницы помечаются как доступные только для чтения, и оба процесса читают их из одного и того же физического места. Настоящая копия создаётся лениво, в тот момент, когда один из двух процессов пытается в страницу записать, и только для затронутой страницы. В типичном случае «породить процесс и сразу запустить в нём другую программу» копировать почти ничего не приходится.

pid_t pid = fork();
if (pid == 0) {
    // Дочерний процесс
    // Страницы памяти разделяются до первой записи
    execve("/bin/ls", args, env);
} else {
    // Родительский процесс
    waitpid(pid, &status, 0);
}

Потоки и процессы

Архитектурные различия

АспектПроцессПоток
ПамятьИзолированнаяРазделяемая
ФайлыОтдельные таблицыОбщая таблица
Стоимость созданияВысокаяНизкая
ИзоляцияПолнаяМинимальная

Практические сценарии использования

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

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

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

Межпроцессное взаимодействие (IPC)

Сигналы как асинхронные уведомления

// Отправка сигнала
kill(pid, SIGTERM);

// Обработка сигнала
void handler(int sig) {
    // Асинхронно! Осторожно с shared state
}
signal(SIGTERM, handler);

Обработчик прерывает выполнение программы в произвольной точке, поэтому вызывать из него разрешено далеко не всё. Если сигнал придёт в середине malloc, повторный malloc из обработчика застанет кучу в противоречивом состоянии и окончательно её повредит. Безопасным является только список функций, оговорённый стандартом. Их называют async-signal-safe, и printf в него не входит. Обычная практика состоит в том, чтобы выставить в обработчике флаг и сразу вернуться, а содержательную работу выполнить в основном цикле программы.

SIGHUP получают процессы, привязанные к терминалу, когда сессия закрывается, и по умолчанию он их завершает. Поэтому долгий расчёт, запущенный по ssh, завершается вместе с разорванным соединением. Пережить закрытие сессии процесс может, если его отвязать от терминала: для этого служат nohup, setsid и встроенная в оболочку команда disown, а также screen и tmux из главы про инструменты Linux.

Каналы как односторонняя связь

# Неименованные каналы
ls -la | grep ".txt" | wc -l

# Именованные каналы (FIFO)
mkfifo mypipe
echo "data" > mypipe &
cat mypipe

Канал представляет собой буфер, выделенный в памяти ядра, по умолчанию около 64 КБ. Пока в буфере остаётся место, пишущая сторона не задерживается; когда буфер заполнен, write блокируется, пока читающая сторона не заберёт накопленное. Так работает конвейер команда | команда. Быстрый производитель, ограниченный медленным потребителем, сам замедляется до его скорости, и никакой явной синхронизации писать не приходится.

Разделяемая память и максимальная производительность

// Создание shared memory
int shm_id = shmget(key, size, IPC_CREAT | 0666);
void *ptr = shmat(shm_id, NULL, 0);

// Использование
memcpy(ptr, data, data_size);

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

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

Семафоры и координация доступа

// Бинарный семафор (мьютекс)
sem_wait(&mutex);
// Критическая секция
sem_post(&mutex);

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

Состояния процессов: полный цикл жизни

Детали каждого состояния

Основные буквы состояния в выводе ps:

R (running / runnable) означает, что процесс либо выполняется в настоящий момент, либо стоит в очереди планировщика и готов выполняться. Ограничивает его только доступность процессорного времени. Если расчёт долго находится в R, он считает.

S (interruptible sleep) означает, что процесс чего-то ожидает: завершения ввода-вывода, семафора, сигнала. Такое ожидание прерывается сигналом, то есть процесс, находящийся в нём, нормально завершается по kill. Для программ, больше ожидающих, чем считающих, S является обычным рабочим состоянием.

D (uninterruptible sleep) описывает то же ожидание, но аппаратного ввода-вывода, диска или сети. Оно не прерывается сигналом, включая kill -9. С ним сталкиваются, когда процесс «завис намертво» и не завершается ничем; чаще всего причиной является отказавший сетевой диск, смонтированный по NFS. Разбираться в таком случае приходится с устройством.

T (stopped) показывает, что процесс приостановлен сигналом SIGSTOP или SIGTSTP и продолжится по SIGCONT. Так работает нажатие Ctrl+Z в оболочке, и так же останавливают процесс отладчики, устанавливающие точку останова.

Z (zombie) означает, что процесс уже завершился, но родитель не забрал его код возврата. Ресурсы освобождены, осталась одна запись в таблице процессов ради кода возврата. Завершить зомби нельзя, он и так завершён; запись исчезнет, когда родитель вызовет wait, а если родитель этого не делает, то после его завершения. Осиротевших зомби забирает процесс init.

Практический мониторинг

Сводку по состояниям всех процессов система выдаёт одной строкой; с неё удобно начинать разбор: она сразу показывает, есть ли в системе процессы, застрявшие в D, или накопившиеся зомби.

# Понимание состояния процессов
ps aux | awk '{print $8}' | sort | uniq -c

# Поиск проблемных процессов
# Процессы в D состоянии
ps aux | awk '$8=="D" {print $0}'

# Zombie процессы
ps aux | awk '$8=="Z" {print $0}'

2. Планировщик

Эволюция планировщиков Linux

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

O(N) планировщик (по 2.4 включительно)

// Псевдокод старого планировщика
for (each task in system) {
    calculate_goodness(task);
    if (goodness > max_goodness) {
        next_task = task;
        max_goodness = goodness;
    }
}

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

O(1) планировщик (2.6.0–2.6.22)

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

CFS (Completely Fair Scheduler) (2.6.23+)

// Основан на красно-чёрных деревьях
struct rb_root_cached {
    struct rb_root rb_root;
    struct rb_node *rb_leftmost;
};

Действующий сегодня планировщик (точнее, его прямой потомок) устроен иначе. Он не раздаёт кванты по приоритетам, а ведёт для каждого процесса счётчик, накапливающий уже полученное процессорное время, и всегда запускает тот процесс, которому досталось меньше всех. Все готовые процессы хранятся в красно-чёрном дереве, отсортированные по этому счётчику, и выбор следующего сводится к взятию самого левого узла.

Приоритеты и политики планирования

Политики реального времени

Для задач, где важна не общая пропускная способность, а гарантированное время реакции (управление установкой, сбор данных с жёстким тактом), в ядре предусмотрены две политики реального времени.

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

SCHED_RR отличается тем, что квант всё же существует, по умолчанию 100 мс. Процессы одинакового приоритета, стоящие в очереди, сменяют друг друга по кругу, поэтому один зациклившийся процесс не блокирует остальные. Эта политика безопаснее, и начинать лучше с неё.

Обычные политики

Всё остальное, а это практически все процессы, включая пользовательский расчёт, работает под политикой SCHED_NORMAL (она же SCHED_OTHER). Жёстких гарантий она не даёт, но и никого не лишает процессорного времени полностью. Приоритет плавающий, планировщик корректирует его сам. Процессу, который много спит в ожидании ввода, например реагирует на нажатия клавиш, достаётся небольшая прибавка, удерживающая систему отзывчивой. Процессу, который непрерывно считает, долю слегка урезают: своё он доберёт, пока остальные ждут.

Значения nice и приоритеты

# Установка nice значения
nice -n 10 ./long_running_task    # низкий приоритет
nice -n -20 ./critical_task       # высокий приоритет

# Изменение running процесса
renice -n 5 -p 1234

Шкала nice идёт от -20 до +19. Чем больше значение, тем ниже приоритет: процесс «вежливее» и охотнее уступает. Понижать себе приоритет может любой пользователь, а повышать, то есть уходить в отрицательные значения, разрешено только root.

CFS: внутреннее устройство

Ключевые концепции

Поведение планировщика определяется несколькими величинами.

Виртуальное время выполнения (vruntime) — счётчик, растущий у каждого процесса, пока тот занимает процессор. Скорость роста зависит от приоритета: у высокоприоритетного медленнее, у низкоприоритетного быстрее. Планировщик всегда выбирает процесс с наименьшим vruntime: «справедливым» считается тот, кому досталось меньше всех. Значение nice влияет на скорость накопления этого счётчика.

Целевой задержкой (target latency) называют срок, за который каждый готовый к выполнению процесс должен успеть получить процессор хотя бы раз. По умолчанию это 6 мс, умноженные на поправку по числу процессоров: единица плюс двоичный логарифм их числа, причём учитываются не более восьми. Получается от 6 мс на одном ядре до 24 мс на восьми и более. За отзывчивость интерфейса платят более частыми переключениями.

Минимальный квант (minimal granularity) задаёт нижнюю границу доли процесса. Готовых процессов бывает очень много, и честное деление целевой задержки на всех дало бы каждому доли микросекунды: система занималась бы одними переключениями. Поэтому квант не делают короче 0,75 мс с той же поправкой по числу процессоров, слегка нарушая справедливость.

Реализация на красно-чёрных деревьях

// Вставка процесса в дерево
struct sched_entity {
    struct rb_node run_node;
    u64 vruntime;
    // ...
};

// Быстрый поиск процесса с минимальным vruntime
struct task_struct *pick_next_task(struct rq *rq) {
    struct rb_node *left = rb_first_cached(&rq->tasks_timeline);
    return rb_entry(left, struct task_struct, se.run_node);
}

Вставка и удаление обходятся в O(log N), а процесс с наименьшим vruntime всегда находится в самом левом узле, причём указатель на него ядро хранит отдельно и обновляет по ходу работы, поэтому самая частая операция планировщика, выбор следующего процесса, выполняется за постоянное время.

Управление планировщиком на практике

Привязка к ядрам

# Привязка процесса к конкретным ядрам
taskset -c 0,1 ./application

# Просмотр текущей маски
taskset -p 1234

# Запуск с распределением по ядрам
numactl --cpunodebind=0,1 --membind=0,1 ./app

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

Настройка планировщика

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

На ядрах до 5.13 параметры располагались в /proc/sys/kernel/:

cat /proc/sys/kernel/sched_min_granularity_ns
cat /proc/sys/kernel/sched_latency_ns
echo 10000000 > /proc/sys/kernel/sched_latency_ns

В 5.13 они были вынесены из sysctl в отладочную файловую систему с сохранением прежних имён. Такой вариант ещё встречается на завершающих поддержку LTS наподобие Ubuntu 22.04 с исходным ядром 5.15:

cat /sys/kernel/debug/sched/min_granularity_ns
cat /sys/kernel/debug/sched/latency_ns

С 6.6 CFS заменён на EEVDF, и параметры, названные под старый планировщик, исчезли вместе с ним. С этим случаем столкнётся большинство читателей: в Ubuntu 24.04 установлено ядро 6.8, в Debian 13 — 6.12, оба с EEVDF, и старых имён в отладочной файловой системе там нет. Ближайший по смыслу параметр называется base_slice_ns:

cat /sys/kernel/debug/sched/base_slice_ns

Какой из трёх случаев имеет место, показывает uname -r.

Мониторинг планировщика

При подозрении, что процесс не получает процессорного времени, лучше обращаться не к top, а к статистике планировщика. Там видно, сколько раз процесс вытесняли и сколько он в сумме простоял в очереди.

# Статистика переключений
cat /proc/1234/sched

# Очереди выполнения
cat /proc/sched_debug

# Профилирование
perf sched record ./application
perf sched latency

3. Прерывания

Архитектура прерываний в x86/x64

Аппаратные прерывания

Прерыванием устройство сообщает процессору о готовности и требует обработки. Источников в обычной машине немного: системные таймеры, сетевые карты, дисковые контроллеры и устройства, подключённые к шине USB. В лаборатории к ним добавляется всё, что установлено в крейте: АЦП, счётчики, генераторы задержек.

Драйвер регистрирует обработчик на номер прерывания, и ядро вызывает его каждый раз, когда устройство подаёт сигнал:

// Регистрация обработчика
request_irq(IRQ_NUMBER, handler, flags, name, dev);

// Обработчик прерывания
static irqreturn_t my_handler(int irq, void *dev_id) {
    // Быстрая обработка
    return IRQ_HANDLED;
}

Исключения процессора

Процессор прерывает и сам себя, встретив ситуацию, с которой не справляется в одиночку. Такие события делятся на три вида по тому, чем они заканчиваются. Отказ (fault) исправим: типичный пример — обращение к странице, отсутствующей в физической памяти; ядро подгружает её и повторяет ту же инструкцию. Ловушка (trap) вызывается намеренно, так работают точки останова в отладчике, и выполнение продолжается со следующей инструкции. Авария (abort) не исправляется: состояние потеряно безвозвратно.

Обработка прерываний: верхняя и нижняя половины

Верхняя половина

Пока выполняется обработчик прерывания, на этом ядре запрещены все прерывания (так с версии 2.6.35, прежде — только того же типа), а планировщик, распределяющий процессорное время, не работает вовсе. Обработчик обязан уложиться в микросекунды: подтвердить устройству получение, забрать данные из регистров, поставить остальное в очередь, разбираемую позже, и вернуться. Ничего блокирующего внутри быть не может: засыпает процесс, а обработчик выполняется вне какого-либо процесса.

// Типичный upper half
irqreturn_t eth_interrupt(int irq, void *dev_id) {
    struct net_device *dev = dev_id;
    disable_irq_nosync(dev->irq);
    schedule_work(&dev->tx_work);
    return IRQ_HANDLED;
}

Нижняя половина

Содержательную работу обработчик откладывает в нижнюю половину. Способов отложить три, и различаются они тем, насколько поздно и в каком контексте выполнится отложенное.

SoftIRQ — самый быстрый механизм, но встроенный в ядро статически. Набор мягких прерываний фиксирован (сеть, блочные устройства), и добавить собственное невозможно. Tasklet создаётся динамически и потому подходит для драйверов, написанных под собственное оборудование, но один и тот же tasklet никогда не выполняется на двух ядрах одновременно, что упрощает разработку и ограничивает масштабирование. Очередь работ (work queue) выполняется в контексте обычного процесса ядра, поэтому ей разрешено засыпать и выполнять блокирующие вызовы. За это платят задержкой, и очереди применяют для длительной работы.

// Work queue пример
DECLARE_WORK(my_work, my_work_function);

void my_work_function(struct work_struct *work) {
    // Медленная обработка
    process_packets();
    enable_irq(dev->irq);
}

Практическая работа с прерываниями

Мониторинг прерываний

# Статистика прерываний
cat /proc/interrupts

# Распределение прерываний по CPU
cat /proc/irq/*/smp_affinity

# Изменение привязки прерываний
echo 2 > /proc/irq/24/smp_affinity

Оптимизация обработки

Когда прерываний становится слишком много (на быстрой сетевой карте или скоростном АЦП их десятки тысяч в секунду), обработку настраивают.

Прерывания распределяют между ядрами процессора. По умолчанию все они могут поступать на нулевое ядро, которое перегружается, пока остальные простаивают. Вместо устаревшего механизма прерываний по выделенной линии используют MSI, где устройство сообщает о событии записью в память; это снимает ограничение на число линий и упрощает распределение. Для сетевых карт подбирают длину очередей: слишком короткая теряет пакеты на всплесках, слишком длинная увеличивает задержку. Включают объединение прерываний (coalescing): устройству разрешают накопить несколько событий и сообщить о накопленном разом, при этом число прерываний падает в разы ценой небольшой задержки.


4. Системные вызовы

Механизм системных вызовов

Переключение между пространствами

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

Пользовательское пространство → Пространство ядра:

; x86-64 системный вызов
mov rax, 1      ; номер syscall (write)
mov rdi, 1      ; fd (stdout)
mov rsi, buffer ; буфер
mov rdx, count  ; размер
syscall         ; переход в ядро

За инструкцией syscall стоят шесть шагов. Процессор сохраняет контекст пользовательского кода, то есть регистры и указатель, хранящий адрес инструкции, и переходит в режим ядра. Ядро проверяет переданные параметры, поскольку доверять пользовательской программе нельзя; адрес буфера может указывать куда угодно. Затем выполняется собственно операция, результат записывается туда, где его ожидает программа, и сохранённый контекст восстанавливается.

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

Таблица системных вызовов

// Определение syscall (fs/read_write.c)
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
                size_t, count)
{
    struct fd f = fdget_pos(fd);
    // ... обработка
    return ret;
}

Дескриптор, полученный от программы, напрямую не используется, а сначала преобразуется в проверенный объект ядра. Так устроен любой системный вызов: ни одному числу из пользовательского пространства ядро не доверяет.

Безопасность системных вызовов

Проверки доступа

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

// Проверка указателей из userspace
if (copy_from_user(kernel_buf, user_buf, size))
    return -EFAULT;

// Проверка прав доступа
if (!file_permission(file, MAY_READ))
    return -EPERM;

Права по возможностям (capabilities)

Исторически права проверялись просто: root (UID 0) может всё, остальные почти ничего. Программе, которой требуется одна привилегия, например открыть сетевой сокет на порту ниже 1024, приходилось выдавать полномочия root целиком. Современное ядро дробит права root на несколько десятков отдельных возможностей (capabilities) и проверяет конкретную из них.

// Вместо проверки UID == 0
if (!capable(CAP_SYS_ADMIN))
    return -EPERM;

Производительность системных вызовов

Измерение стоимости

#include <sys/time.h>

struct timeval start, end;
gettimeofday(&start, NULL);
// системный вызов
gettimeofday(&end, NULL);

long microseconds = (end.tv_sec - start.tv_sec) * 1000000 
                  + (end.tv_usec - start.tv_usec);

Порядки величин необходимо помнить. Простой системный вызов наподобие getpid обходится в 0.1–1 микросекунду. Вызовы с вводом-выводом занимают от микросекунды до миллисекунды; разброс зависит от того, попал ли запрос в кеш или был направлен на диск. Переключение контекста между процессами стоит 1–10 микросекунд.

Обычное обращение в оперативную память занимает около сотни наносекунд. Отсюда следует правило: в тесном цикле системным вызовам не место; один write на каждое число превратит расчёт в сплошное ожидание.

Оптимизация

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

Вызовы объединяют, заменяя десять write подряд одним writev, принимающим сразу список буферов. Файл отображают в память через mmap и далее работают с ним как с обычным массивом, без read и write; для больших файлов данных это является лучшим вариантом. Лишние вызовы устраняют: типичный случай — проверка существования файла перед открытием, хотя открытие и так вернёт ошибку. Для нескольких самых частых вызовов наподобие gettimeofday в Linux предусмотрен vDSO — фрагмент кода ядра, отображённый в память процесса, благодаря чему переключаться в режим ядра не приходится.


5. Память процесса

Виртуальная память: полная картина

Карта адресного пространства

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

0x0000000000000000 ┌──────────────────────┐
                   │   Зарезервировано    │
                   │   (NULL-ptr guard)   │
0x0000000000400000 ├──────────────────────┤
                   │         Text         │
                   │   (код программы)    │
0x0000000000600000 ├──────────────────────┤
                   │         Data         │
                   │        (init)        │
0x0000000000601000 ├──────────────────────┤
                   │         BSS          │
                   │       (uninit)       │
0x0000000000800000 ├──────────────────────┤
                   │         Heap         │
                   │    (динамическая)    │
                   │          ↓           │
0x00007ffff0000000 ├──────────────────────┤
                   │     MMAP region      │
                   │     (библиотеки,     │
                   │     shared mem)      │
0x00007ffff7a00000 ├──────────────────────┤
                   │        Stack         │
                   │   (автоматические)   │
                   │          ↑           │
0x00007fffffffffff ├──────────────────────┤
                   │    неканоническая    │
                   │         дыра         │
0xffff800000000000 ├──────────────────────┤
                   │     Kernel space     │
                   │     (недоступно)     │
0xffffffffffffffff └──────────────────────┘

Управление памятью на практике

Анализ памяти процесса

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

# Детальная информация о памяти
pmap -XX 1234

# Статистика памяти
cat /proc/1234/smaps

# Page faults
ps -o min_flt,maj_flt,cmd -p 1234

Виды промахов по странице

Промахи по страницам бывают двух видов, и различаются они на три порядка по стоимости.

Малый промах (minor fault) происходит, когда нужная страница уже находится в физической памяти, но пока не прописана в таблицах страниц процесса, например разделяется с соседним процессом, читающим то же самое, или только что выделена. Ядру достаточно обновить таблицы, и обходится это дёшево.

Большой промах (major fault) означает, что страницы в памяти нет и её необходимо читать с диска. Это миллисекунды вместо микросекунд. Если ps или top показывают, что расчёт набирает большие промахи тысячами, значит, ему не хватает выделенной памяти и система перешла в подкачку. Оптимизировать код в этот момент бессмысленно, необходимо разбираться с памятью.

Проблемы с памятью и решения

Нехватка памяти и OOM killer

Когда память в системе заканчивается и освободить её больше нечем, ядро не возвращает ошибку, а выбирает жертву и завершает её. OOM killer работает в три шага. Ядро фиксирует нехватку памяти, начисляет каждому процессу «балл вредности» (главным образом по объёму занятой памяти) и завершает процесс с наибольшим баллом. Обычно это пользовательский расчёт, самый требовательный к памяти в системе.

Процесс исчезает без сообщения об ошибке, а в выводе dmesg остаётся строка Out of memory: Killed process. Балл корректируют вручную, защищая критичную службу или назначая заведомую жертву.

Управление OOM:

# Настройка политики OOM
echo -1000 > /proc/1234/oom_score_adj    # защитить процесс
echo 1000 > /proc/1234/oom_score_adj     # первый кандидат

# Ручной вызов OOM killer: НЕ на рабочей машине —
# ядро прямо сейчас убьёт живой процесс, выбрав его само.
# kernel.sysrq ограничивает только клавиатуру: root вызывает
# через /proc/sysrq-trigger любые функции SysRq
echo f > /proc/sysrq-trigger

Управление подкачкой

Подкачка является не запасной памятью, а способом выгрузить на диск страницы, давно не использовавшиеся. Расчётному узлу она скорее вредит: если рабочий набор перестал помещаться в оперативную память, подкачка замедлит всё в сотни раз, и машина будет выглядеть зависшей. Насколько охотно ядро выгружает страницы, задаёт параметр swappiness, установленный по умолчанию в 60; на сервере его снижают, на настольной машине оставляют без изменений.

# Мониторинг swap
swapon --show
free -h

# Настройка swappiness
echo 10 > /proc/sys/vm/swappiness    # меньше swap (сервер)
echo 60 > /proc/sys/vm/swappiness    # больше swap (десктоп)

Настройка под NUMA

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

# Информация о NUMA
numactl --hardware

# Запуск с учетом NUMA
numactl --membind=0 --cpunodebind=0 ./application

# Статистика NUMA
cat /proc/1234/numa_maps

6. Изоляция

На рассматриваемых ниже механизмах ядра основан Docker из следующей главы.

Эволюция изоляции в Linux

От chroot до контейнеров

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

  • 1979, chroot в UNIX: процессу подменяют корень файловой системы;
  • 2000, FreeBSD Jails: к файловой системе добавляется изоляция процессов и сети;
  • 2001, Linux-VServer и 2004, Solaris Zones: те же идеи в других системах;
  • 2008, LXC: в ядре Linux появляются пространства имён и cgroups, то есть всё необходимое;
  • 2013, Docker: удобная упаковка поверх готовых механизмов ядра;
  • 2014, Kubernetes: управление контейнерами в масштабе кластера.

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

Cgroups: управление ресурсами

Иерархия cgroups v2

Cgroups отвечают за вторую половину изоляции, не за то, что процесс видит, а за то, сколько он может потребить. Устроены они как дерево каталогов в /sys/fs/cgroup, где каждый каталог представляет группу процессов, а файлы внутри неё задают лимиты и хранят накопленные счётчики. Ограничение, установленное на узел дерева, действует на всё поддерево целиком.

/sys/fs/cgroup/
├── system.slice/          # системные службы
│   ├── ssh.service
│   └── docker.service
├── user.slice/            # пользовательские процессы
│   ├── user-1000.slice
│   └── user-1001.slice
├── kubepods.slice/        # Kubernetes pods
│   ├── pod1/
│   └── pod2/
└── cgroup.controllers     # доступные контроллеры

Контроллеры ресурсов

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

Перед тем как устанавливать лимиты, группу необходимо создать и разрешить в ней нужные контроллеры, поскольку родитель передаёт их потомкам явно:

mkdir /sys/fs/cgroup/mygroup
echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control

Без этих двух строк ни одна команда ниже не выполнится.

CPU:

# Ограничение CPU
echo "200000 1000000" > /sys/fs/cgroup/mygroup/cpu.max
# 200ms из каждых 1000ms

# CPU shares
echo 512 > /sys/fs/cgroup/mygroup/cpu.weight

Память:

# Лимит памяти
echo 1G > /sys/fs/cgroup/mygroup/memory.max

# SWAP лимит
echo 2G > /sys/fs/cgroup/mygroup/memory.swap.max

# убивать всю группу целиком, а не один процесс
echo 1 > /sys/fs/cgroup/mygroup/memory.oom.group

I/O:

# Ограничение дискового I/O
echo "8:0 rbps=1048576 wbps=1048576" > /sys/fs/cgroup/mygroup/io.max

Пространства имён: изоляция представлений

Виды пространств имён

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

ПространствоЧто подменяетКоманда
PIDномера процессовunshare --pid
Networkсетевые интерфейсы, порты, маршрутыunshare --net
Mountсмонтированные файловые системыunshare --mount
UTSимя узла и доменаunshare --uts
IPCочереди сообщений и семафоры System Vunshare --ipc
Userидентификаторы пользователей и группunshare --user
Cgroupвидимый корень иерархии cgroupsunshare --cgroup
Timeсистемное времяunshare --time

Пространства имён на практике

Команда unshare создаёт новые пространства имён и запускает в них процесс:

# Создание namespace и запуск процесса
sudo unshare --fork --pid --mount-proc bash

# В новом namespace:
ps aux    # видит только свои процессы
mount     # видит только свои mount points

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

# Создание root filesystem
mkdir /mycontainer
debootstrap stable /mycontainer

# Запуск в изоляции
unshare --fork --pid --mount-proc --net --uts --root=/mycontainer /bin/bash

# В контейнере:
hostname mycontainer
ip link set lo up

Заключение

При странном поведении программы правильнее не гадать, а запросить у системы, чем она занята. Если процесс завис, рассматривается его состояние; если расчёт замедлился, рассматриваются промахи по страницам и переключения контекста; если время уходит непонятно на что, рассматриваются системные вызовы, сделанные за секунду. Ядро сообщает о себе через /proc, /sys и perf.

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

Задания. Наблюдение за процессами и сигналами на практике: «Работа с долгоиграющими процессами». strace и perf, здесь только названные, потребуются в задании «Настройка окружения и работа с утилитами».

Docker

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

«У меня на машине работает»

Типичная ситуация выглядит следующим образом. Скрипт обработки данных, полученных на эксперименте, работает на ноутбуке автора, а при отправке коллеге завершается с ImportError. При запуске на кластере обнаруживается Python 3.8 вместо 3.12, системный numpy собран без нужных флагов, а права на установку пакетов есть только у администратора, находящегося в отпуске. Через два года рецензент просит воспроизвести график, напечатанный в статье, а автор уже не помнит версий библиотек, использовавшихся в то время.

Для физики это удар по воспроизводимости. Численный результат, не поддающийся повторению, стоит немногим больше, чем результат, которого нет. Виртуальные окружения и requirements.txt решают проблему частично: они фиксируют версии установленных Python-пакетов, но не версию самого Python, не системные библиотеки (BLAS, LAPACK, HDF5, компиляторы), не переменные окружения и не операционную систему. Создание виртуальных окружений и их наполнение рассматриваются в главе «От скрипта к приложению».

Docker упаковывает приложение вместе со всем его окружением, с интерпретатором, библиотеками, системными зависимостями и настройками, в единый образ. Собранный образ запускается одинаково на ноутбуке автора, на машине коллеги и на сервере в лаборатории.

Контейнеры vs виртуальные машины

Контейнер не является виртуальной машиной. Виртуальная машина эмулирует целый компьютер, несущий внутри полноценную гостевую операционную систему со своим ядром, а гипервизор транслирует её обращения к аппаратуре. Изоляция надёжная, но дорогая: каждая ВМ занимает гигабайты и стартует десятки секунд.

Контейнер представляет собой обычный процесс операционной системы, к которому ядро Linux применило два механизма. Namespaces (пространства имён) изолируют то, что процесс видит: у контейнера свои дерево процессов, сетевые интерфейсы и файловая система, и он считает себя единственным в системе. Cgroups (control groups) ограничивают то, что процесс потребляет, то есть отведённые ему память, процессорное время и дисковый ввод-вывод. Ядро при этом остаётся общим для всех контейнеров и хоста, поэтому контейнер стартует за доли секунды, а образ занимает от десятков до сотен мегабайт вместо гигабайт, занимаемых виртуальной машиной. Платой является несколько более слабая изоляция, чем у ВМ, что существенно для запуска чужого недоверенного кода и несущественно для упаковки собственных расчётов. Пространства имён и контрольные группы рассмотрены в предыдущей главе.

На macOS и Windows нативные Linux-контейнеры невозможны, поэтому Docker Desktop поддерживает лёгкую виртуальную машину с Linux, внутри которой работают контейнеры.

Образы и контейнеры

Существуют два центральных понятия:

  • Образ (image) — неизменяемый шаблон: файловая система с установленными в неё программами плюс метаданные (какую команду запускать, какие порты открывать). В терминах программирования образ соответствует классу.
  • Контейнер (container) — запущенный экземпляр образа, процесс со своим состоянием, объект этого класса. Из одного образа можно запустить произвольное число контейнеров.

Готовые образы скачиваются из реестра, по умолчанию из Docker Hub.

# скачать официальный образ Python 3.12 (slim — урезанный, без лишнего)
docker pull python:3.12-slim

# список локально скачанных образов
docker images

Запись python:3.12-slim читается как «репозиторий:тег», где тег означает версию. Если тег не указан, подставляется latest, однако образ под этим тегом сегодня и через год окажется разным, а требуется воспроизводимость.

Запуск контейнера осуществляется следующим образом:

# запустить контейнер и выполнить в нём одну команду
docker run python:3.12-slim python -c "print('hello from container')"

# интерактивный запуск: -i — пробросить stdin, -t — выделить терминал
# получаем REPL питона внутри контейнера
docker run -it python:3.12-slim python

# а так можно попасть в шелл контейнера и осмотреться
docker run -it python:3.12-slim bash

# --rm — удалить контейнер сразу после завершения (не копить мусор)
docker run --rm -it python:3.12-slim bash

Внутри контейнера команда ls / показывает чужую файловую систему, pip install устанавливает пакет внутри контейнера, и после его удаления всё исчезает. Контейнеры эфемерны: всё ценное должно находиться либо в образе, либо в примонтированных томах (о них ниже).

Управление контейнерами:

docker ps               # запущенные контейнеры
docker ps -a            # все, включая остановленные
docker stop <id|имя>    # мягко остановить контейнер
docker rm <id|имя>      # удалить остановленный контейнер
docker rmi <образ>      # удалить образ
docker logs <id|имя>    # посмотреть, что контейнер писал в stdout/stderr

Вместо полного идентификатора можно указывать первые несколько символов или имя, выданное контейнеру самим Docker (или заданное через --name).

Проброс портов и томов

Контейнер изолирован, в том числе по сети и по файлам; изоляция нарушается в нужных местах двумя флагами.

Порты, -p хост:контейнер. Сервис внутри контейнера слушает свой порт, и снаружи он недоступен, пока порт не проброшен на хост.

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

Пример: Jupyter, запущенный в контейнере без единого pip install на собственной машине.

# jupyter/scipy-notebook — готовый образ с numpy, scipy, pandas, matplotlib
# -p 8888:8888          порт 8888 хоста -> порт 8888 контейнера
# -v ...:/home/jovyan/work   текущий каталог -> каталог work внутри контейнера
# тег фиксируем ради воспроизводимости, реестр указываем явно:
# образы jupyter в конце 2023 года переехали с Docker Hub на quay.io
docker run --rm \
    -p 8888:8888 \
    -v "$(pwd)":/home/jovyan/work \
    quay.io/jupyter/scipy-notebook:2024-05-27

В браузере открывается http://localhost:8888 по ссылке с токеном, выведенной контейнером в журнал, и работа ведётся в блокнотах, находящихся в текущем каталоге хоста. После удаления контейнера блокноты остаются, а окружение исчезает.

Dockerfile: свой образ для научного проекта

Расчётный код требует своего набора зависимостей; рецепт сборки образа описывается в файле Dockerfile. Пусть имеется проект моделирования динамики пучка.

beam-simulation/
├── Dockerfile
├── requirements.txt      # numpy, scipy, matplotlib с версиями
├── simulate.py           # основной расчёт
└── beamlib/              # свои модули

Содержимое requirements.txt:

numpy==2.1.3
scipy==1.14.1
matplotlib==3.9.2

Dockerfile:

# Базовый образ: официальный Python, slim-вариант без лишних пакетов.
# Всегда фиксируй версию — это фундамент воспроизводимости.
FROM python:3.12-slim

# Рабочий каталог внутри образа; все следующие команды выполняются в нём.
# Если каталога нет, он будет создан.
WORKDIR /app

# Сначала копируем ТОЛЬКО файл с зависимостями...
COPY requirements.txt .

# ...и устанавливаем их. --no-cache-dir не хранит кеш pip в образе,
# образ получается меньше.
RUN pip install --no-cache-dir -r requirements.txt

# Только теперь копируем весь остальной код проекта.
COPY . .

# Команда по умолчанию при запуске контейнера.
# Форма со скобками (exec form) запускает процесс напрямую, без оболочки.
CMD ["python", "simulate.py"]

Рассмотрим инструкции:

  • FROM задаёт базовый образ. Всё, что содержится в python:3.12-slim (Debian, Python, pip), достаётся готовым.
  • WORKDIR /app объявляет текущий каталог для последующих COPY, RUN, CMD.
  • COPY <откуда> <куда> копирует файлы из каталога проекта, называемого контекстом сборки, внутрь образа.
  • RUN выполняет команду во время сборки и сохраняет результат в образ. Здесь же устанавливаются системные пакеты наподобие RUN apt-get update && apt-get install -y gfortran.
  • CMD указывает, что запускать при старте контейнера. Выполняется не при сборке, а при docker run, и легко переопределяется командой docker run мой-образ bash.

Слои и кеширование: отдельное копирование requirements.txt

Каждая инструкция Dockerfile создаёт слой — снимок изменений, внесённых ею в файловую систему. Образ представляет собой стопку слоёв, и Docker их кеширует: если инструкция и её входные файлы не изменились, слой берётся из кеша, а не пересобирается. Как только один слой изменился, все последующие пересобираются.

Код (simulate.py) правится по двадцать раз в день, а requirements.txt раз в месяц. Если бы COPY . . располагалась до установки зависимостей, любая правка, сделанная в коде, инвалидировала бы слой копирования, и Docker заново выполнял бы pip install со скачиванием numpy и scipy, что означает пять минут ожидания после каждой изменённой строки. В приведённом порядке слой с зависимостями зависит только от requirements.txt: при правке кода пересобирается лишь дешёвый COPY . ., и сборка занимает секунды. Правило: редко меняющееся выше, часто меняющееся ниже.

Файл .dockerignore устроен по образцу .gitignore: в нём перечисляются .git, __pycache__, данные и прочее, что не должно попадать в образ.

Сборка и запуск

# Собрать образ из Dockerfile в текущем каталоге (точка — контекст сборки).
# -t задаёт имя и тег.
docker build -t beam-simulation:0.1 .

# Тег можно навесить и потом (это просто ярлык на тот же образ)
docker tag beam-simulation:0.1 beam-simulation:latest

# Запуск: результаты пишем в примонтированный каталог,
# иначе они умрут вместе с контейнером
docker run --rm -v "$(pwd)/results":/app/results beam-simulation:0.1

# Переопределить CMD: запустить другой скрипт из того же образа
docker run --rm beam-simulation:0.1 python -m beamlib.diagnostics

Весь расчёт сводится к одной команде, воспроизводимой на любой машине с Docker.

Реестры: Docker Hub и ghcr.io

Передать образ коллеге можно файлом (docker save и docker load), однако стандартным путём является реестр (registry), устроенный как git-хостинг для образов. По умолчанию используется публичный Docker Hub, откуда был скачан python:3.12-slim. Второй популярный вариант, GitHub Container Registry (ghcr.io), удобен тем, что образы располагаются рядом с кодом проекта на GitHub и собираются автоматически через GitHub Actions.

Имя образа, отправляемого в реестр, должно включать аккаунт владельца, после чего выполняется docker push.

# тегируем образ под свой аккаунт на Docker Hub
docker tag beam-simulation:0.1 fuodorov/beam-simulation:0.1

docker login                            # авторизация (однократно)
docker push fuodorov/beam-simulation:0.1

# коллега на любой машине делает:
docker pull fuodorov/beam-simulation:0.1

# для ghcr.io всё то же, только префикс реестра в имени:
docker tag beam-simulation:0.1 ghcr.io/fuodorov/beam-simulation:0.1
docker push ghcr.io/fuodorov/beam-simulation:0.1

Docker Compose: несколько контейнеров

Система редко состоит из одного контейнера: типичный сценарий — приложение, записывающее результаты в базу данных PostgreSQL. Запускать оба контейнера вручную, настраивать сеть между ними, помнить порядок запуска и десяток флагов утомительно. Docker Compose описывает всю связку декларативно, в одном файле compose.yaml:

services:
  app:
    build: .                    # собрать образ из Dockerfile в текущем каталоге
    volumes:
      - ./results:/app/results  # результаты — на хост
    environment:
      DB_HOST: db               # имя сервиса работает как сетевое имя хоста
      DB_USER: physicist
      DB_PASSWORD: secret
    depends_on:
      - db                      # сначала стартует база, потом приложение

  db:
    image: postgres:16          # готовый образ с Docker Hub
    environment:
      POSTGRES_USER: physicist
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: measurements
    volumes:
      - pgdata:/var/lib/postgresql/data   # именованный том: данные переживут контейнер

volumes:
  pgdata:

Compose автоматически создаёт общую сеть, в которой контейнеры видят друг друга по именам сервисов, объявленным в файле, и приложение подключается к базе по адресу db:5432, без IP. Основные команды:

docker compose build      # только собрать образы, не запуская
docker compose up -d      # собрать и запустить всё; -d — в фоне
docker compose logs -f    # смотреть логи всех сервисов
docker compose ps         # какие сервисы подняты и какие порты опубликованы
docker compose down       # остановить и удалить контейнеры (тома останутся)

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

Видимость портов снаружи и внутри

В примере выше наружу не опубликовано ничего: приложение и база взаимодействуют только между собой, по сети, созданной Compose. Чтобы открыть сервис пользователю или соседней машине, существуют два разных ключа.

services:
  nginx:
    image: nginx:1.27
    ports:
      - "80:80"     # опубликовать наружу: порт 80 хоста -> порт 80 контейнера
  backend:
    build: ./backend
    expose:
      - "8000"      # видно только соседям по сети Compose, с хоста не достучаться

ports публикует порт на хост, и по нему в контейнер может обратиться любой, у кого есть доступ к машине. expose не публикует ничего, а лишь помечает порт, на котором сервис слушает, для соседей по той же сети. Обычная схема рабочего стенда: снаружи опубликован единственный порт, а за ним стоит nginx, раздающий статику фронтенда и перенаправляющий запросы к API на бэкенд по имени сервиса. Такой nginx называется обратным прокси, и в его конфигурации достаточно одной секции:

server {
    listen 80;
    location / {
        root /usr/share/nginx/html;     # собранный фронтенд
    }
    location /api/ {
        proxy_pass http://backend:8000/;   # backend — имя сервиса из compose.yaml
    }
}

Порт, не опубликованный наружу, нельзя атаковать с хоста и из сети: база данных и бэкенд остаются недосягаемыми, даже если в них обнаружится уязвимость. Проверкой служит команда docker compose ps: публикация должна присутствовать ровно у одного сервиса, а curl на порт бэкенда с хоста отвечать не должен.

Контейнеры в науке

Контейнер является ответом рецензенту, требующему воспроизвести результаты. Всё чаще к статье прикладывают не только код, но и собранный образ или хотя бы Dockerfile, помещённый в репозиторий. Одной команды docker run достаточно, чтобы читатель получил те же графики из тех же данных, с точностью до версий всех задействованных библиотек. На суперкомпьютерах Docker обычно запрещён из-за демона, работающего с правами root. Там используется Apptainer (бывший Singularity), контейнерная система, спроектированная для HPC, которая работает без root-прав, совместима с MPI и GPU и позволяет напрямую конвертировать Docker-образы. Поэтому образ, собранный для ноутбука, почти без изменений запускается и на кластере.

Полезные ссылки

Задание. Упаковать бэкенд и фронтенд в образы и связать их через docker compose: «Деплой стартапа „Котики в мир“».

Устройство интерпретатора Python

… in December 1989, I was looking for a “hobby” programming project that would keep me occupied during the week around Christmas. My office … would be closed, but I had a home computer, and not much else on my hands.

Гвидо ван Россум, предисловие к «Programming Python» (1-е изд.)

Так, из «хобби на рождественские каникулы», родился язык, на котором сегодня основана значительная часть научных вычислений.

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

Когда обработка данных, полученных в эксперименте, занимает часы вместо минут, причина почти всегда состоит не в «медленном Python», а в неудачно выбранной структуре данных или лишнем копировании.

Слайды к главе. Материал главы изложен также в первой части лекции «Python. Начало», продолжение лекции относится к трём следующим главам; слайды лекции доступны на сайте книги и в PDF.

Язык и его реализации

Python — язык, описанный спецификацией (The Python Language Reference), и у него несколько реализаций. Эталонной и самой распространённой является CPython, написанный на C: именно он устанавливается с python.org и из пакетов дистрибутивов Linux. PyPy компилирует часто выполняемые участки программы в машинный код во время работы (JIT), поэтому циклы на чистом Python выполняются в нём в несколько раз быстрее. MicroPython работает на микроконтроллерах с сотнями килобайт памяти, GraalPy — на виртуальной машине GraalVM.

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

Путь исходного текста

CPython не исполняет исходный текст напрямую. Лексический анализатор разбивает текст на токены: имена, числа, строки, операторы, отступы. Синтаксический анализатор строит из токенов абстрактное синтаксическое дерево (AST). Компилятор обходит дерево и порождает байт-код — последовательность инструкций виртуальной стековой машины, упакованную вместе с таблицами констант и имён в объект кода. Этот объект исполняет цикл вычисления интерпретатора, по одной инструкции за шаг. Каждый этап доступен из самого Python: модули tokenize и ast, встроенная функция compile и модуль dis.

Байт-код функции печатает модуль dis; здесь и далее приведён вывод CPython 3.12.

import dis

def area(r):
    return 3.14159 * r ** 2

dis.dis(area)
  3           0 RESUME                   0

  4           2 LOAD_CONST               1 (3.14159)
              4 LOAD_FAST                0 (r)
              6 LOAD_CONST               2 (2)
              8 BINARY_OP                8 (**)
             12 BINARY_OP                5 (*)
             16 RETURN_VALUE

Первый столбец — номер строки исходного текста, второй — смещение инструкции в байтах. Инструкции берут операнды с вершины стека значений и кладут туда результат: LOAD_CONST загружает константу, LOAD_FAST — локальную переменную, BINARY_OP снимает два значения и кладёт результат операции, RETURN_VALUE возвращает вершину стека вызывающему коду. RESUME в начале функции служит точкой, в которой интерпретатор проверяет запросы трассировки. Выражения из одних констант компилятор вычисляет заранее: в байт-коде присваивания x = 2 * 3.14159 остаётся одна константа 6.28318.

С версии 3.11 интерпретатор адаптивный (PEP 659): во время работы он заменяет общие инструкции специализированными. Уже после двух вызовов area(1.0) функция dis.dis(area, adaptive=True) показывает вместо второго BINARY_OP инструкцию BINARY_OP_MULTIPLY_FLOAT, умножающую именно вещественные числа. Байт-код не входит в спецификацию языка: набор инструкций меняется от версии к версии, поэтому вывод dis в материалах о других версиях выглядит иначе.

Модули и файлы .pyc

Каждый файл .py является модулем. Инструкция import circle находит файл circle.py, создаёт объект модуля, заносит его в словарь sys.modules и выполняет файл сверху вниз; имена, определённые в файле, становятся атрибутами модуля: circle.area. Повторный import находит модуль в sys.modules и файл заново не выполняет. Байт-код импортированного модуля сохраняется в каталоге __pycache__ в файле с тегом версии интерпретатора (circle.cpython-312.pyc) и используется повторно, пока исходный файл не изменился; запускаемый сценарий компилируется при каждом запуске.

У импортированного модуля атрибут __name__ равен его имени, а у файла, запущенного как сценарий, — строке "__main__". На этом основана идиома, позволяющая одному файлу служить и сценарием, и библиотекой функций: код под условием выполняется при запуске файла и не выполняется при его импорте.

def main():
    ...

if __name__ == "__main__":
    main()

Модули ищутся по списку каталогов sys.path, и после встроенных в интерпретатор модулей первым просматривается каталог запускаемого сценария. Поэтому файл random.py или numpy.py, созданный рядом со сценарием, подменяет одноимённый модуль стандартной библиотеки или установленного пакета, и импорт завершается непонятной ошибкой вида AttributeError: module 'random' has no attribute 'randint'. Имя собственного файла выбирается так, чтобы не совпадать с именами модулей.

Интерактивная оболочка

Интерактивная оболочка (REPL, read–eval–print loop) читает строку, выполняет её и печатает значение выражения; приглашение ... означает, что оболочка ждёт продолжения блока. Для исследования объектов служат функции help, dir, type и id, а значение последнего выражения сохраняется в переменной _. Команда python3 -i run.py выполняет сценарий и оставляет оболочку открытой со всеми его переменными, что удобно для разбора ошибки. В Python 3.13 оболочка получила многострочное редактирование и цветные сообщения об ошибках, а IPython и блокноты Jupyter дополняют её сохранением кода, результатов и графиков.

Объекты и память

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

Слайды к главе. Материал главы изложен также во второй, третьей и четвёртой частях лекции «Python. Начало» (объекты в памяти, изменяемость, сборка мусора) с демонстрациями в интерактивной оболочке; слайды лекции доступны на сайте книги и в PDF.

Объекты в памяти

В Python объектом является всё: и число, и строка, и функция, и сам класс. Любой объект обладает тремя свойствами. Тип определяет набор действий, разрешённых с объектом (int, str, list). Значение представляет собой данные, хранящиеся внутри объекта (42, "hello"). Идентификатор является числом, которое не меняется на всё время жизни объекта и уникально среди объектов, существующих одновременно: после уничтожения объекта его идентификатор может достаться новому. В CPython это адрес объекта в памяти, получаемый функцией id().

s = "hello"
print(id(s))  # Выведет что-то вроде 4390213040

Ссылки, а не копии

Переменная в Python не «содержит» объект, а ссылается на него, поэтому присваивание одной переменной другой создаёт второе имя для того же объекта, а данные остаются на месте. Если объект изменён через одно имя, изменение будет видно и через второе.

a = [1, 2, 3]
b = a
b.append(4)
print(a)  # [1, 2, 3, 4] — изменился и a!

Сравнение is и ==

Оператор is проверяет, является ли это одним объектом, а == сравнивает значения. Два списка могут быть равны поэлементно и при этом оставаться разными объектами, расположенными в памяти по разным адресам.

x = [1, 2]
y = [1, 2]
print(x == y)  # True
print(x is y)  # False

Кеширование целых чисел

При запуске интерпретатор заранее создаёт объекты для небольших целых чисел (в CPython 3.10–3.14 это диапазон от −5 до 256, в 3.15 кеш расширен), и все переменные с такими значениями ссылаются на один и тот же заранее созданный объект.

a = 256
b = 256
print(a is b)  # True — это один и тот же закешированный объект

Для 257 результат зависит от того, каким образом запускается код.

c = 257
d = 257
print(c is d)

При запуске этого фрагмента в виде файла будет получено True, тогда как при вводе тех же трёх строк в интерактивной оболочке результатом окажется False.

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

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

💡 Золотое правило: для сравнения значений необходимо использовать ==, а is применять только для проверки на None, True и False.

Изменяемые и неизменяемые объекты

Это основное деление объектов Python.

Неизменяемые (immutable)

К неизменяемым относятся int, float, str, tuple, bytes и bool. Однажды созданный объект хранит своё значение навсегда. Любая «модификация» создаёт новый объект, на который переставляется старое имя, в чём проще всего убедиться по идентификатору, меняющемуся после каждого +=.

s = "hello"
print(id(s))
s += "!"
print(id(s))  # ID изменился — это новый объект!

Изменяемые (mutable)

list, dict и set, напротив, изменяются на месте. Список после append остаётся тем же самым объектом, в котором лишь стало на один элемент больше, и все имена, ссылавшиеся на него, увидят добавленный элемент.

lst = [1, 2]
print(id(lst))
lst.append(3)
print(id(lst))  # ID остался прежним

Кортежи с изменяемыми элементами

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

t = ([1, 2], [3, 4])
t[0].append(3)
print(t)  # ([1, 2, 3], [3, 4]) — это допустимо!

Такой кортеж нельзя поместить в множество или использовать как ключ словаря, поскольку хеш кортежа складывается из хешей его элементов, а у списка хеша нет. Попытка завершится исключением TypeError: unhashable type: 'list' при вставке.

Строки и байты

Ниже рассматриваются два неизменяемых типа, которые начинающие разработчики часто путают.

Строки (str)

str хранит текст, то есть последовательность символов Unicode. Длина строки измеряется в символах, а не в байтах, и один символ может быть латинской буквой, кириллической, иероглифом или значком эмодзи. Способ размещения этих символов в памяти определяется интерпретатором.

Байты (bytes)

bytes хранит сырые данные, то есть последовательность чисел от 0 до 255: то, что находится в файле, приходит по сети или считывается с прибора. Понятие «символ» здесь отсутствует, есть только числа. Тип также является неизменяемым; его изменяемый вариант называется bytearray.

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

text = "привет"
encoded = text.encode('utf-8')  # str -> bytes
decoded = encoded.decode('utf-8')  # bytes -> str

Кириллица в UTF-8 занимает по два байта на символ, поэтому len(text) даст 6, а len(encoded) вернёт 12. Почти все UnicodeDecodeError возникают именно отсюда: байты читаются не в той кодировке, в которой были сохранены.

Условные конструкции

Базовый синтаксис

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

temperature = 15
if temperature > 20:
    print("Наденьте футболку")
else:
    print("Лучше взять куртку")

Элегантные проверки

В условии не обязательно записывать сравнение, поскольку любой объект сам по себе истинен или ложен: пустая строка, пустой список, ноль и None ложны, всё остальное истинно. Поэтому if name: читается как «если имя есть» и заменяет if name != "":

name = input("Введите имя: ")
if name:  # False, если строка пустая
    print(f"Привет, {name}!")
else:
    print("Привет, незнакомец!")

Современный match (Python 3.10+)

Когда одну переменную необходимо сравнить с десятком значений, цепочка из elif становится нечитаемой, и для этого случая предусмотрен match, аналог switch из других языков. Ветви перебираются сверху вниз, вертикальная черта объединяет несколько вариантов в один случай, а case _ перехватывает всё, что не было перехвачено раньше.

http_status = 404
match http_status:
    case 200 | 201:
        print("Успех")
    case 401 | 403 | 404:
        print("Ошибка клиента")
    case 500 | 503:
        print("Ошибка сервера")
    case _:
        print("Неизвестный статус")

💡 Совет: match следует применять при сравнении одной переменной с несколькими значениями. Для сложных условий предпочтительнее if/elif/else.

Циклы

while

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

i = 1
while i < 1000:
    print(i)
    i *= 2

for и range

for в Python перебирает не индексы, а элементы; если необходим счётчик, его предоставляет range. С одним аргументом он отсчитывает от нуля, с тремя задаёт начало, границу и шаг. Правая граница в диапазон не входит.

for i in range(5):      # 0, 1, 2, 3, 4
    print(i)

for i in range(1, 10, 2):  # 1, 3, 5, 7, 9
    print(i)

enumerate для получения индекса

Когда вместе с элементом требуется и его номер, заводить отдельный счётчик не нужно: enumerate выдаёт пары «индекс, элемент», распаковываемые в заголовке цикла.

for idx, char in enumerate("abc"):
    print(f"Индекс: {idx}, Символ: {char}")

Ветка else в циклах

У циклов в Python есть ветка else, почти не встречающаяся в других языках. Она выполняется, если цикл завершился естественно, то есть перебрал всё до конца и ни разу не встретил break. Это готовый каркас для поиска: в теле цикла выполняется поиск с выходом по break, а в else записывается ветка «не найдено».

for i in range(5):
    if i == 10:
        break
else:
    print("Цикл завершился без break")

Производительность при работе со строками

Каждое += для строки создаёт новый объект и копирует в него всё накопленное ранее. На сотой итерации копируется сто символов, на стотысячной — сто тысяч, и в сумме получается квадратичное время вместо линейного.

result = ""
for _ in range(100000):
    result += "a"  # Создаёт новый объект на каждой итерации!

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

Правильнее накапливать части в списке и объединить их один раз. Список изменяем, append ничего не копирует, а join заранее вычисляет итоговую длину, выделяет память один раз и переносит туда все части.

parts = []
for _ in range(100000):
    parts.append("a")
result = "".join(parts)  # Быстрое объединение

💡 Совет: для частых операций конкатенации рекомендуется использовать list + join() для строк или bytearray для байтов.

Сборка мусора

Память под объекты в Python освобождается автоматически, и обеспечивается это двумя механизмами.

Основной — подсчёт ссылок: у каждого объекта есть счётчик, показывающий, сколько имён и контейнеров на него ссылаются. Как только счётчик падает до нуля, объект уничтожается немедленно. Это быстро и предсказуемо, и в большинстве программ ничего сверх этого не требуется.

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

a = []
b = [a]
a.append(b)  # Теперь a и b ссылаются друг на друга
del a, b     # Объекты недостижимы, но ссылки остались

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

Неочевидные особенности Python

1. Изменяемые аргументы по умолчанию

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

def append_to(element, target=[]):  # так делать нельзя!
    target.append(element)
    return target

print(append_to(1))  # [1]
print(append_to(2))  # [1, 2] — тот же список!

Решение: по умолчанию указывается None, а пустой список создаётся внутри функции, заново при каждом вызове.

def append_to(element, target=None):
    if target is None:
        target = []
    target.append(element)
    return target

2. Ленивые логические операторы

and и or вычисляют правую часть только тогда, когда без неё нельзя обойтись. Если левый операнд and ложен, результат уже известен, и правая часть не вычисляется. Благодаря этому выражение if xs and xs[0] > 0 не приведёт к ошибке на пустом списке.

def expensive_call():
    print("Вызов выполнен!")
    return True

# expensive_call() не будет вызвана, т.к. первое условие False
if False and expensive_call():
    pass

3. Атрибуты функций

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

def my_func():
    pass

my_func.custom_attr = 42
print(my_func.custom_attr)  # 42

Заключение

Переменная представляет собой имя, привязанное к объекту, а не контейнер со значением. Отсюда следуют и общий список после двух присваиваний, и накопительный аргумент по умолчанию, и квадратичное время сборки строки через +=.

Таким образом, можно сформулировать четыре правила:

  • is применяется только для None, True и False; во всех остальных случаях используется ==.
  • Строки собираются через join, а не через += в цикле.
  • Значение по умолчанию не должно быть изменяемым объектом, вместо него указывается None.
  • Прежде чем передать объект в стороннюю функцию, необходимо определить, изменяем он или нет.

Полезные материалы:

Коллекции

Коллекции составляют основу хранения и обработки данных в Python. В настоящей главе рассматриваются конкретные типы, встроенные в язык, и их устройство, тогда как сами структуры данных, дек и хеш-таблица в их общем виде, разбираются в части «Алгоритмы и структуры данных»; стоимость конкретных операций над списком, множеством и словарём оценивается в следующей главе. Выбор между ними определяет, будет программа выполняться секунды или часы.

Слайды к главе. Материал главы изложен также в пятой части лекции «Python. Начало» с демонстрациями в интерактивной оболочке; слайды лекции доступны на сайте книги и в PDF.

Понятие коллекции

Коллекцией в Python стандартная библиотека называет объект с тремя свойствами. Он является контейнером (Container), то есть отвечает на оператор in; он итерируемый (Iterable), и его элементы можно перебрать в цикле; он ограниченной длины (Sized), а значит знает, сколько в нём элементов, и отвечает на len().

Принадлежность к коллекциям проверяется через isinstance с абстрактным классом из стандартной библиотеки.

from collections.abc import Collection

# Проверим, является ли список коллекцией
print(isinstance([1, 2, 3], Collection))  # True

Любопытные исключения

  • Числовой интервал служит контейнером: про любое число можно сказать, лежит оно внутри или нет. Однако перебрать все его точки нельзя, и длины, измеряемой в элементах, у него нет.
  • Генератор итерируем, но заранее не знает, сколько элементов выдаст, и не способен отвечать на in, не израсходовав себя.

Синтаксис @dataclass и магические методы, за которыми стоят операторы наподобие in, разбираются в главе «Классы»; здесь описывается собственный тип, отвечающий на in.

# Пример: Интервал как контейнер
from dataclasses import dataclass

@dataclass
class Interval:
    a: float
    b: float

    def __contains__(self, x):
        return self.a < x < self.b

interval = Interval(0, 1)
print(0.5 in interval)  # True — оператор in работает

# а вот длины у интервала нет и быть не может:
# len(interval) -> TypeError: object of type 'Interval' has no len()

Иерархия коллекций

Стандартная библиотека Python определяет абстрактные базовые классы (ABC), по которым коллекции и классифицируются:

   Container      Iterable       Sized          три независимых свойства
       |             |             |
       +-------------+-------------+
                     |
                 Collection        объект, обладающий всеми тремя
                     |
        +------------+------------+
        |            |            |
    Sequence        Set        Mapping

Container, Iterable и Sized представляют собой три независимых свойства, и объект может обладать любым их сочетанием. Генератор итерируем, но не знает своей длины; рассмотренный выше Interval, не позволяющий перебирать точки, остаётся контейнером. Collection объединяет все три свойства, потому и наследуется от всех трёх.

Ниже располагаются три семейства:

  • Sequence (последовательности), где элементы упорядочены и доступны по индексу. Сюда относятся списки, кортежи и строки.
  • Set (множества), где элементы уникальны, а порядка, связывающего их, нет. Это set и frozenset.
  • Mapping (отображения), хранящие пары «ключ, значение». Это словари.

Списки (list): универсальные и изменяемые

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

Неочевидные особенности инициализации

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

# Кажется, что это создаст матрицу 2x1
chunks = [[0]] * 2
chunks[0][0] = 42
print(chunks)  # [[42], [42]] Оба элемента ссылаются на один и тот же список!

# Правильный способ: включение создаёт новый список на каждой итерации
correct_chunks = [[0] for _ in range(2)]
correct_chunks[0][0] = 42
print(correct_chunks)  # [[42], [0]] — а вот теперь как надо

Тело включения выполняется на каждой итерации заново, и [0] создаёт новый список каждый раз.

Эффективные операции

Внутренне список устроен как динамический массив, и стоимость операций следует из этого устройства. append(item) и pop(), работающие с последним элементом, имеют амортизированную сложность O(1). insert(0, item) и pop(0) сдвигают все элементы и имеют сложность O(n). extend(iterable) выгоднее серии append(), поскольку один раз оценивает требуемый размер вместо наращивания списка по одному элементу.

Совет: При необходимости частых операций с обоих концов целесообразно использовать collections.deque (устройство дека и сравнительные измерения со списком приведены в главе «Основные структуры данных»).

Кортежи (tuple): неизменяемые и хешируемые

Неизменяемость даёт кортежу свойство, отсутствующее у списка: кортеж хешируем, а значит, подходит для ключей словаря и элементов множества. Пара координат, связка «прибор, канал», дата, записанная тремя числами, помещаются в кортеж и становятся ключом.

Распаковка кортежей

Распаковка разбирает кортеж на переменные одним присваиванием. Звёздочка слева от знака равенства собирает остаток в список, подчёркивание по соглашению помечает ненужное значение, а звёздочка в вызове функции работает в обратную сторону и раскладывает кортеж по аргументам.

# Распаковка с упаковкой «лишних» элементов в переменную
first, second, *rest = range(10)
print(first, second, rest)  # 0 1 [2, 3, 4, 5, 6, 7, 8, 9]

# Игнорирование ненужных значений
x, _, z = (1, 2, 3)

# Распаковка в аргументы функции
def greet(name, greeting):
    return f"{greeting}, {name}!"

person = ("Alice", "Hello")
print(greet(*person))  # "Hello, Alice!"

Именованные кортежи (namedtuple)

Через месяц смысл, вложенный в point[1], оказывается забытым. collections.namedtuple является фабрикой классов, создающей подтип кортежа с именованными полями. Полученный объект остаётся кортежем со всеми его свойствами, но к элементам можно обращаться по имени.

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(10, y=20)
print(p.x, p.y)  # 10 20
print(p._asdict()) # {'x': 10, 'y': 20}

Метод _asdict() преобразует такой кортеж в словарь, что удобно для вывода и сериализации. Имена полей задаются пользователем, поэтому служебные методы помечаются подчёркиванием, чтобы они не столкнулись с полем, названным asdict.

Множества (set): уникальность и скорость

Множества реализованы как хеш-таблицы, рассматриваемые в главе «Хеш-функции», поэтому проверка вхождения (in) имеет среднюю сложность O(1), и время не зависит от размера множества.

Неочевидные применения множеств

Множество применяется не только ради уникальности, но и ради быстрой проверки принадлежности.

  1. Удаление дубликатов из списка.

    unique_list = list(set(duplicated_list))
    
  2. Подсчёт элементов, встречающихся сразу в двух коллекциях.

    common = set(list1) & set(list2)
    
  3. Фильтрация «мусора» при разборе данных.

    valid_tags = {'python', 'tutorial', 'advanced'}
    tags = ['python', 'beginner', 'advanced']
    filtered_tags = [tag for tag in tags if tag in valid_tags]
    # ['python', 'advanced']
    

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

frozenset: неизменяемое множество

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

# Множество множеств? Нет.
# {set([1,2]), set([3,4])}  # TypeError: unhashable type: 'set'

# А так — можно.
fs1 = frozenset([1, 2])
fs2 = frozenset([3, 4])
meta_set = {fs1, fs2}  # Valid

Словари (dict): сердце Python

Словарь является наиболее нагруженной структурой в языке. На нём построены пространства имён модулей, атрибуты объектов и передача именованных аргументов. Начиная с Python 3.7 словарь гарантированно сохраняет порядок добавления элементов; ранее это было деталью реализации CPython 3.6, не оговорённой в языке.

Малоизвестные возможности словарей

  1. Метод setdefault() возвращает значение по ключу, а при отсутствующем ключе сначала помещает в словарь указанное значение, за один поиск по хеш-таблице вместо двух.

    data = {}
    # Классический, но неэффективный способ
    if 'key' not in data:
        data['key'] = []
    data['key'].append(1)
    
    # Эффективный способ с setdefault
    data.setdefault('key', []).append(1)
    
  2. Метод popitem() удаляет и возвращает пару (ключ, значение) в порядке LIFO (последним пришёл, первым ушёл). Полезен для обработки данных в обратном порядке.

  3. Словарные включения (Dict Comprehensions).

    squares = {x: x*x for x in range(5)}
    # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
    

Коллекции из модуля collections

Для частых сценариев в стандартной библиотеке предусмотрены готовые надстройки над четырьмя базовыми типами. Counter, defaultdict и OrderedDict являются подклассами словаря, а deque поддерживает интерфейс последовательности, поэтому большая часть кода работает с ними без изменений. Исключения всё же есть: у дека нет срезов, а defaultdict создаёт ключ уже при чтении d[k].

  • defaultdict, словарь с фабричной функцией, отвечающей за недостающие ключи.

    from collections import defaultdict
    graph = defaultdict(list)
    graph['a'].append('b')  # Не нужно проверять, есть ли ключ 'a'
    
  • Counter, подкласс словаря, подсчитывающий хешируемые объекты.

    from collections import Counter
    words = ['apple', 'banana', 'apple', 'orange']
    word_count = Counter(words)
    print(word_count.most_common(1))  # [('apple', 2)]
    
  • deque, двусторонняя очередь, пополняемая с обоих концов. Подходит для очередей (FIFO) и стеков (LIFO).

    from collections import deque
    queue = deque([1, 2, 3])
    queue.append(4)    # O(1)
    queue.popleft()    # O(1) — в отличие от списка!
    
  • OrderedDict, словарь, сохраняющий порядок. В Python 3.7+ обычный dict также упорядочен, но у OrderedDict есть дополнительные методы (move_to_end, popitem(last=True/False)).

Заключение

Выбор коллекции определяет эффективность и корректность программы. Это решение принимается в начале, когда переписывание ещё обходится дёшево.

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

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

Задание. Собрать словарь цепочек и сгенерировать по нему текст: «Генерация текста на основе данных».

Сложность операций с коллекциями

Здесь рассматривается, во что обходятся конкретные операции над списком, множеством и словарём и почему выбор структуры определяет больше, чем оптимизация кода. Сама O-нотация со всей строгостью разбирается далее, в главе «Введение в алгоритмы».

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

Слайды к главе. Материал главы изложен также в шестой части лекции «Python. Начало» с замерами в терминале; слайды лекции доступны на сайте книги и в PDF.

В нотации O-большое описывается, как время выполнения алгоритма растёт вслед за размером входных данных.

Значение сложности

Ниже миллион чисел помещается в список и в множество, после чего в каждом из них выполняется поиск одного и того же значения оператором in. Синтаксис не отличается ни одним символом, отличается работа, выполняемая внутри:

from timeit import timeit
import random

# Сравним поиск в списке и множестве
large_list = list(range(1000000))
large_set = set(large_list)

# Ищем случайный элемент
target = random.randrange(1000000)

# Время поиска в списке (O(n))
list_time = timeit(lambda: target in large_list, number=1000)
# Время поиска в множестве (O(1))
set_time = timeit(lambda: target in large_set, number=1000)

print(f"Поиск в списке: {list_time:.4f} сек")
print(f"Поиск в множестве: {set_time:.4f} сек")

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

Сложность операций со списками

Константное время O(1)

my_list = [1, 2, 3, 4, 5]

# Эти операции выполняются за постоянное время
my_list.append(6)      # Добавление в конец
last = my_list.pop()   # Удаление с конца
element = my_list[2]   # Доступ по индексу
length = len(my_list)  # Получение длины

Почему O(1): список в Python устроен как динамический массив указателей. Он выделяет память с запасом, поэтому append записывает указатель в свободную ячейку и увеличивает счётчик длины, не перемещая остальные элементы.

Изредка запас исчерпывается, и списку приходится выделить больший блок памяти и скопировать туда все элементы, что стоит \( O(n) \). Однако поскольку размер увеличивается кратно, перевыделения происходят всё реже, и в среднем на одну операцию по-прежнему приходится константа. Такую усреднённую сложность называют амортизированной \( O(1) \).

Линейное время O(n)

my_list = [1, 2, 3, 4, 5]

# Эти операции требуют обхода или сдвига элементов
my_list.insert(0, 0)   # Вставка в начало → сдвиг всех элементов
my_list.remove(3)      # Поиск и удаление элемента
element in my_list     # Поиск элемента
my_list.index(4)       # Поиск индекса элемента

Почему O(n): при вставке в начало все элементы, следующие далее, приходится сдвигать на одну позицию. А in, remove и index проходят по списку с начала, сравнивая элементы: заранее известного места под значение у массива нет.

Медленный и быстрый код

Обе функции ниже удаляют дубликаты с сохранением порядка. Отличие в одной строке: первая проверяет item not in result по списку, вторая обращается к множеству seen. За строкой внутри цикла скрывается ещё один проход по всем элементам, превращающий линейный алгоритм в квадратичный.

# НЕЭФФЕКТИВНО: O(n²)
def remove_duplicates_slow(data):
    result = []
    for item in data:
        if item not in result:  # O(n) для каждого элемента!
            result.append(item)
    return result

# ЭФФЕКТИВНО: O(n)
def remove_duplicates_fast(data):
    seen = set()
    result = []
    for item in data:
        if item not in seen:    # O(1) проверка!
            seen.add(item)
            result.append(item)
    return result

# Тестируем на семи тысячах случайных чисел
import random
data = [random.randrange(7000) for _ in range(7000)]

slow_time = timeit(lambda: remove_duplicates_slow(data), number=1)
fast_time = timeit(lambda: remove_duplicates_fast(data), number=1)

print(f"Медленная версия: {slow_time:.4f} сек")   # 0.0520
print(f"Быстрая версия:   {fast_time:.4f} сек")   # 0.0002

Разница составляет двести пятьдесят раз, и растёт она вместе с объёмом. На семидесяти тысячах элементов медленная версия выполняется за 4.6 секунды против 2 миллисекунд у быстрой, то есть отстаёт почти в две тысячи раз. Время первой выросло в девяносто раз при десятикратном росте данных: это и есть квадратичность.

Следует отметить тонкость самого измерения. Если вместо случайных чисел подставить набор [1, 2, 2, 3, 4, 4, 5] * 1000, содержащий те же семь тысяч элементов, разница сократится до полутора раз вместо двухсот пятидесяти. Причина в том, что различных значений там всего пять. Список result, растущий только на новых значениях, не превысит пяти элементов, проверка item not in result пройдёт по пяти элементам вместо тысяч, и квадратичность не проявится.

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

Сложность операций с множествами

В основе множества лежит хеш-таблица, рассмотренная в главе про хеш-функции, поэтому большинство операций имеют сложность O(1).

my_set = {1, 2, 3, 4, 5}

# O(1) операции
my_set.add(6)           # Добавление
my_set.remove(3)        # Удаление
4 in my_set             # Проверка вхождения
len(my_set)             # Длина

# операции над двумя множествами — сложность у каждой своя
s1 = {1, 2, 3}
s2 = {3, 4, 5}
union = s1 | s2         # объединение:  O(len(s1) + len(s2))
intersection = s1 & s2  # пересечение:  O(min(len(s1), len(s2)))
difference = s1 - s2    # разность:     O(len(s1))

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

Интересный факт: при массовых коллизиях хешей операции с множеством вырождаются в O(n). На практике это почти не встречается, за исключением случаев, когда данные намеренно подобраны злоумышленником, знающим хеш-функцию.

Сложность операций со словарями

Словари также построены на хеш-таблицах, поэтому всё, что выполняется по ключу, стоит O(1). Любая операция, вынужденная просмотреть все значения, стоит O(n), сколь бы коротко она ни записывалась.

my_dict = {'a': 1, 'b': 2, 'c': 3}

# O(1) операции
my_dict['d'] = 4        # Вставка/обновление
value = my_dict['a']    # Доступ
del my_dict['b']        # Удаление
'a' in my_dict          # Проверка ключа

# O(n) операции
list(my_dict.keys())    # Создание списка ключей
list(my_dict.values())  # Создание списка значений
'value' in my_dict.values()  # Поиск по значениям

Вычисление сложности на практике

Метод 1. Анализ вложенных циклов

# O(n²) — квадратичная сложность
def find_pairs_quadratic(items):
    pairs = []
    for i in range(len(items)):          # O(n)
        for j in range(i + 1, len(items)):  # O(n)
            pairs.append((items[i], items[j]))  # O(1)
    return pairs

# O(n) — линейная сложность
def count_unique(items):
    seen = set()
    for item in items:                   # O(n)
        seen.add(item)                   # O(1)
    return len(seen)                     # O(1)

Сложности вложенных циклов перемножаются, а сложности участков, следующих подряд, складываются: один цикл по всем элементам даёт \( O(n) \), а цикл, вложенный в цикл, \( O(n^2) \). Оценивать следует не отступы, а суть: вызов item not in some_list внутри цикла также является скрытым вложенным циклом, хотя выглядит как одна строка.

Метод 2. Учёт дорогостоящих операций

Вложенные циклы видны сразу, а вызовы библиотечных функций остаются незаметными. sorted стоит \( O(n \log n) \), list(...) и sum(...) обходятся в \( O(n) \), min и max также в \( O(n) \). При разборе функции эти стоимости подставляются и складываются.

def process_data(data):
    result = []
    
    # Сортировка: O(n log n)
    sorted_data = sorted(data)           # O(n log n)
    
    # Поиск каждого элемента: O(n) × O(log n) = O(n log n)
    for target in sorted_data:           # O(n)
        # Бинарный поиск: O(log n)
        # (предположим, что у нас есть реализация)
        index = binary_search(sorted_data, target)
        result.append(index)
    
    return result

# Общая сложность: O(n log n) + O(n log n) = O(n log n)

Метод 3. Практическое измерение

Сложность можно измерить, выполнив операцию на входах разного размера и проследив, как растёт время. Одну быструю операцию измерить нельзя. Вставка в список занимает микросекунды, разрешение таймера и случайный шум того же порядка, и вместо зависимости будут получены помехи. Поэтому операция повторяется много раз, и это обеспечивает timeit.

from timeit import timeit

print(f"{'размер':>8} {'время, мкс':>12} {'отношение':>10}")
prev = None
for size in [10_000, 20_000, 40_000, 80_000, 160_000]:
    setup = f"data = list(range({size}))"
    # вставляем и тут же удаляем, чтобы список не рос за время замера
    t = timeit("data.insert(0, -1); data.pop(0)", setup=setup, number=2000) / 2000
    ratio = f"{t / prev:.1f}x" if prev else "—"
    print(f"{size:8} {t * 1e6:12.1f} {ratio:>10}")
    prev = t
  размер   время, мкс  отношение
   10000          4.4          —
   20000          8.0        1.8x
   40000         15.7        2.0x
   80000         33.1        2.1x
  160000         62.0        1.9x

Линейная сложность: удвоение размера удваивает время. В измерении важны две детали. Подготовка списка вынесена в setup, иначе в измеряемое время попало бы его создание. После каждой вставки элемент удаляется, иначе за две тысячи повторов растущий список исказил бы результат.

Если проделать то же самое с append, окажется, что время вызова не зависит от размера.

Практические правила для выбора коллекций

Область применения списков

Список является выбором по умолчанию. Он необходим, когда важен порядок элементов и доступ по индексу, когда данные добавляются и извлекаются с конца и когда повторяющиеся значения допустимы или осмысленны.

Список плохо приспособлен к работе с началом: insert(0, x) и pop(0), требующие сдвига всего хвоста, стоят \(O(n)\) каждая. На больших данных это превращается в квадратичное время; там требуется дек, рассмотренный в главе про структуры данных и приспособленный к работе с обоими концами.

Область применения множеств

Множество применяется ради проверки принадлежности. Она стоит \(O(1)\) против \(O(n)\) у списка. Фильтрация миллиона событий по списку разрешённых идентификаторов займёт минуты, а через множество — доли секунды. Попутно множество удаляет дубликаты и поддерживает объединение, пересечение и разность.

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

Область применения словарей

Словарь необходим там, где у данных есть естественный ключ, будь то имя канала, номер события или дата измерения. Поиск по ключу стоит \(O(1)\), поэтому словарь заменяет перебор списка везде, где приходится искать запись, у которой поле равно заданному значению. На нём же удобно группировать, отводя под ключ категорию, а под значение список объектов, попадающих в неё.

С версии Python 3.7 словарь сохраняет порядок вставки. Поиск по значению словарь не поддерживает: такой поиск стоит \(O(n)\). Если он требуется часто, имеет смысл завести второй словарь с обратным отображением.

Оптимизация на практике

Если по коллекции многократно выполняется поиск, её лучше преобразовать в множество или словарь один раз до цикла. Построение множества имеет тот же порядок \( O(m) \), что и один линейный поиск, хотя и с большей константой, поэтому оно окупается после нескольких поисков: \( n \) проверок обходятся в \( O(n + m) \) вместо \( O(n \cdot m) \).

# ПЛОХО: O(n²)
def find_common_elements_slow(list1, list2):
    result = []
    for item in list1:           # O(n)
        if item in list2:        # O(m) - линейный поиск!
            result.append(item)
    return result

# ХОРОШО: O(n + m)
def find_common_elements_fast(list1, list2):
    set2 = set(list2)            # O(m) - создание множества
    result = []
    for item in list1:           # O(n)
        if item in set2:         # O(1) - поиск в хеш-таблице!
            result.append(item)
    return result

Некоторые правила сложности

  1. O(1) < O(log n) < O(n) < O(n log n) < O(n²) — эту иерархию необходимо запомнить.
  2. Особого внимания требуют вложенные циклы, дающие O(n²): внутренний цикл часто скрыт за вызовом наподобие x in list.
  3. Структура подбирается под операцию: множества и словари для поиска, списки для последовательного перебора, дек для работы с обоих концов.

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

Задание. Решить задачи с разбором сложности: «Решение задач на Python и анализ сложности».

Функции

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

Слайды к главе. Материал главы изложен также в первых четырёх частях лекции «Функции в Python» (функция как объект, аргументы, области видимости, функциональный стиль), продолжение лекции относится к главе «Декораторы и functools»; слайды лекции доступны на сайте книги и в PDF.

Синтаксис объявления функций

Базовый синтаксис

Имя функции в Python может содержать буквы, цифры и символ подчёркивания _, но не может начинаться с цифры. Буквы допускаются из любого алфавита (PEP 3131), однако принято использовать латиницу и английские слова: PEP 8 требует этого от стандартной библиотеки и рекомендует открытым проектам.

def foo():
    return 42

foo()  # возвращает 42

Оператор return не является обязательным, и функция, обходящаяся без него, возвращает None.

def foo():
    42

print(foo())  # выводит None

Если хотя бы одна ветвь функции возвращает значение, PEP 8 требует единообразия: остальные ветви также завершаются явным return, при необходимости return None, и тогда отсутствие результата видно как предусмотренное.

Документирование функций

Для документирования используются строковые литералы (docstring), расположенные первыми в теле функции.

def foo():
    """I return 42."""
    return 42

Документация доступна через атрибут __doc__ или функцию help().

foo.__doc__  # 'I return 42.'
help(foo)    # показывает документацию

Для научного кода принят формат строк документации NumPy: после краткого описания следуют разделы Parameters (имя, тип, смысл и единицы измерения каждого параметра), Returns (тип и смысл результата), Raises (возбуждаемые исключения) и Examples (вызовы с ожидаемым выводом в формате интерактивной оболочки). Так документированы NumPy, SciPy и Astropy, а генератор документации Sphinx с расширением numpydoc собирает из таких строк справочник.

def kinetic_energy(m, v):
    """Кинетическая энергия тела.

    Parameters
    ----------
    m : float
        Масса, кг.
    v : float
        Скорость, м/с.

    Returns
    -------
    float
        Энергия, Дж.

    Examples
    --------
    >>> kinetic_energy(2.0, 3.0)
    9.0
    >>> kinetic_energy(1.0, 1.0)
    0.5
    """
    return m * v ** 2 / 2

Раздел Examples проверяется автоматически. Модуль doctest находит в строках документации фрагменты, начинающиеся с >>>, выполняет их и сравнивает вывод с записанным. Команда python3 -m doctest phys.py ничего не выводит, если все примеры сошлись, а с ключом -v печатает подробный отчёт. Если в формуле пропущено деление на два, отчёт указывает на расхождение.

Failed example:
    kinetic_energy(2.0, 3.0)
Expected:
    9.0
Got:
    18.0

Пример с известным ответом (тело массой 2 кг при скорости 3 м/с обладает энергией 9 Дж) одновременно документирует функцию и проверяет её формулу. Вывод сравнивается как текст, поэтому для вещественных результатов подбирают точно представимые ответы, такие как 9.0 и 0.5, или печатают округлённое значение. Расхождение документации с кодом обнаруживается при очередном запуске doctest, и его включают в проверки проекта наравне с тестами.

Функция как объект

Инструкция def создаёт объект класса function и связывает его с именем. Функции в Python являются объектами первого класса: функцию связывают со вторым именем, хранят в списке или словаре, передают в аргументе другой функции и возвращают из функции. Интегратору scipy.integrate.quad(f, a, b) безразлично, как устроена переданная функция f, вычисляет ли она формулу или интерполирует таблицу: он лишь вызывает её в выбранных им точках.

Объект функции хранит сведения о ней в атрибутах.

АтрибутСодержимое
__name__, __qualname__имя и полное имя с учётом вложенности
__doc__строка документации
__defaults__, __kwdefaults__значения параметров по умолчанию
__code__объект кода: байт-код, имена локальных переменных
__annotations__аннотации параметров и результата
__closure__ячейки замыкания
__globals__словарь глобальных имён модуля

Аннотации типов

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

def mean(xs: list[float], w: list[float] | None = None) -> float:
    ...

mean.__annotations__
# {'xs': list[float], 'w': list[float] | None, 'return': <class 'float'>}

Синтаксис аннотаций появился в Python 3.0 (PEP 3107), их смысл как подсказок типов закрепил PEP 484 вместе с модулем typing (Python 3.5). С версии 3.9 встроенные коллекции указываются как list[float] без импорта из typing (PEP 585), с 3.10 объединение типов записывается через вертикальную черту: float | None (PEP 604). В Python 3.14 аннотации вычисляются отложенно, при первом обращении (PEP 649 и 749).

Интерпретатор аннотации сохраняет, но не проверяет. Вызов area("2") для функции area(r: float), возводящей радиус в квадрат, выполняется, и ошибка TypeError возникает уже внутри тела, на возведении строки в степень. Соответствие типов проверяют статические анализаторы mypy и pyright, а редакторы кода используют аннотации для подсказок. Единицы измерения аннотации не выражают, их по-прежнему указывают в документации.

Работа с аргументами

Позиционные и именованные аргументы

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

def min_of(x, y):
    return x if x < y else y

min_of(-5, 12)        # -5
min_of(x=-5, y=12)    # -5
min_of(y=12, x=-5)    # -5 (порядок не важен)

Функция названа min_of, а не min, чтобы не закрывать встроенную min (см. правило LEGB ниже). Позиционные аргументы следуют первыми. Запись min_of(x=-5, 12) недопустима и завершается ошибкой SyntaxError: positional argument follows keyword argument: интерпретатор не может определить, к какому параметру относится 12.

Упаковка позиционных аргументов

Произвольное количество аргументов принимает *args: звёздочка перед именем параметра собирает все лишние позиционные аргументы в кортеж под этим именем.

def min_of(*args):
    res = float("inf")
    for arg in args:
        if arg < res:
            res = arg
    return res

min_of(-5, 12, 13)  # -5
min_of()            # inf

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

def min_of(first, *args):
    res = first
    for arg in args:
        if arg < res:
            res = arg
    return res

min_of()  # TypeError: min_of() missing 1 required positional argument: 'first'

Распаковка аргументов

Звёздочка работает и в обратную сторону, разбирая в месте вызова готовую последовательность на отдельные аргументы. Подходит любой итерируемый объект: список, кортеж, множество или генератор.

xs = {-5, 12, 13}
min_of(*xs)           # -5
min_of(*[-5, 12, 13]) # -5
min_of(*(-5, 12, 13)) # -5

С Python 3.5 (PEP 448) звёздочек в одном вызове может быть несколько, и между ними допускаются обычные аргументы: min_of(*xs, 0, *ys).

Параметры после *args и значения по умолчанию

Всё, что записано в объявлении после *args, может быть передано только по имени, поскольку позиции для этих параметров уже исчерпаны. Таким образом оформляются необязательные настройки со значением по умолчанию, которое вызывающая сторона изменяет явно.

def bounded_min(first, *args, lo=float("-inf"), hi=float("inf")):
    res = None
    for arg in (first,) + args:
        if lo <= arg <= hi and (res is None or arg < res):
            res = arg
    return res

bounded_min(-5, 12, 13, lo=0, hi=255)  # 12
bounded_min(-5, lo=0, hi=255)          # None

Из трёх чисел в отрезок [0, 255] попадают 12 и 13, минимальное из них равно 12; значение −5 отброшено как выходящее за нижнюю границу, которую задаёт параметр lo. Если в отрезок не попадает ни одно число, функция возвращает None: любое число в качестве ответа было бы неотличимо от настоящего минимума.

Опасность изменяемых значений по умолчанию

Значение по умолчанию вычисляется один раз, при выполнении инструкции def, а не при каждом вызове, и хранится в атрибуте функции __defaults__. Для числа или строки это незаметно, но изменяемый объект, созданный однажды, окажется общим для всех вызовов и будет накапливать состояние между ними.

def unique(iterable, seen=set()):
    acc = []
    for item in iterable:
        if item not in seen:
            seen.add(item)
            acc.append(item)
    return acc

xs = [1, 1, 2, 3]
unique(xs)  # [1, 2, 3]
unique(xs)  # [] 😱
unique.__defaults__  # ({1, 2, 3},)

Второй вызов на тех же данных возвращает пустой список, поскольку множество seen уже содержит все числа с предыдущего вызова, что и показывает __defaults__. В объявлении указывается None, а настоящее множество создаётся внутри функции при каждом вызове.

def unique(iterable, seen=None):
    seen = set() if seen is None else set(seen)
    acc = []
    for item in iterable:
        if item not in seen:
            seen.add(item)
            acc.append(item)
    return acc

xs = [1, 1, 2, 3]
unique(xs)  # [1, 2, 3]
unique(xs)  # [1, 2, 3] ✅

Переданная коллекция копируется в новое множество, и объект вызывающей стороны не изменяется. Проверка is None надёжнее распространённой записи seen or []: оператор or подставляет значение по умолчанию вместо любого ложного значения. Настройка k = k or 1.0 без сообщения об ошибке превратит переданный коэффициент 0.0 в 1.0, а для массива NumPy из нескольких элементов завершится ошибкой ValueError, поскольку истинность такого массива не определена.

Только именованные параметры

Можно потребовать, чтобы некоторые аргументы передавались только по имени. Одиночная звёздочка без имени отделяет такие параметры, не собирая лишних позиционных аргументов (PEP 3102).

def flatten(xs, *, depth=None):
    pass

flatten([1, [2], 3], depth=1)  # ✅
flatten([1, [2], 3], 1)        # TypeError

Так оформляют настройки расчёта. По вызову integrate(g, 0, 1, 1e-6, 100) не понять, где допуск, а где число узлов, тогда как запись integrate(g, 0, 1, tol=1e-6, n=100) понятна без документации, а ошибочный позиционный вызов завершается TypeError.

Только позиционные параметры

Обратное ограничение задаёт косая черта: параметры, записанные до /, передаются только по позиции (PEP 570, Python 3.8). Имена таких параметров не входят в интерфейс функции и могут меняться без последствий для вызывающего кода; так объявлены многие встроенные функции, например len(obj, /).

def f(a, b, /, c, *args, d=1, **kw):
    return a, b, c, args, d, kw

f(1, 2, 3)                  # (1, 2, 3, (), 1, {})
f(1, 2, 3, 4, 5, d=6, e=7)  # (1, 2, 3, (4, 5), 6, {'e': 7})
f(1, 2, c=3)                # (1, 2, 3, (), 1, {})

def g(a, /):
    return a

g(a=1)  # TypeError: g() got some positional-only arguments passed as keyword arguments: 'a'

Сигнатура f содержит все виды параметров в обязательном порядке: только позиционные, обычные, *args, только именованные, **kw.

Упаковка именованных аргументов

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

def runner(cmd, **kwargs):
    if kwargs.get("verbose", True):
        print("Logging enabled")

runner("mysqld", limit=42)                    # ✅
runner("mysqld", **{"verbose": False})        # ✅
options = {"verbose": False}
runner("mysqld", **options)                   # ✅

Имена args и kwargs являются соглашением, а не требованием языка: значение имеют только звёздочки. Сочетание обеих даёт универсальную обёртку def wrapper(*args, **kwargs): return f(*args, **kwargs), которая передаёт любые аргументы без изменений; на ней построены декораторы.

Распаковка и присваивание

Базовая распаковка

Распаковка работает в любом присваивании. Слева записывается структура, составленная из имён, справа располагается итерируемый объект, и Python раскладывает второе по первому, причём форма слева может повторять вложенность справа.

x, y, z = [1, 2, 3]           # ✅
x, y, z = {1, 2, 3}           # ✅ (но порядок не гарантирован!)
x, y, z = "xyz"               # ✅

# Распаковка вложенных структур
rectangle = (0, 0), (4, 4)
(x1, y1), (x2, y2) = rectangle

С множеством код выполнится, но число, попавшее в x, не определено, поскольку порядка у множества нет.

Расширенная распаковка (Python 3.0+)

Звёздочка слева от знака равенства собирает «всё остальное» в список (PEP 3132). Имя, помеченное ею, может располагаться в начале, в конце или в середине; Python сначала распределяет значения по обычным именам, а остаток, не разобранный ими, достаётся звёздочке.

first, *rest = range(1, 5)           # first=1, rest=[2, 3, 4]
first, *rest, last = range(1, 5)     # first=1, rest=[2, 3], last=4

# Можно использовать в любом месте
*_, (first, *rest) = [range(1, 5)] * 5

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

first, *rest, last = [42]  # ValueError

Строка файла данных вида «время, номер канала, отсчёты» разбирается одним присваиванием: t, ch, *counts = line.split().

Распаковка в цикле for

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

for a, *b in [range(4), range(2)]:
    print(b)
# Вывод:
# [1, 2, 3]
# [1]

Распаковка в литералах (Python 3.5+)

Звёздочки допускаются и внутри литералов коллекций (PEP 448): одна звёздочка раскладывает последовательность, две — словарь.

old, new = [1, 2], (3, 4)
points = [*old, *new]          # [1, 2, 3, 4]

defaults = {"n": 1000, "rule": "simpson"}
user = {"n": 10_000}
config = {**defaults, **user}  # {'n': 10000, 'rule': 'simpson'}

При совпадении ключей сохраняется значение из правого словаря, поэтому пользовательские настройки перекрывают значения по умолчанию; с Python 3.9 то же записывается как defaults | user.

Области видимости (Scopes)

Функции внутри функций

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

def wrapper():
    def identity(x):
        return x
    return identity

f = wrapper()
f(42)  # 42

Правило LEGB

Интерпретатор ищет имя в четырёх областях по порядку и останавливается на первой найденной:

  • Local (локальная), имена, объявленные внутри самой функции.
  • Enclosing (объемлющая), имена объемлющей функции, если эта вложена в другую.
  • Global (глобальная), имена уровня модуля.
  • Built-in (встроенная), имена, доступные всегда, например len, print и min.
min = 42  # global

def f(*args):
    min = 2  # enclosing
    def g():
        min = 4  # local
        print(min)

Здесь три разных min; g печатает свой, локальный. Если из g удалить строку с присваиванием, печататься начнёт min, объявленный в f; если удалить её и оттуда, будет напечатана глобальная 42. Поэтому переменные не называют именами встроенных функций: встроенный min, последнее звено цепочки, оказывается закрытым. Ошибка проявляется далеко от места присваивания: после len = 5 вызов len("abc") завершается ошибкой TypeError: 'int' object is not callable.

Замыкания и позднее связывание

Имя, которое функция не определяет сама, разрешается не при объявлении, а в момент вызова; это называется поздним связыванием. Функция f ниже не знает, откуда возьмётся i, и при каждом вызове обращается к значению, находящемуся под этим именем в текущий момент.

def f():
    print(i)

for i in range(4):
    f()
# Вывод:
# 0
# 1
# 2
# 3

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

def make_adder(step):
    def add(x):
        return x + step      # step взят из объемлющей функции
    return add

add5 = make_adder(5)
print(add5(10))                          # 15
print(add5.__closure__[0].cell_contents) # 5 — значение хранится в ячейке замыкания

Функция make_adder давно завершилась, её локальные переменные должны были исчезнуть, но step сохраняется благодаря add, ссылающейся на него. На этом механизме построены декораторы, рассматриваемые в главе «Декораторы и functools». Той же схемой пользуются для моделей с параметрами: фабрика make_gaussian(mu, sigma) возвращает функцию одной переменной x с закреплёнными параметрами распределения, пригодную для интегрирования и построения графика. Для подгонки curve_fit, наоборот, модель принимает подбираемые параметры аргументами: model(x, mu, sigma).

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

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])          # [2, 2, 2], а не [0, 1, 2]

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])          # [0, 1, 2]

Во втором варианте значение фиксируется аргументом по умолчанию, вычисляемым, в отличие от тела функции, сразу при её создании. Тот же результат дают functools.partial, закрепляющий значения аргументов в момент создания, и фабрика функций, создающая на каждый вызов отдельную ячейку замыкания. При переборе параметров физической модели ловушка не сопровождается сообщением об ошибке: список моделей, построенный в цикле лямбдами и вызываемый после цикла, вычисляет всё с последним значением параметра, и ошибка видна только по совпадению результатов. Лямбда, вызванная в той же итерации, например интеграл quad(lambda x: s * x, 0, 1) внутри цикла по s, вычисляется верно.

Присваивание и области видимости

Чтение имени возможно из любой внешней области, а присваивание всегда создаёт локальную переменную. Решение принимается при компиляции функции и распространяется на всё её тело, поэтому min += 1 завершается ошибкой не на присваивании, а на попытке прочитать локальную min, ещё не получившую значения; начиная с Python 3.11 сообщение звучит как cannot access local variable 'min' where it is not associated with a value, в прежних версиях — local variable 'min' referenced before assignment.

min = 42

def f():
    min += 1  # UnboundLocalError!
    return min

Оператор global

Чтобы изменять глобальную переменную, а не создавать одноимённую локальную, о намерении необходимо заявить явно.

min = 42

def f():
    global min
    min += 1
    return min

f()  # 43
f()  # 44

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

Оператор nonlocal (Python 3+)

nonlocal выполняет то же самое, но для объемлющей функции, а не для модуля (PEP 3104). Именно он позволяет вложенной функции присвоить новое значение переменной той функции, в которую она вложена; на этом основаны счётчики и накопители внутри декораторов.

def cell(value=None):
    def get():
        return value
    def set(update):
        nonlocal value
        value = update
    return get, set

get, set = cell()
set(42)
get()  # 42

Две функции разделяют одну переменную value, существующую столько же, сколько они сами. Интерпретатор хранит её в ячейке, на которую ссылается кортеж get.__closure__; помимо get и set, к ней обращаются только через этот служебный атрибут, предназначенный для отладки: get.__closure__[0].cell_contents.

Функциональное программирование

Анонимные функции (lambda)

Функцию в одну строку, передаваемую в sorted или map, задаёт lambda. Её тело представляет собой одно выражение, и его значение становится результатом без return.

lambda arguments: expression

# Эквивалентно:
def <lambda>(arguments):
    return expression

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

lambda x: x ** 2
lambda foo, *args, bar=None, **kwargs: 42

Как только логика перестаёт умещаться в одну строку, ей дают имя через def. PEP 8 рекомендует всегда использовать def вместо присваивания лямбды имени: запись f = lambda x: ... ничем не лучше def f(x): ..., но у такой функции нет строки документации, а в трассировке ошибки вместо имени стоит <lambda>.

Функции map, filter, zip

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

map применяет функцию к каждому элементу, поступающему из источника.

list(map(lambda x: x % 7, [1, 9, 16, -1, 2, 5]))  # [1, 2, 2, 6, 2, 5]

filter оставляет только элементы, для которых функция вернула истину. Если вместо функции передать None, фильтр отсеет всё ложное: нули, пустые коллекции и пустые строки.

list(filter(lambda x: x % 2 != 0, range(10)))  # [1, 3, 5, 7, 9]

# С None - оставляет только truthy значения
xs = [0, None, [], {}, set(), "", 42]
list(filter(None, xs))  # [42]

zip проходит по нескольким последовательностям параллельно и на каждом шаге возвращает кортеж, составленный из их элементов. Останавливается он по самой короткой, и лишние элементы длинных последовательностей теряются; с Python 3.10 параметр strict=True превращает несовпадение длин в ошибку ValueError.

list(zip("abc", range(3), [42j, 42j, 42j]))
# [('a', 0, 42j), ('b', 1, 42j), ('c', 2, 42j)]

Функцию в аргументе принимают и другие встроенные функции. sorted, min и max упорядочивают и выбирают элементы по функции-ключу key, а any и all прекращают перебор, как только результат известен. Модуль operator содержит готовые ключи: itemgetter выбирает элемент по индексу или ключу словаря, attrgetter — атрибут объекта.

from operator import itemgetter

events = [{"id": 1, "energy": 3.2}, {"id": 2, "energy": 7.9}]
max(events, key=itemgetter("energy"))  # {'id': 2, 'energy': 7.9}

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

Включения коллекций

То же самое записывается через включения. Форма ниже читается как «взять x ** 2 для каждого x из range(10), если x нечётно». Второй пример показывает ту же операцию через map и filter. Два for подряд разворачивают вложенную структуру в плоский список; их порядок такой же, как во вложенных циклах.

[x ** 2 for x in range(10) if x % 2 == 1]  # [1, 9, 25, 49, 81]

# Эквивалент с map/filter:
list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 1, range(10))))

# Вложенные включения:
nested = [range(5), range(8, 10)]
[x for xs in nested for x in xs]  # [0, 1, 2, 3, 4, 8, 9]

Тот же синтаксис применяется для множеств и словарей, отличаются только скобки. Фигурные скобки с одним выражением дают множество, и в первом примере совпавшие между собой остатки объединились; а с парой, разделённой двоеточием, получается словарь.

{x % 7 for x in [1, 9, 16, -1, 2, 5]}  # {1, 2, 5, 6}

date = {"year": 2014, "month": "September", "day": ""}
{k: v for k, v in date.items() if v}  # {'year': 2014, 'month': 'September'}

{x: x ** 2 for x in range(4)}  # {0: 0, 1: 1, 2: 4, 3: 9}

Во втором примере условие if v отбрасывает записи с пустым значением, и здесь действует та же истинность объектов, применяемая и в filter(None, ...).

map остаётся уместен с готовой функцией: list(map(float, fields)) короче включения [float(s) for s in fields]. Выражение-генератор в круглых скобках вычисляет значения по одному и не строит промежуточный список: sum(x * x for x in data) суммирует квадраты, не выделяя память под список квадратов. Генераторы рассматриваются в главе «Итераторы, генераторы и корутины».

Чистые функции

Чистой называют функцию, результат которой зависит только от аргументов и которая не имеет побочных эффектов: не изменяет аргументы, глобальные переменные и файлы. Такую функцию проще проверять, поскольку тест не требует подготовки окружения; её результат можно запомнить и не вычислять повторно, как делает functools.lru_cache; её вызовы выполняются параллельно без блокировок.

total = 0.0

def add_sample(x):     # нечистая: изменяет глобальное состояние
    global total
    total += x

def mean(xs):          # чистая
    return sum(xs) / len(xs)

Программа целиком из чистых функций не состоит: чтение данных и запись результатов являются побочными эффектами. Расчётное ядро удобно держать чистым, а ввод и вывод сосредоточить на его границе. Для моделирования со случайными числами это означает передачу генератора аргументом: функция, использующая глобальный генератор, даёт разные результаты при каждом запуске, а генератор numpy.random.default_rng(42), созданный с фиксированным зерном и переданный в функцию, делает расчёт воспроизводимым. Чистой такая функция не становится, поскольку каждый вызов меняет состояние генератора, но её результат определяется аргументами, включая генератор.

Параметры модели для интеграторов

Интеграторы, оптимизаторы и решатели SciPy ожидают функцию одной переменной (или вектора), а модель обычно зависит ещё и от параметров. Закрепить параметры можно несколькими способами.

import math
from functools import partial
from scipy.integrate import quad

def gauss(x, mu, sigma):
    return math.exp(-(x - mu) ** 2 / (2 * sigma ** 2))

quad(gauss, -5, 5, args=(0.0, 1.0))             # параметр args
quad(lambda x: gauss(x, 0.0, 1.0), -5, 5)       # лямбда
quad(partial(gauss, mu=0.0, sigma=1.0), -5, 5)  # partial

Многие функции SciPy (quad, minimize, solve_ivp) принимают дополнительные аргументы в параметре args и передают их вызываемой функции после основных переменных. Лямбда подставляет параметры при каждом вызове: литералы — как записаны, имена — по значению на момент вызова. functools.partial закрепляет значения при создании и, в отличие от лямбды, сериализуется модулем pickle, если сериализуемы исходная функция и закреплённые аргументы; это требуется при распараллеливании через multiprocessing, и сама функция при этом определяется в импортируемом модуле (см. главу «Многопоточность и GIL»). Замыкание или фабрика функций уместны, когда модель содержит собственную логику или состояние, но pickle их не сериализует.

Стоимость вызова функции

Вызов функции в Python имеет собственную стоимость: интерпретатор создаёт кадр, связывает аргументы и возвращает результат. Замеры python3 -m timeit на Python 3.12 для списка xs из 100 тысяч чисел и функции sq = lambda x: x * x показывают порядок этих затрат.

Операция над 100 тысячами чиселВремя
[x * x for x in xs]1,64 мс
[sq(x) for x in xs]2,66 мс
list(map(sq, xs))3,10 мс
цикл for с накоплением t += x1,17 мс
sum(xs)0,25 мс

Вызов функции добавил около 10 нс на элемент: 2,66 мс против 1,64 мс. Соотношение map и включения с вызовом зависит от версии интерпретатора: в других версиях map бывает быстрее. Встроенная sum в четыре-пять раз быстрее явного цикла, поскольку её цикл выполняется в функции на C, без исполнения байт-кода для каждого элемента. Отсюда не следует, что функций нужно избегать: 10 нс заметны только в цикле по миллионам элементов. Участок, на который приходится основное время работы, переносят во встроенные функции или в векторные операции NumPy, обрабатывающие весь массив одним вызовом; подробно это рассматривается в главе «Оптимизация средствами самого Python».

PEP 8 и стиль кода

Код читают чаще, чем пишут, и единый стиль экономит время всем участникам. PEP 8 является официальным соглашением о том, как выглядит код на Python; его соблюдение проверяют линтеры, например pycodestyle и ruff.

Базовые рекомендации

  • 4 пробела для отступов, задающих структуру блока
  • Максимум 79 символов в строке кода (72 символа для комментариев и строк документации)
  • lower_case_with_underscores для переменных и функций
  • UPPER_CASE_WITH_UNDERSCORES для констант

Выражения и операторы

Пробелы вокруг операторов расставляются по приоритету, чтобы структура выражения была видна визуально. Тело if переносится на новую строку, поскольку однострочную запись труднее заметить при беглом чтении. Отрицание принадлежности записывается оператором not in, а не через not ... in: PEP 8 формулирует это правило для is not, а линтеры распространяют его и на in. Сравнение принято записывать в естественном порядке, с проверяемой переменной слева от значения. PEP 8 прямо этого не оговаривает, однако обратный порядок, так называемые условия Йоды, в Python не нужен: в C он защищает от случайного присваивания if (x = 5), а в Python присваивание = внутри условия является синтаксической ошибкой.

exp = -1.05
value = (item_value / item_count) * offset / exp
hypot2 = x*x + y*y

if bar:
    x += 1

if method == 'md5':
    pass

if key not in d:
    pass

Ниже приведены те же четыре конструкции в нерекомендуемой записи.

value = ( item_value/item_count )*offset/exp

if bar: x += 1

if 'md5' == method:
    pass

if not key in d:
    pass

Функции

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

def something_useful(arg, **options):
    """One-line summary.

    Optional longer description.
    """
    pass

Вокруг знака = у значения по умолчанию пробелы не ставятся, если параметр не аннотирован, и ставятся, если аннотирован; стрелка перед типом результата также окружается пробелами.

def scale(x, factor=2.0): ...
def scale(x: float, factor: float = 2.0) -> float: ...

Резюме

  • Функция является объектом: её передают в аргументах, хранят и возвращают; аннотации документируют типы, но не проверяются
  • Функции принимают произвольное количество позиционных (*args) и именованных (**kwargs) аргументов; параметры до / передаются только по позиции, после * — только по имени
  • Синтаксис распаковки работает в вызовах функций, присваивании, циклах и литералах коллекций
  • Имена ищутся по правилу LEGB: локальная, объемлющая, глобальная, встроенная область
  • Присваивание создаёт локальную переменную; поведение, заданное по умолчанию, изменяется через global и nonlocal
  • Значение по умолчанию вычисляется один раз, при выполнении def, поэтому изменяемым оно быть не должно
  • Для функций, созданных в цикле, значение фиксируют параметром по умолчанию, partial или фабрикой
  • Python поддерживает элементы функционального программирования: lambda, map, filter, zip, включения коллекций
  • Вызов функции стоит порядка 10 нс, что заметно только в циклах по миллионам элементов
  • PEP 8 описывает, как должен выглядеть читаемый код, и правила, записанные в нём, проверяются линтерами

Итераторы, генераторы и корутины

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

Эти механизмы позволяют обрабатывать файлы, не помещающиеся в память, и описывать бесконечные последовательности; на генераторах построена вся асинхронность Python — asyncio рассматривается в главе, посвящённой асинхронности.

Итераторы

Любая коллекция, встроенная в язык (списки, словари, множества, строки, файлы), является итерируемой.

Итератор представляет собой объект с методом __next__, который при каждом вызове возвращает следующий элемент, а по исчерпании элементов возбуждает StopIteration. Итерируемый объект обладает методом __iter__, возвращающим итератор. Список является итерируемым, но не итератором, поэтому по нему можно пройти дважды, а по итератору — лишь один раз.

Рассмотрим собственный аналог range, в котором обе роли разделены явно. Контейнер Range способен только выдать итератор, а сам обход осуществляется классом RangeIterator.

class Range:
    def __init__(self, stop_value: int):
        self.stop_value = stop_value - 1

    def __iter__(self):
        return RangeIterator(self)

class RangeIterator:
    def __init__(self, container):
        self.container = container
        self.current = -1          # состояние обхода живёт здесь

    def __iter__(self):
        return self                # итератор обязан быть итерируемым

    def __next__(self):
        if self.current < self.container.stop_value:
            self.current += 1
            return self.current
        raise StopIteration

Поле current хранится в итераторе, а не в контейнере, поэтому каждый новый for начинает счёт заново, а по одному объекту Range(5) можно пройти произвольное число раз. Если поместить счётчик в контейнер, второй проход окажется пустым. Без метода __iter__ у самого итератора объект не является итерируемым, и цикл for по нему невозможен.

Чаще обе роли совмещают в одном классе, где __iter__ возвращает self: кода при этом меньше, однако второй проход начнётся там, где закончился первый.

class Range2:
    def __init__(self, stop_value: int):
        self.current = -1
        self.stop_value = stop_value - 1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.current < self.stop_value:
            self.current += 1
            return self.current
        raise StopIteration

Обычный цикл for получает итератор через iter, вызывает next в цикле и перехватывает StopIteration, чтобы остановиться.

iterable = Range2(5)
iterator = iter(iterable)

while True:
    try:
        value = next(iterator)
        print(value)
    except StopIteration:
        break

Генераторы

Генераторы работают на принципе запоминания контекста, сохраняемого ключевым словом yield.

Написание двух классов ради простого перебора является избыточным. Функция с yield при вызове не выполняет тело, а возвращает генератор. Тело начинает выполняться при next, доходит до ближайшего yield, отдаёт значение и приостанавливается, сохранив все локальные переменные и место остановки.

В примере ниже первые два вызова возвращают значения, а третий доходит до return и возбуждает StopIteration, поместив в него возвращённое значение.

def simple_generator():
    yield 1
    yield 2
    return 3

gen = simple_generator()
print(next(gen))  # 1
print(next(gen))  # 2
print(next(gen))  # StopIteration: 3

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

gen_exp = (x for x in range(100000))
print(gen_exp)  # <generator object <genexpr> at 0x...>

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

def read_events(path):
    """Отдаёт события журнала по одному, не читая файл целиком."""
    with open(path) as f:
        for line in f:
            if not line.strip() or line.startswith('#'):
                continue           # пустые строки и комментарии пропускаем
            t, channel, value = line.split()
            yield float(t), channel, float(value)

for t, channel, value in read_events('run042.log'):
    if channel == 'BPM01':
        print(t, value)

Обратный случай — бесконечная последовательность. Генератор вычисляет значения, пока они запрашиваются, а границу устанавливает читающая сторона: itertools.islice берёт первые несколько значений и освобождает генератор.

import itertools

def clock(dt):
    """Бесконечная последовательность моментов времени с шагом dt."""
    t = 0.0
    while True:
        yield t
        t += dt

for t in itertools.islice(clock(0.02), 5):
    print(round(t, 3))  # 0.0, 0.02, 0.04, 0.06, 0.08 — по одному в строке

Когда генератор лишь передаёт элементы другого источника, цикл с yield записывается короче через yield from. Это не только синтаксическое упрощение: конструкция пробрасывает во вложенный генератор всё, что передаётся через send и throw, а наружу отдаёт его return, что необходимо при вложенных генераторах.

numbers = [1, 2, 3]

# Стандартный подход
def func():
    for item in numbers:
        yield item

# Упрощенный подход
def func():
    yield from numbers

Корутины

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

Генератор только отдаёт значения наружу, а корутина способна и принимать их. Выражение deposit = (yield) приостанавливает функцию и ожидает, пока извне будет вызван send. Таким образом, получается диалог с функцией, сохраняющей между запросами своё состояние.

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

import math
from collections.abc import Generator

def cash_return_coro(percent: float,
                     years: int) -> Generator[float | None, float, None]:
    value = math.pow(1 + percent / 100, years)
    while True:
        try:
            deposit = (yield)
            yield round(deposit * value, 2)
        except GeneratorExit:
            print('Выход из корутины')
            raise

# Использование
coro = cash_return_coro(5, 5)
next(coro)
values = [1000, 2000, 5000, 10000, 100000]
for item in values:
    print(coro.send(item))
    next(coro)
coro.close()

За один оборот цикла корутина приостанавливается дважды: сначала на yield, принимающем вклад, затем на yield, отдающем результат. Поэтому send возвращает вычисленное число, а следующий за ним next доводит корутину до очередного приёма.

Дальнейшее изучение

Таким образом, итератор отдаёт элементы по одному, генератор приостанавливает функцию на yield и продолжает с места, запомненного при остановке, а корутина способна ещё и принимать значения через send().

На этом построена асинхронность в Python: цикл событий представляет собой планировщик, который переключается между приостановленными генераторами и отдаёт процессорное время той задаче, которая готова работать, пока остальные ожидают ответа от сети или диска. Внутреннее устройство этого механизма и его использование через asyncio рассматриваются в главе про асинхронность в разделе «Ускорение расчётов».

Декораторы и модуль functools

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

В настоящей главе рассматривается устройство декоратора изнутри, способы передачи ему аргументов, возникающие при этом ловушки, а также готовые декораторы из модуля functools.

Слайды к главе. Материал главы изложен также в пятой и шестой частях лекции «Функции в Python» (декораторы и модуль functools) с демонстрациями в интерактивной оболочке; слайды лекции доступны на сайте книги и в PDF.

Синтаксис декораторов

Декоратор представляет собой вызываемый объект, чаще всего функцию, принимающий другую функцию и возвращающий новую (или любой другой объект). Символ @ перед определением является синтаксическим сахаром, не добавляющим возможностей, но выносящим имя декоратора на видное место перед функцией.

@decorator
def foo(x):
    return 42

Эквивалентно:

def foo(x):
    return 42

foo = decorator(foo)

После применения декоратора имя foo ссылается на результат вызова decorator(foo). Эквивалентность практическая: выражение после @ вычисляется до создания функции, а имя foo с исходной функцией не связывается ни в какой момент. Декоратор выполняется один раз, вместе с инструкцией def, то есть при импорте или запуске модуля, а не при каждом вызове функции. Синтаксис @ появился в Python 2.4 (PEP 318); с версии 3.9 после @ допускается любое выражение, а не только имя или вызов (PEP 614).

В научном коде декораторы встречаются постоянно: @numba.njit компилирует функцию в машинный код, @functools.lru_cache запоминает результаты, @pytest.fixture объявляет подготовку данных для тестов.

Устройство простого декоратора

Рассмотрим устройство на примере декоратора trace, печатающего каждый вызов функции. Декоратор получает исходную функцию в func и определяет новую, inner; та принимает аргументы в самом общем виде *args, **kwargs, применимом к любой функции; inner выполняет свою работу, передаёт управление оригиналу и возвращает полученный результат. Наружу декоратор возвращает inner.

def trace(func):
    def inner(*args, **kwargs):
        print(func.__name__, args, kwargs)
        return func(*args, **kwargs)
    return inner

func остаётся доступной внутри inner даже после завершения trace: это замыкание, рассмотренное в главе про функции.

Применим его.

@trace
def identity(x):
    "I do nothing useful."
    return x

identity(42)  # Вывод: identity (42,) {} → 42

Проблема атрибутов функции

Побочный эффект заключается в том, что после декорирования имя identity ссылается уже не на исходную функцию, а на inner со всеми её атрибутами. Документация, имя и сигнатура берутся теперь от обёртки, и help не покажет ничего полезного.

identity.__name__  # 'inner'
help(identity)     # Справка о inner, а не identity

Помимо неудобства при отладке, это нарушает работу всего, что опирается на имена функций, от автоматически собираемой документации до фреймворков, ищущих обработчики по имени.

Спасение через functools.wraps

functools.wraps является декоратором, копирующим в обёртку метаданные оригинала: __module__, __name__, __qualname__, __doc__ и __annotations__ (с Python 3.12 также __type_params__). Словарь атрибутов оригинала __dict__ дописывается в словарь обёртки, а ссылка на исходную функцию помещается в __wrapped__. При написании собственного декоратора над внутренней функцией всегда ставится @functools.wraps(func).

import functools

def trace(func):
    @functools.wraps(func)
    def inner(*args, **kwargs):
        print(func.__name__, args, kwargs)
        return func(*args, **kwargs)
    return inner

Теперь identity.__name__ и help(identity) возвращают данные оригинала, а inspect.signature(identity), следуя по ссылке __wrapped__, показывает сигнатуру (x) вместо (*args, **kwargs). Та же ссылка позволяет вызвать исходную функцию в обход декоратора, а inspect.unwrap снимает всю цепочку обёрток. Ещё одно следствие касается сериализации: без wraps декорированную функцию нельзя передать модулю pickle, поскольку обёртка называется trace.<locals>.inner, а с wraps можно, что важно для передачи функций в процессы multiprocessing.

Декораторы с аргументами

Иногда декоратор необходимо настроить, например указать trace, куда выводить. Здесь появляется третий уровень вложенности. Запись @trace(sys.stderr) означает два действия: сначала вызывается trace(sys.stderr), и только полученный результат применяется к функции как декоратор, а значит, trace возвращает декоратор, который уже возвращает обёртку.

import sys


def trace(handle):
    def decorator(func):
        @functools.wraps(func)
        def inner(*args, **kwargs):
            print(func.__name__, args, kwargs, file=handle)
            return func(*args, **kwargs)
        return inner
    return decorator

@trace(sys.stderr)
def identity(x):
    return x

Эквивалентно:

decorator = trace(sys.stderr)
identity = decorator(identity)

Декораторы с опциональными аргументами

Два декоратора, приведённых выше, несовместимы по способу применения: один записывается как @trace, другой только как @trace(...). Чтобы работали оба варианта, декоратор анализирует переданное ему значение. Если получена func, декоратор применён без скобок и необходимо сразу возвращать обёртку. Если ничего не получено, вызов выполнен со скобками, и вернуть необходимо декоратор, применяемый следующим шагом.

def trace(func=None, *, handle=sys.stdout):
    if func is None:
        return lambda func: trace(func, handle=handle)
    if not callable(func):
        raise TypeError("настройки trace передаются по имени: trace(handle=...)")

    @functools.wraps(func)
    def inner(*args, **kwargs):
        print(func.__name__, args, kwargs, file=handle)
        return func(*args, **kwargs)
    return inner

Использование:

@trace
def foo(): ...

@trace(handle=sys.stderr)
def bar(): ...

Звёздочка делает handle параметром, передаваемым только по имени, и настройка записывается как @trace(handle=sys.stderr). От ошибочной записи @trace(sys.stderr) звёздочка не защищает: файловый объект, переданный позиционно, всё равно попадает в func. Без проверки декоратор принял бы файл за декорируемую функцию и вернул бы обёртку, а уже при декорировании, когда эта обёртка получит identity вместо аргументов, возникла бы ошибка AttributeError с сообщением, не указывающим на причину. Проверка callable(func) превращает эту ситуацию в понятный TypeError.

Значение sys.stdout по умолчанию вычисляется при выполнении def, поэтому перенаправление contextlib.redirect_stdout, выполненное позже, печать trace не затрагивает: она уходит в исходный поток. Если это важно, по умолчанию указывают handle=None, а sys.stdout подставляют при вызове.

Полезные декораторы на практике

Декоратор @timethis для замера времени

Декоратор для измерения времени выполняет функцию n_iter раз и печатает лучший результат, а не средний. Среднее искажается любой случайной помехой наподобие переключения задач операционной системой, а минимум показывает, на что код способен в отсутствие помех. По той же причине в стандартной библиотеке предусмотрен timeit.repeat, возвращающий список измерений, из которого берётся min. Сам timeit.timeit минимума не вычисляет и возвращает суммарное время всех запусков, которое приходится самостоятельно делить на их число.

import time

def timethis(func=None, *, n_iter=100):
    if func is None:
        return lambda func: timethis(func, n_iter=n_iter)

    @functools.wraps(func)
    def inner(*args, **kwargs):
        print(func.__name__, end=" ... ")
        acc = float("inf")
        for i in range(n_iter):
            tick = time.perf_counter()
            result = func(*args, **kwargs)
            acc = min(acc, time.perf_counter() - tick)
        print(acc)
        return result
    return inner

Декоратор @once для однократного вызова

Существует работа, которая выполняется один раз, сколько бы раз функция ни вызывалась: чтение конфигурации, установка соединения, разбор большого справочника. @once запоминает результат первого вызова и в дальнейшем возвращает сохранённое значение.

Хранить состояние декоратор мог бы в замыкании, но здесь оно размещено на inner: функция также является объектом и допускает произвольные атрибуты. Флаг при этом виден извне, и some_func.called покажет, вызывалась функция или нет.

def once(func):
    @functools.wraps(func)
    def inner(*args, **kwargs):
        if not inner.called:
            inner.result = func(*args, **kwargs)
            inner.called = True
        return inner.result
    inner.called = False
    return inner

Для функции без аргументов то же поведение даёт готовый @functools.cache (Python 3.9), рассматриваемый ниже.

Декоратор @memoized и мемоизация

Мемоизация представляет собой запоминание результата отдельно для каждого набора аргументов. Функция ведёт словарь «аргументы → результат» и ничего не вычисляет повторно, если ответ уже сохранён. Ключом служит кортеж из позиционных аргументов и отсортированных именованных; сортировка необходима, чтобы f(a=1, b=2) и f(b=2, a=1) попали в одну ячейку.

def memoized(func):
    cache = {}
    mark = object()  # разделитель позиционных и именованных аргументов

    @functools.wraps(func)
    def inner(*args, **kwargs):
        key = args + (mark,) + tuple(sorted(kwargs.items()))
        if key not in cache:
            cache[key] = func(*args, **kwargs)
        return cache[key]
    return inner

Между двумя частями ключа стоит особый объект-разделитель mark. Без него вызовы f(1, ("a", 2)) и f(1, a=2) дали бы одинаковый ключ (1, ("a", 2)) и получили бы один результат на двоих; так же, отдельным маркером, разделяет части ключа и functools.lru_cache.

Ключом словаря может быть только хешируемый объект, поэтому такая мемоизация приведёт к ошибке, если в функцию передать список, словарь или множество. Ради универсальности аргументы сериализуют, например через pickle, но тогда теряется скорость. На практике проще применять мемоизацию только к функциям с простыми аргументами и использовать готовый functools.lru_cache, рассматриваемый ниже.

Декоратор @deprecated для устаревших функций

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

import warnings

def deprecated(func):
    @functools.wraps(func)
    def inner(*args, **kwargs):
        warnings.warn(
            f"{func.__name__} устарела и будет удалена",
            category=DeprecationWarning,
            stacklevel=2,      # показать строку вызывающего кода, а не эту
        )
        return func(*args, **kwargs)
    return inner

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

Аргумент stacklevel=2 переводит указатель со строки внутри декоратора на ту, где функция вызвана, и относит предупреждение к модулю вызывающего кода. Без него место вызова по предупреждению найти невозможно, а при фильтре по умолчанию предупреждение не выводится вовсе.

DeprecationWarning по умолчанию скрыт везде, кроме кода модуля __main__ (PEP 565, Python 3.7). Чтобы увидеть его в собственном коде, интерпретатор запускают с ключом -W default или добавляют строку warnings.simplefilter("always", DeprecationWarning); ключ -W error::DeprecationWarning превращает такие предупреждения в исключения, что удобно в тестах.

С Python 3.13 стандартная библиотека содержит готовый декоратор warnings.deprecated (PEP 702). Он выдаёт то же предупреждение при вызове и вдобавок сообщает об устаревании статическим анализаторам типов.

from warnings import deprecated

@deprecated("old_api устарела, её заменяет new_api")
def old_api(): ...

Контрактное программирование с декораторами

Контрактное программирование состоит в том, чтобы описывать в самой функции условия, требуемые от входа, и обещания, даваемые на выходе. Проверка, добавленная декоратором рядом с объявлением, видна сразу, а тело функции свободно от проверок. Запишем два декоратора, @pre для предусловия и @post для постусловия. Оба принимают проверяющую функцию и текст сообщения: pre проверяет аргументы до вызова, post — результат после.

def pre(cond, message):
    def decorator(func):
        @functools.wraps(func)
        def inner(*args, **kwargs):
            assert cond(*args, **kwargs), message
            return func(*args, **kwargs)
        return inner
    return decorator

def post(cond, message):
    def decorator(func):
        @functools.wraps(func)
        def inner(*args, **kwargs):
            result = func(*args, **kwargs)
            assert cond(result), message
            return result
        return inner
    return decorator

Использование:

import math


@pre(lambda x: 0 < x < 1, "argument must be a fraction")
@post(lambda r: r < 0, "log of a fraction must be negative")
def log_fraction(x):
    return math.log(x)

Предусловие на физический смысл аргумента (доля в интервале от 0 до 1, положительная масса, температура выше абсолютного нуля) останавливает расчёт на входе функции с понятным сообщением, а не порождает nan, обнаруживаемый только в итоговом графике.

assert полностью исключается из кода, если интерпретатор запущен с ключом -O. Для контрактов внутри собственной программы это подходит: при отладке проверка выполняется, а в рабочем режиме не выполняется, и остаётся лишь вызов обёртки. Чтобы убрать и его, декоратор возвращает функцию без обёртки: return inner if __debug__ else func. Данные, поступившие извне, проверять через assert нельзя, там необходим явный raise.

Цепочки декораторов

Декораторов на одной функции может быть несколько, и их порядок важен.

@deco1
@deco2
def foo(): ...

Применяются они снизу вверх, ближайший к def — первым.

foo = deco1(deco2(foo))

Сами выражения после @ вычисляются сверху вниз, что заметно у декораторов с аргументами, а полученные декораторы применяются снизу вверх. При вызове управление передаётся в обратную сторону: сначала во внешний deco1, затем во внутренний deco2 и только потом в саму функцию. Поэтому @functools.lru_cache располагают выше проверок, чтобы попадание в кеш не расходовало время на валидацию, а @timethis располагают ниже, чтобы он измерял саму функцию, а не работу декораторов, применённых сверху.

Декораторы стандартной библиотеки

Большая часть декораторов, встречающихся на практике, уже есть в стандартной библиотеке.

ДекораторНазначение
@functools.lru_cache, @functools.cacheмемоизация
@property, @classmethod, @staticmethodметоды классов
@dataclasses.dataclassкласс данных
@contextlib.contextmanagerменеджер контекста из генератора
@atexit.registerвызов при завершении интерпретатора
@warnings.deprecated("…") (Python 3.13)пометка устаревших функций
@typing.overloadварианты сигнатуры для анализаторов типов

Декоратор применяется и к классу (PEP 3129): он получает класс и возвращает класс. Так устроен @dataclass, дописывающий по аннотациям полей методы __init__, __repr__ и __eq__; классы рассматриваются в главе «Классы».

Модуль functools

Рекурсия и предел глубины

Мемоизация особенно полезна для рекурсивных функций. Каждый рекурсивный вызов создаёт кадр стека со своими аргументами и локальными переменными, и кадры накапливаются, пока рекурсия не дойдёт до базового случая. Интерпретатор ограничивает глубину: sys.getrecursionlimit() по умолчанию возвращает 1000, и более глубокая рекурсия завершается ошибкой RecursionError.

def depth(n):
    return 0 if n == 0 else 1 + depth(n - 1)

depth(900)   # 900
depth(5000)  # RecursionError: maximum recursion depth exceeded

Предел поднимается функцией sys.setrecursionlimit, но это откладывает проблему, а не решает её. Хвостовую рекурсию Python не оптимизирует; это сознательное решение, сохраняющее полную трассировку ошибок. Поэтому глубокая рекурсия переписывается циклом, при необходимости со своим стеком в списке. Предел на практике достигается там, где глубина рекурсии равна размеру данных: рекурсивный поиск связного кластера на решётке (перколяция, кластерный алгоритм Вольфа для модели Изинга) или обход графа в глубину на решётке 100 × 100 легко превышает 1000 уровней, а итеративный обход со стеком из списка такого ограничения не имеет. Сама рекурсия рассмотрена в главе «Рекурсия и сортировки», обход графа в глубину — в главе «Графы».

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

def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

Мемоизация с ограничением через lru_cache

Готовая замена рассмотренному выше @memoized. Кеш ограничен по размеру, и при переполнении из него вытесняется запись, к которой дольше всего не обращались, — так расшифровывается LRU, least recently used. Благодаря ограничению кеш не занимает всю память в долго работающей программе.

@functools.lru_cache(maxsize=128)
def expensive_func(x):
    return x * x

С Python 3.8 декоратор записывается и без скобок, @functools.lru_cache, с размером по умолчанию 128. Значение maxsize=None снимает ограничение, и так поступают только для функций с заведомо небольшим числом различных аргументов; с Python 3.9 то же записывается короче: @functools.cache. Метод cache_info() показывает число попаданий и промахов, cache_clear() очищает кеш, а параметр typed=True хранит аргументы разных типов, например 1 и 1.0, раздельно. Если попаданий почти нет, кеш только расходует память.

Ограничение то же, что у самописного варианта: аргументы обязаны быть хешируемыми, поэтому функцию от массива NumPy так не кешируют. В отличие от @memoized, именованные аргументы в ключе не сортируются, и вызовы f(a=1, b=2) и f(b=2, a=1) могут занять две записи кеша.

Кеш превращает экспоненциальную рекурсию для чисел Фибоначчи в линейную, поскольку каждое значение вычисляется однажды. Предел глубины мемоизация при этом не снимает: fib(2000) с пустым кешем завершается RecursionError, поэтому таблицу значений заполняют по возрастанию n или считают циклом.

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

fib(32)           # 2178309
fib.cache_info()  # CacheInfo(hits=30, misses=33, maxsize=None, currsize=33)

Без кеша вычисление fib(32) на Python 3.12 заняло около 0,15 с, с кешем — около 70 мкс.

Частичное применение через partial

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

f = functools.partial(sorted, key=lambda x: x[1])
f([('a', 4), ('b', 2)])  # [('b', 2), ('a', 4)]

int2 = functools.partial(int, base=2)
int2("1010")    # 10
int2.keywords   # {'base': 2}

У partial три преимущества перед лямбдой. Значения аргументов закрепляются в момент создания, поэтому ловушки позднего связывания нет. Объект partial сериализуется модулем pickle, если сериализуемы исходная функция и закреплённые аргументы, и потому передаётся в процессы через multiprocessing.Pool.map, где лямбда вызывает ошибку сериализации; исходная функция при этом определяется в импортируемом модуле, а не в ячейке блокнота (см. главу «Многопоточность и GIL»). Атрибуты func, args и keywords показывают, что именно закреплено. Интегратор quad ожидает функцию одной переменной, а функция Планка зависит от частоты и температуры: partial(planck, T=5800.0) закрепляет температуру.

С Python 3.14 объект functools.Placeholder позволяет закрепить не только первые позиционные аргументы: на его месте остаётся пропуск, заполняемый при вызове.

Обобщённые функции и singledispatch

Когда функция должна вести себя по-разному для разных типов, обычно применяется цепочка из isinstance. Она работает, но её приходится править всякий раз, когда появляется новый тип. С singledispatch базовая функция объявляется один раз, а реализации для конкретных типов регистрируются отдельно, в том числе из другого модуля.

@functools.singledispatch
def pack(obj):
    raise TypeError(f"Unsupported type: {type(obj)}")

@pack.register(int)
def _(obj):
    return b"I" + hex(obj).encode("ascii")

@pack.register(list)
def _(obj):
    return b"L" + b",".join(map(pack, obj))

Выбор реализации выполняется по типу первого аргумента, отсюда «single» в названии, причём с учётом наследования: для True выбирается реализация для int, поскольку bool является подклассом int. Зарегистрированные функции названы _, поскольку обращаться к ним напрямую не требуется: вызываться всегда будет базовая pack.

С Python 3.7 тип указывается и аннотацией первого параметра регистрируемой функции, без аргумента у register; с Python 3.11 в такой аннотации допускается объединение типов. Для методов классов предусмотрен functools.singledispatchmethod (Python 3.8).

@pack.register
def _(obj: str):
    return b"S" + obj.encode("utf-8")

Свёртка последовательности через reduce

reduce сворачивает последовательность в одно значение, беря первые два элемента, применяя к ним функцию, а к полученному результату применяя её же со следующим элементом, и так до конца. Необязательный третий аргумент задаёт начальное значение, которое ставится перед элементами и возвращается для пустой последовательности; без него свёртка пустой последовательности завершается ошибкой TypeError.

functools.reduce(lambda acc, x: acc * x, [1, 2, 3, 4])  # 24

В функциональных языках это базовая конструкция, а в Python она применяется редко: явный цикл читается лучше, а для наиболее частых свёрток предусмотрены готовые sum, min, max, any, all и появившаяся в Python 3.8 math.prod, для которой math.prod([1, 2, 3, 4]) даёт те же 24. Встроенная sum для чисел с плавающей точкой с Python 3.12 выполняет компенсированное суммирование и точнее свёртки через сложение: sum([0.1] * 10) даёт 1.0, а functools.reduce(operator.add, [0.1] * 10) — 0.9999999999999999.

Другие средства functools

Остальные средства модуля относятся к классам, обёрткам и сортировке. total_ordering достраивает все операции сравнения класса по __eq__ и одному из методов __lt__, __le__, __gt__, __ge__; cached_property (Python 3.8) вычисляет атрибут при первом обращении и запоминает значение; partialmethod и singledispatchmethod являются вариантами partial и singledispatch для методов; cmp_to_key переводит функцию сравнения старого стиля в функцию-ключ.

Заключение

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

Таким образом, на практике потребуется следующее:

  • Собственный декоратор всегда начинается с @functools.wraps(func) над внутренней функцией.
  • Декоратор выполняется один раз, вместе с инструкцией def, а обёртка — при каждом вызове функции.
  • Декоратор с аргументами представляет собой функцию, возвращающую декоратор; уровней вложенности становится три.
  • Декоратор с необязательными настройками принимает их только по имени и проверяет, что первый аргумент является вызываемым объектом: звёздочка сама по себе не защищает от записи @deco(x).
  • В цепочке декораторов ближайший к def применяется первым, а при вызове срабатывает последним.
  • Прежде чем писать собственную реализацию, следует обратиться к functools: cache, lru_cache, partial, singledispatch и total_ordering покрывают большую часть потребностей.

Дополнительные материалы:

Классы

Класс связывает данные с операциями, определёнными над ними, и вместо разрозненных переменных и обрабатывающих их функций получается один объект, отвечающий за своё состояние. От Java и C++ Python отличается тем, что не скрывает механику: ссылка на экземпляр передаётся методу явным первым аргументом self, а не появляется в теле сама собой.

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

Базовые понятия

Определение класса

Метод __init__ вызывается сразу после создания объекта и задаёт начальное состояние; всё, записанное в self, становится атрибутом конкретного экземпляра, которым и пользуются остальные методы.

class Counter:
    """I count. That is all."""
    
    def __init__(self, initial=0):  # конструктор
        self.value = initial        # запись атрибута

    def increment(self):
        self.value += 1

    def get(self):
        return self.value          # чтение атрибута

# Использование
c = Counter(42)
c.increment()
print(c.get())  # 43

Название «конструктор», закрепившееся за __init__, строго говоря неверно, поскольку объект к этому моменту уже создан. Создаёт его метод __new__, а __init__ только заполняет заготовку, полученную от него. Разница проявляется при наследовании от неизменяемого типа наподобие int или tuple, у которого изменять уже нечего, и вся работа переносится в __new__.

Специфика Python

Первым аргументом любого метода следует self, то есть экземпляр, на котором метод вызван, причём указывать его приходится вручную, в соответствии с правилом «явное лучше неявного».

Модификаторов доступа наподобие private и protected в Python нет, вместо них действует соглашение об именовании.

  • public_attribute составляет часть публичного интерфейса, и его использование допустимо;
  • _internal_attribute является внутренним, извне к нему не обращаются;
  • __private_attribute означает то же самое, но с дополнительной защитой от случайного переопределения, выполненного в подклассе.

Только за двойным подчёркиванием стоит механизм, встроенный в язык. Интерпретатор переписывает имя, помеченное таким образом, добавляя к нему имя класса, вследствие чего __private внутри класса Counter превращается в _Counter__private. Механизм предназначен для предотвращения коллизий, а не для сокрытия данных, так что базовый класс и подкласс, объявив по атрибуту __cache, не столкнутся. Доступ к «приватному» атрибуту при этом сохраняется через полное имя.

Атрибуты классов и экземпляров

Атрибуты в Python бывают двух видов, и путаница между ними приводит к трудноуловимым ошибкам.

Атрибуты экземпляра

Атрибут экземпляра появляется в результате присваивания, выполненного к self, и принадлежит только этому объекту. Создавать его не обязательно в __init__, поскольку присвоить атрибут можно и извне, уже созданному объекту.

class Noop:
    def __init__(self):
        self.some_attribute = 42

noop = Noop()
noop.other_attribute = 100500  # динамическое добавление

Эта свобода следует из того, что атрибуты хранятся в обычном словаре, и у неё есть оборотная сторона: опечатка в имени не вызовет ошибки. Строка noop.som_attribute = 0 без предупреждения создаст новый атрибут, а старый останется прежним.

Атрибуты класса

Атрибут, объявленный в теле класса, принадлежит самому классу и является одним на все его экземпляры. Это подходит для общих сущностей: настроек, констант, реестра созданных объектов.

class Counter:
    all_counters = []  # атрибут класса
    
    def __init__(self, initial=0):
        Counter.all_counters.append(self)
        self.value = initial

# Также можно добавлять атрибуты после определения
Counter.some_other_attribute = 42

Ловушка та же, что с изменяемыми аргументами по умолчанию: список all_counters создаётся один раз, при чтении определения класса. Если бы он был задуман как список, заведённый у каждого счётчика отдельно, все экземпляры разделяли бы один. Здесь общий список необходим, однако при объявлении изменяемого атрибута класса следует проверять, должен ли он быть общим.

Словарь атрибутов

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

noop = Noop()
noop.some_attribute = 42
print(noop.__dict__)  # {'some_attribute': 42}
print(vars(noop))     # альтернативный способ

# Динамическое управление атрибутами
noop.__dict__["dynamic_attr"] = "value"

Встроенная vars() с аргументом возвращает __dict__ объекта, а без аргументов — словарь текущего локального пространства имён. Прямая запись в __dict__ требуется редко, но оказывается полезной при имени атрибута, вычисляемом во время выполнения.

__slots__ для оптимизации

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

__slots__ фиксирует набор допустимых атрибутов заранее, благодаря чему интерпретатор обходится без словаря и размещает значения по ячейкам, отведённым при создании объекта.

class Noop:
    __slots__ = ["some_attribute"]  # фиксирует набор атрибутов
    
    def __init__(self):
        self.some_attribute = 42

# Экономит память, но запрещает добавление новых атрибутов

На объекте с парой полей это экономит около 40 % памяти: 96 байт на экземпляр без слотов против 56 со слотами. Скорость доступа не меняется: измерение, приведённое в главе про оптимизацию, показывает, что разница находится в пределах погрешности. Взамен теряется динамика: добавить атрибут, отсутствующий в списке, не удастся, будет получено AttributeError, и перестают работать слабые ссылки, если __weakref__ не указан в слотах явно. Начинать с __slots__ нецелесообразно; его добавляют тогда, когда профилировщик покажет, что память расходуется именно здесь.

Методы

Связанные и несвязанные методы

Метод представляет собой функцию, хранящуюся в атрибуте класса, и получить её можно двумя путями. Через класс получается обычная функция, требующая экземпляр, переданный явно. Через экземпляр получается связанный метод, в котором объект уже подставлен в self, и передавать его повторно не требуется.

class SomeClass:
    def do_something(self):
        print("Doing something.")

# Несвязанный метод
method = SomeClass.do_something
instance = SomeClass()
method(instance)  # нужно явно передать экземпляр

# Связанный метод
bound_method = instance.do_something
bound_method()  # self уже привязан

Связанный метод представляет собой полноценный объект: его помещают в переменную, передают в map или регистрируют как обработчик события, а ссылку на свой экземпляр он хранит в себе. В Python 2 обращение через класс давало особый тип, проверявший тип первого аргумента и не пропускавший посторонние объекты; в Python 3 это обычная функция.

Свойства (Properties)

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

Это обеспечивает декоратор @property: метод, помещённый под ним, вызывается при чтении, метод под @имя.setter при записи, метод под @имя.deleter при удалении. Извне всё выглядит как обычное присваивание.

class BigDataModel:
    def __init__(self):
        self._params = []
    
    @property
    def params(self):
        return self._params
    
    @params.setter
    def params(self, new_params):
        assert all(p > 0 for p in new_params)
        self._params = new_params
    
    @params.deleter
    def params(self):
        del self._params

model = BigDataModel()
model.params = [0.1, 0.5, 0.4]
print(model.params)  # [0.1, 0.5, 0.4]

Присваивание model.params = [...] выглядит как запись в обычное поле, но проходит через assert в сеттере. Интерфейс не меняется, а поведение за ним может меняться произвольно. Внутренне свойства построены на дескрипторах, рассматриваемых в конце главы.

Наследование

Базовое наследование

Подкласс получает всё, объявленное у родителя, и добавляет своё. Если метода нет у подкласса, интерпретатор поднимается по цепочке наследования и ищет его выше. OtherCounter не определяет __init__, поэтому при создании работает родительский.

class Counter:
    def __init__(self, initial=0):
        self.value = initial

class OtherCounter(Counter):
    def get(self):
        return self.value

oc = OtherCounter()  # вызывает Counter.__init__
print(oc.get())      # вызывает OtherCounter.get

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

Перегрузка методов и super()

Чаще подкласс не заменяет метод родителя целиком, а надстраивает над ним своё и передаёт управление выше, а ссылку на реализацию, унаследованную от родителя, предоставляет super().

class Counter:
    all_counters = []
    
    def __init__(self, initial=0):
        self.__class__.all_counters.append(self)
        self.value = initial
    
    def increment(self):
        self.value += 1
    
    def get(self):
        return self.value

class OtherCounter(Counter):
    def __init__(self, initial=0):
        self.initial = initial
        super().__init__(initial)  # вызов родительского конструктора

Запись Counter.__init__(self, initial) вместо super() является распространённой ошибкой, и разница проявится при множественном наследовании. super() обращается не к родителю, а к следующему классу в порядке разрешения методов, зависящем от того, как класс использовался в дальнейшем. Имя родителя, указанное явно, эту цепочку разрывает.

Множественное наследование

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

class A:
    def f(self):
        print("A.f")

class B:
    def f(self):
        print("B.f")

class C(A, B):
    pass

print(C.mro())  # порядок разрешения методов
C().f()         # A.f (согласно MRO)

Здесь f находится в A, поскольку A стоит первым в списке родителей, перечисленных в скобках. Порядок, выстраиваемый здесь, вычисляет алгоритм C3, заимствованный из языка Dylan, и он обеспечивает три гарантии. Подкласс всегда следует раньше своих родителей; порядок, в котором родители перечислены, сохраняется; каждый класс иерархии встречается в списке единожды. Если требования противоречат друг другу, Python откажется создавать класс.

Классы-примеси

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

import threading

class ThreadSafeMixin:
    def __init__(self, *args, **kwargs):
        self._lock = threading.Lock()
        super().__init__(*args, **kwargs)
    
    def get_lock(self):
        return self._lock
    
    def increment(self):
        with self.get_lock():
            super().increment()
    
    def get(self):
        with self.get_lock():
            return super().get()

class ThreadSafeCounter(ThreadSafeMixin, Counter):
    pass

Всё это работает благодаря super() и порядку разрешения методов. Примесь стоит перед Counter в списке родителей, поэтому её increment перехватывает вызов первым, а super().increment(), расположенный внутри, передаётся дальше по цепочке, в настоящий счётчик. ThreadSafeMixin не наследуется от Counter и ничего о нём не знает: следующий класс в MRO определяется в момент, когда собирается ThreadSafeCounter. Аналогичным образом устроена значительная часть Django.

Декораторы классов

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

import functools

def singleton(cls):
    instance = None
    
    @functools.wraps(cls)
    def inner(*args, **kwargs):
        nonlocal instance
        if instance is None:
            instance = cls(*args, **kwargs)
        return instance
    
    return inner

@singleton
class Noop:
    "I do nothing at all."

print(id(Noop()) == id(Noop()))  # True

Декораторы классов появились в Python 3.0 и потеснили метаклассы: многое, ради чего раньше писался метакласс, теперь выполняется одной функцией. Наиболее известным примером является @dataclass, добавляющий классу __init__, __repr__ и __eq__ по полям, объявленным в теле.

Магические методы

Магическими (или дандер-) методами называют методы, чьи имена окружены двойными подчёркиваниями. Напрямую их не вызывают, поскольку за каждым стоит синтаксическая конструкция или встроенная функция. Определение нужного метода позволяет объекту вести себя как встроенный, то есть складываться, сравниваться, печататься, попадать в множество.

Управление атрибутами

Первая группа перехватывает работу с атрибутами. __getattr__ вызывается только тогда, когда атрибут не найден обычным путём, и в нём подставляется значение вместо AttributeError. __setattr__ и __delattr__, напротив, перехватывают любую запись и любое удаление.

class Noop:
    def __getattr__(self, name):
        # Вызывается при доступе к несуществующему атрибуту
        return f"Attribute {name} doesn't exist"
    
    def __setattr__(self, name, value):
        # Вызывается при установке любого атрибута
        super().__setattr__(name, value)
    
    def __delattr__(self, name):
        # Вызывается при удалении атрибута
        super().__delattr__(name)

noop = Noop()
print(noop.non_existent)  # "Attribute non_existent doesn't exist"

Обращение к super() внутри __setattr__ обязательно: self.name = value вызовет метод рекурсивно. По той же причине не рекомендуется переопределять __getattribute__, перехватывающий доступ ко всем атрибутам, а не только к отсутствующим.

Операторы сравнения

Вторая группа отвечает за сравнение, и методов в ней шесть, но записывать все шесть не требуется: декоратор @functools.total_ordering достроит остальные по __eq__ и любому одному из методов, задающих упорядочивание.

import functools

@functools.total_ordering
class Counter:
    def __init__(self, value):
        self.value = value
    
    def __eq__(self, other):
        return self.value == other.value
    
    def __lt__(self, other):
        return self.value < other.value

c1, c2 = Counter(1), Counter(2)
print(c1 < c2)   # True
print(c1 >= c2)  # False (автоматически из __lt__ и __eq__)

Определение __eq__ отключает хеширование: объект не попадёт ни в множество, ни в ключи словаря. Причина та же, что у изменяемых коллекций: равные объекты обязаны иметь равные хеши, а вычислять их самостоятельно Python не берётся. Если объект должен оставаться хешируемым, __hash__ необходимо определить явно.

Строковое представление

Третья группа отвечает за текстовое представление. __repr__ предназначен для программиста: он должен быть однозначным и в идеале выглядеть как выражение, воссоздающее объект. __str__ предназначен для человека, просматривающего вывод программы. Если __str__ не определён, Python использует __repr__, поэтому начинают именно с него.

class Counter:
    def __init__(self, initial=0):
        self.value = initial
    
    def __repr__(self):
        return f"Counter({self.value})"
    
    def __str__(self):
        return f"Counted to {self.value}"
    
    def __format__(self, format_spec):
        return self.value.__format__(format_spec)

c = Counter(42)
print(repr(c))           # Counter(42)
print(str(c))            # Counted to 42
print(f"{c:b}")          # 101010 (бинарное представление)

Третий метод, __format__, отвечает за подстановку в f-строку со спецификатором, а здесь он передаёт полученный спецификатор дальше в целое число, поэтому {c:b} печатает значение счётчика в двоичном виде.

__repr__ имеет смысл определять у любого класса, существующего дольше пары строк, иначе при отладке объекты печатаются как <__main__.Counter object at 0x7f3a…>.

Другие полезные магические методы

__call__ делает экземпляр вызываемым, и на этом основаны декораторы, реализованные классами, и объекты, хранящие настройки между вызовами. __bool__ задаёт, что объект означает в условии if; без него Python обращается к __len__, и объект нулевой длины оказывается ложным, а объект без обоих методов истинен всегда. __hash__ определяет, как объект попадает в множества и словари.

class Identity:
    def __call__(self, x):
        # Позволяет вызывать экземпляры как функции
        return x
    
    def __bool__(self):
        # Определяет поведение в булевом контексте
        return True
    
    def __hash__(self):
        # Используется для хеширования в словарях и множествах
        return hash(id(self))

identity = Identity()
print(identity(42))  # 42

__call__ стирает границу между функцией и объектом: вызов identity(42) выглядит как обычный, хотя слева стоит экземпляр класса. Поэтому всюду, где документация требует передать функцию, подходит любой объект с __call__.

Дескрипторы

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

Базовый дескриптор

Дескриптор представляет собой класс с определёнными __get__, __set__ или __delete__. Экземпляр такого класса, помещённый в атрибут класса, с этого момента перехватывает любое обращение к атрибуту.

class NonNegative:
    def __get__(self, instance, owner):
        # magically_get_value
        pass
    
    def __set__(self, instance, value):
        assert value >= 0, "non-negative value required"
        # magically_set_value
    
    def __delete__(self, instance):
        # magically_delete_value
        pass

class VerySafe:
    x = NonNegative()
    y = NonNegative()

very_safe = VerySafe()
very_safe.x = 42      # OK
very_safe.x = -42     # AssertionError

Проверка написана один раз, защищённых ею полей два, и добавление третьего требует одной строки.

Через дескрипторы в Python реализовано почти всё, связанное с атрибутами: и @property, и @staticmethod, и @classmethod, и сама привязка методов к экземпляру. Знание протокола напрямую требуется нечасто, но он объясняет, почему функция, хранящаяся в классе, при обращении через экземпляр оказывается связанным методом.

Хранение данных в дескрипторах

Хранить значение в самом дескрипторе нельзя: он один на весь класс, а экземпляров много, и значения, записанные ими, затирали бы друг друга; правильным местом является словарь самого экземпляра.

class Proxy:
    def __init__(self, label):
        self.label = label
    
    def __get__(self, instance, owner):
        return instance.__dict__[self.label]
    
    def __set__(self, instance, value):
        instance.__dict__[self.label] = value
    
    def __delete__(self, instance):
        del instance.__dict__[self.label]

class Something:
    attr = Proxy("attr")

some = Something()
some.attr = 42
print(some.attr)  # 42

У каждого экземпляра значение своё, и существует оно столько же, сколько сам экземпляр. Второй вариант, общий словарь «экземпляр → значение», заведённый внутри дескриптора, хуже по двум причинам: он требует, чтобы объекты были хешируемыми, и удерживает на них ссылки, не позволяя сборщику мусора их удалить.

Имя поля приходится дублировать в Proxy("attr"); начиная с Python 3.6 от этого избавляет метод __set_name__, вызываемый интерпретатором при создании класса и сообщающий дескриптору имя, под которым тот записан.

Встроенные дескрипторы

Три реализации ниже сокращены, но верно передают устройство, заложенное в интерпретатор.

# @property реализован через дескрипторы
class property:
    def __init__(self, get=None, set=None, delete=None):
        self._get = get
        self._set = set
        self._delete = delete
    
    def __get__(self, instance, owner):
        if self._get is None:
            raise AttributeError("unreadable attribute")
        return self._get(instance)

# @staticmethod и @classmethod тоже используют дескрипторы
class staticmethod:
    def __init__(self, method):
        self.__method = method
    
    def __get__(self, instance, owner):
        return self.__method

class classmethod:
    def __init__(self, method):
        self.__method = method
    
    def __get__(self, instance, owner):
        if owner is None:
            owner = type(instance)
        return self.__method.__get__(owner, type(owner))

Вся разница между @staticmethod и @classmethod заключается в их __get__. Статический метод возвращает исходную функцию без привязки, поэтому self в нём отсутствует. Метод класса привязывает функцию к классу, и первым аргументом приходит сам класс. Аналогично, только к экземпляру, привязываются обычные методы.

Метаклассы

Поскольку класс является объектом, у него также есть тип. Типом обычного класса служит type, а метаклассом называют класс, унаследованный от type, экземплярами которого оказываются другие классы. Если обычный класс описывает, как устроены его объекты, то метакласс описывает, как устроены сами классы.

Требуются они редко: почти всё, ради чего раньше писались метаклассы, сегодня выполняется декоратором, применённым к классу, или методом __init_subclass__. Однако на метаклассах построены ORM и системы валидации данных, где поля, объявленные в классе, описываются декларативно.

Базовый метакласс

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

from collections import OrderedDict

class Meta(type):
    def __new__(metacls, name, bases, clsdict):
        print(f"Creating class {name}")
        cls = super().__new__(metacls, name, bases, clsdict)
        return cls
    
    @classmethod
    def __prepare__(metacls, name, bases):
        # Может вернуть нестандартный mapping для clsdict
        return OrderedDict()

class Something(metaclass=Meta):
    attr = "foo"
    other_attr = "bar"

Второй метод, __prepare__, выбирает, в чём накапливать тело класса, пока оно читается. Возвращённый им словарь и попадёт затем в clsdict, а значит, подставив сюда собственный тип отображения, можно запомнить порядок объявления атрибутов или проверять на месте каждое выполненное присваивание. Так и работают ORM, где порядок полей определяет порядок колонок, созданных в таблице. Начиная с версии 3.7 тело класса и без __prepare__ накапливается в обычном dict, сохраняющем порядок вставки, так что OrderedDict здесь требуется не ради порядка, а ради move_to_end и прочих возможностей, отсутствующих у простого словаря.

Создание классов через type()

Оператор class является удобной записью вызова type с тремя аргументами: именем, родителями и словарём атрибутов.

# Эквивалентно class Something: attr = 42
name, bases, attrs = "Something", (), {"attr": 42}
Something = type(name, bases, attrs)

some = Something()
print(some.attr)  # 42

Модуль abc для абстрактных базовых классов

Абстрактный базовый класс описывает интерфейс, не реализуя его, и перечисляет методы, обязательные для наследника. Пока хотя бы один из них не реализован, класс считается абстрактным, и создать его экземпляр не удастся, причём ошибка возникнет сразу, а не при первом обращении к недостающему методу.

import abc

class Iterable(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def __iter__(self):
        pass

class Something(Iterable):
    pass

# Something()  # TypeError: Can't instantiate abstract class

Отдельной конструкции interface, как в Java, в Python нет: интерфейсы описываются обычными классами с заданным метаклассом ABCMeta. Проще всего наследоваться от готового abc.ABC, выполняющего то же самое.

Модуль collections.abc

Абстрактные классы для самих коллекций уже написаны и находятся в collections.abc, и это те же Container, Iterable, Sequence и Mapping, рассмотренные в главе про коллекции. Наследование даёт две выгоды: интерпретатор сначала проверит, что реализовано всё обязательное, а затем сам добавит остальное. Реализация у MutableMapping пяти методов даёт готовые get, pop, setdefault, update, items, keys и values.

from collections import deque
from collections.abc import MutableMapping

class MemorizingDict(MutableMapping):
    def __init__(self, *args, **kwargs):
        self._data = dict(*args, **kwargs)
        self._history = deque(maxlen=10)
    
    def __getitem__(self, key):
        return self._data[key]
    
    def __setitem__(self, key, value):
        self._history.append(key)
        self._data[key] = value
    
    def __delitem__(self, key):
        del self._data[key]
    
    def __iter__(self):
        return iter(self._data)
    
    def __len__(self):
        return len(self._data)
    
    def get_history(self):
        return self._history

Написано пять обязательных методов и один собственный, а в результате получается полноценный словарь, запоминающий десять последних записанных ключей. Он пройдёт isinstance(d, MutableMapping) и будет принят любой библиотекой, ожидающей отображение.

Заключение

Большая часть этого инструментария в повседневной практике не потребуется: метаклассы за студенческую практику, скорее всего, не встретятся ни разу, дескрипторы встретятся в лучшем случае однажды. Рассматриваются они потому, что почти всё, кажущееся в Python магией, оказывается одним из этих механизмов.

В повседневной работе достаточно немногого:

  • Класс необходим там, где есть состояние, которое требуется поддерживать согласованным. Если состояния нет, достаточно функции.
  • Атрибут остаётся атрибутом; свойство добавляется тогда, когда за ним потребовалась логика.
  • __repr__ определяется сразу.
  • В переопределённых методах родитель вызывается через super(), а не по имени класса.
  • Прежде чем строить иерархию наследования, целесообразно проверить, не решается ли задача композицией: объект, содержащий другой объект, почти всегда проще объекта, унаследованного от него.

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

Роль алгоритмов в работе физика

Устройство Python было рассмотрено в предыдущем разделе, однако за ним стоит фундамент, переживающий и смену языка, и смену моды, — базовые знания computer science.

Алгоритмическая грамотность представляет собой умение заранее оценить, выполним ли расчёт за приемлемое время. Миллион событий детектора, обрабатываемый алгоритмом за \(O(n^2)\), приводит к триллиону операций, и NumPy, написанный на C, в этом случае не поможет. Удачно выбранная структура данных сокращает время счёта на порядки, причём чаще, чем низкоуровневая оптимизация.

В настоящем разделе рассматриваются:

  • Введение в алгоритмы, где вводится понятие алгоритма и разбирается оценка сложности, не привязанная к конкретной машине;
  • Основные структуры данных, где разобраны массивы, списки, стеки, очереди и деки и показано, как они представлены в Python;
  • Рекурсия и сортировки, где разобраны сортировка слиянием, быстрая сортировка и сортировка подсчётом;
  • Хеш-функции, объясняющие, почему словарь ищет за \(O(1)\) и как устроены хеш-таблицы;
  • Деревья, иерархические структуры, дополненные деревьями поиска и кучами;
  • Графы, их представления, обходы и классические задачи, решаемые обходом.

Материал построен на разборе задач: сначала излагается необходимая теория, затем приводятся задачи с решениями, расписанными на Python. Рекомендуется сперва попытаться решить задачу самостоятельно и только после этого обращаться к разбору.

Введение в алгоритмы

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

Линейный поиск

Дан массив целых чисел длины \(N\). Требуется найти в нём заданное число \(x\) и вернуть его индекс. Если \(x\) в массиве не встречается, необходимо вернуть -1.

def find_element(numbers, x):
    for i in range(len(numbers)):
        if numbers[i] == x:
            return i
    return -1

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

Вычислительная сложность представляет собой количество элементарных операций, совершаемых алгоритмом. Под входными данными понимается всё, поданное алгоритму на вход; в рассматриваемой задаче это массив numbers и число x, а размер входа примерно равен N. Линейная зависимость описывается формулой \(y = kx+b\).

Бинарный поиск

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

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

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

def binary_search(arr, x):
    mid, low, high = 0, 0, len(arr) - 1

    while low <= high:
        mid = (high + low) // 2

        if arr[mid] < x:
            low = mid + 1
        elif arr[mid] > x:
            high = mid - 1
        else:
            return mid

    return -1

Линейный vs Бинарный поиск

Запустим оба поиска на одних и тех же данных и рассмотрим, как меняется измеренное время работы с ростом массива.

Будем искать элемент, стоящий в самом конце, что даёт худший случай для линейного поиска:

Размер массиваЛинейный поискБинарный поиск
100.2 мкс0.14 мкс
1000.9 мкс0.23 мкс
1 00011.2 мкс0.37 мкс
10 000122.1 мкс0.50 мкс
100 0001281.6 мкс0.59 мкс
1 000 00012340.3 мкс0.69 мкс

При увеличении массива в 10 раз линейный поиск замедляется также примерно вдесятеро: 11 мкс, 122 мкс, 1.3 мс, 12 мс. Бинарный же поиск за пять порядков роста замедлился всего впятеро, с 0.14 до 0.69 мкс.

Именно так выглядит разница между \(O(n)\) и \(O(\log n)\) на практике. На миллионе элементов бинарный поиск быстрее линейного почти в восемнадцать тысяч раз, и чем больше накопленных данных, тем этот разрыв шире.

Измерение может быть воспроизведено самостоятельно:

from timeit import timeit

n = 1_000_000
setup = f"from __main__ import find_element, binary_search; arr = list(range({n})); x = {n} - 1"
print(timeit("find_element(arr, x)", setup=setup, number=1000) / 1000)
print(timeit("binary_search(arr, x)", setup=setup, number=1000) / 1000)

Сложность алгоритма. O-нотация

Для того чтобы говорить о скорости алгоритма, не привязываясь к конкретной машине, была введена O-нотация — запись, показывающая, как растёт число операций с ростом входных данных. В ней не учитываются константы и коэффициенты, то есть если в алгоритме совершается \(5\cdot n+3\) операций, его сложность будет \(O(n)\). В асимптотической оценке отброшены и значения констант при \(n\). Отброшенные константы также имеют значение, однако изменить применимость алгоритма на практике они не могут.

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

  • Квадратичная зависимость \(O(n^2)\).
  • Кубическая зависимость \(O(n^3)\).
  • Экспоненциальная зависимость \(O(2^n)\).
  • Константная зависимость \(O(1)\). Встречаются и случаи, когда время работы алгоритма не зависит от размера входных данных, а число выполняемых операций остаётся постоянным.

Оценка времени исполнения

Современный процессор выполняет около 2.5 миллиардов действий, называемых «инструкциями», в секунду, или, иначе говоря, «тактовая частота процессора = 2.5 ГГц».

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

Предположим, что обработка каждой итерации цикла занимает один такт процессора. Возьмём \(10^9\) итераций, тогда:

$$ t = \frac{10^9 [итер] \cdot 1 [\frac{такт}{итер}]}{2.5 \cdot 10^9 [\frac{такт}{с}]} = 0.4 [с] $$

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

Даже при желании узнать константы в оценке временной сложности сделать это не удалось бы: пришлось бы учесть слишком много нюансов. Это ещё одна причина пользоваться O-нотацией и опускать константы, что не мешает уменьшать константу, сокращая число действий.

Чтобы оценить, во сколько раз ошибается такая оценка, измерим, сколько секунд займёт пустой цикл, выполненный миллиард раз.

# Python
import time

time_start = time.time()
i = 0
while i < 1000000000:
    # Do nothing
    i += 1

time_finish = time.time()
time_span = time_finish - time_start
print(time_span, 'seconds')
97.01491403579712 seconds
// CPP
#include <chrono>
#include <iostream>

int main() {
    using namespace std::chrono;
    auto time_start = high_resolution_clock::now();
    int i = 0;
    while (i < 1000000000) {
        // Do nothing
        ++i;
    }
    auto time_finish = high_resolution_clock::now();
    auto time_span = duration_cast<duration<double>>(time_finish - time_start);

    std::cout << time_span.count() << " seconds\n";
  return 0;
}

Каждая итерация цикла состоит из трёх действий: прибавить единицу, проверить условие и переместиться обратно к началу цикла. Три миллиарда команд должны занимать приблизительно секунду, и опыт это подтверждает, но только при оговорённых условиях. При сборке без оптимизации (c++ -O0 loop.cpp) получается примерно секунда. С -O2 компилятор обнаруживает, что i далее нигде не используется, удаляет цикл целиком и печатает почти ноль; чтобы этого не происходило, счётчик объявляют volatile. Это классическая ловушка микробенчмарков на компилируемых языках. Аналогичная программа на Python выполняется около 100 секунд, то есть в сто раз медленнее. Так происходит, поскольку код, написанный на языках низкого уровня, почти один к одному транслируется в инструкции процессору.

Пространственная сложность алгоритма

Вычислительная (или временнáя) сложность показывает, насколько экономно алгоритм расходует процессорное время. Второй характеристикой является объём оперативной памяти. Программы, не помещающиеся в неё, зависают и мешают работе остальных, а часто и не доводят вычисление до конца.

Если программе не хватает оперативной памяти, она пытается использовать файл подкачки, или swap-раздел (от англ. swap, «подкачивать»), расположенный во внешней памяти (на жёстком диске или на SSD-диске). При недостатке оперативной памяти программа начинает перекладывать данные из небольшой, но быстрой оперативной памяти на большой, но медленный диск и по мере необходимости возвращать требуемые данные с диска в память. Это очень медленный процесс. Из-за него программы, вышедшие за пределы оперативной памяти, начинают тратить много процессорного времени и зависать, даже если у них небольшая временна́я сложность. Более того, они могут вытеснять из памяти другие программы, зависающие следом. Если запущенные на компьютере программы заняли не только всю оперативную память, но и файл подкачки, то какая-то из программ завершится аварийно.

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

Взаимосвязь пространственной и временной сложности алгоритма

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

Тестирование программы

Код, разделённый на функции, удобно тестировать: для каждой выделенной функции пишутся юнит-тесты.

Необходимо продумать краевые случаи — входные данные, на которых программы отказывают чаще всего:

тип тестастрокичисланабор данных/массивы
самый маленький тестпустая строка0 или минимальное отрицательное число для задачипустой набор данных
самый большой тестсамая длинная строка по условиюсамое большое положительное число для задачинабор данных максимального размера
особые случаистроки со строчными и заглавными буквами
строки с кириллицей и латиницей
положительное/отрицательное число
ноль
четное/нечет число
вещественное число
набор с одинаковыми элементами
отсортированные наборы
неотсортированные наборы

Задача

Дана строка (UTF-8). Требуется найти самый часто встречающийся в ней символ.

Решение 1

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

s = 'ababa'
ans = ''
anscnt = 0
for i in range(len(s)):
    nowcnt = 0
    for j in range(len(s)):
        if s[i] == s[j]:
            nowcnt += 1
    if nowcnt > anscnt:
        ans = s[i]
        anscnt = nowcnt
print(ans)
a

Решение 2

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

s = 'ababa'
ans = ''
anscnt = 0
for now in set(s):
    nowcnt = 0
    for j in range(len(s)):
        if now == s[j]:
            nowcnt += 1
    if nowcnt > anscnt:
        ans = now
        anscnt = nowcnt
print(ans)
a

Решение 3

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

s = 'ababa'
ans = ''
anscnt = 0
symcnt = {}
for now in s:
    if now not in symcnt:
        symcnt[now] = 0
    symcnt[now] += 1
    if symcnt[now] > anscnt:
        ans = now
        anscnt = symcnt[now]
print(ans)
a

Сравнение сложности

Обозначим через \(N\) длину строки, а через \(K\) количество различных символов.

РешениеВремяДополнительная память
1\(O(N^2)\)\(O(1)\)
2\(O(NK)\)\(O(K)\)
3\(O(N)\)\(O(K)\)

Память здесь учитывается дополнительная, сверх самого входного массива: в противном случае все три решения одинаково требовали бы \(O(N)\), и сравнивать было бы нечего. Первое не хранит ничего, кроме счётчиков; второе строит множество различных значений, встреченных в строке, а третье — словарь тех же значений, вследствие чего оба и требуют \(O(K)\).

Значение алгоритмов для программиста

  • Знание алгоритмов способствует эффективному решению задач, встречающихся в работе
  • Дополнительная готовность к собеседованию
  • Общая когнитивная тренировка

Свойства алгоритмов

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

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

Лайфхаки. Оценка сложности

Чаще всего сложность видна в структуре кода, и несколько признаков покрывают большинство случаев:

  • Цикл, проходящий по всему массиву, означает линейную сложность
  • При вложенном цикле необходимо считать суммарное число итераций внутреннего цикла, а не глубину вложенности. Часто получается произведение длин, но не всегда: у решета Эратосфена два вложенных цикла, а работает оно за \( O(n \log\log n) \)
  • Деление пополам, повторяющееся на каждом шаге, означает логарифмическую сложность
  • Полный перебор, охватывающий все комбинации, означает экспоненциальную сложность

Алгоритмически неразрешимые проблемы

Проблема останова

Существуют задачи, для которых доказано, что решающего их алгоритма не существует; самая известная из них формулируется следующим образом. Пусть даны алгоритм \(A\) и входные данные \(N\); требуется алгоритм, который по этой паре определяет, остановится ли \(A\) на входе \(N\) или будет работать бесконечно. Такого алгоритма не существует, и доказывается это от противного, подачей предполагаемого распознавателя на вход ему же самому.

Ресурсы

  • https://tproger.ru/digest/competitive-programming-practice/
  • Введение в анализ сложности: https://habr.com/ru/post/196560/
  • Гейл Макдауэлл «Карьера программиста»

Бонус. Задача. Поиск простых чисел

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

def is_prime(n):
    if n < 2:
        return False
    i = 2
    while i < n:
        if n % i == 0:
            return False
        i = i + 1
    return True

Эту задачу можно решить быстрее, поскольку делители, превышающие \(\sqrt n\), проверять необязательно.

def is_prime(n):
    if n < 2:
        return False
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i = i + 1
    return True

С помощью функции is_prime(n) можно найти все простые числа, не превосходящие \(n\). Для этого заведём пустой массив smaller_primes и будем проверять на простоту все числа, не превышающие \(n\). Если число простое, добавим его в массив smaller_primes, где в конце работы алгоритма и будет содержаться искомый ответ.

def get_smaller_primes(n):
    smaller_primes = []
    for num in range(2, n + 1):
        if is_prime(num):
            smaller_primes.append(num)
    return smaller_primes

Для практики этот способ слишком медленный: делители для каждого числа перебираются заново. Гораздо быстрее работает решето Эратосфена, известное ещё с древности.

Алгоритм состоит в следующем:

  • Выписываются все целые числа от 0 до \(n\). Сразу отмечается, что 0 и 1 не простые (на соответствующих позициях записывается False).
  • Заводится переменная \(\mathrm{num}\), равная первому не рассмотренному простому числу. Изначально она равна 2.
  • Числа в списке от \(2 \cdot \mathrm{num}\) до \(n\) с шагом, равным \(\mathrm{num}\), помечаются составными. Например, для 2 значением False помечаются чётные числа 4, 6, 8 и так далее.
  • Далее переменной \(\mathrm{num}\) присваивается следующее простое число, то есть следующее не рассмотренное число в списке. Для этого достаточно увеличивать \(\mathrm{num}\) с шагом 1, пропуская числа, отмеченные как составные. На первом найденном простом числе достаточно остановиться.
  • Два предыдущих шага повторяются, пока это возможно.

В коде это выглядит следующим образом:

def eratosthenes(n):
    numbers = list(range(n + 1))
    numbers[0] = numbers[1] = False
    for num in range(2, n):
        if numbers[num]:
            for j in range(2 * num, n + 1, num):
                numbers[j] = False
    return numbers
eratosthenes(9)
[False, False, 2, 3, False, 5, False, 7, False, False]

Простые числа остались на своих местах, а на позициях, занятых составными числами, стоит False. Алгоритм можно ускорить. Для каждого простого числа \(p\) начнём отмечать составными числа начиная с \(p^2\): всё, лежащее ниже, к этому моменту уже рассмотрено. Получится следующий код:

def eratosthenes_effective(n):
    numbers = list(range(n + 1))
    numbers[0] = numbers[1] = False
    for num in range(2, n):
        if numbers[num]:
            for j in range(num * num, n + 1, num):
                numbers[j] = False
    return numbers

Рассмотрим подробно пример работы алгоритма для \(n = 15\).

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Запишем числа от 0 до 15

False False 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Отметим, что 0 и 1 не простые

num = 2 # Пометим все числа, кратные 2, начиная с 4, значением False

False False 2 3 False 5 False 7 False 9 False 11 False 13 False 15

num = 3 # Пометим все числа, кратные 3, начиная с 9, значением False

False False 2 3 False 5 False 7 False False False 11 False 13 False False

num = 5 # Алгоритм можно завершить, так как num**2 больше 15.
              # Все числа, кратные 5 и меньшие 15, уже рассмотрены.

Решето Эратосфена работает за \(O(n \log(\log n))\). Доказательство опирается на нетривиальные факты из теории чисел.

Существует метод решения задачи нахождения всех простых чисел, не превосходящих \(n\), требующий \(O(n)\) операций. Он называется линейным решетом и помечает каждое число как составное только один раз.

Как и прежде, числа перебираются в порядке возрастания, только в отличие от классического решета Эратосфена найденные составные числа не вычёркиваются. Вместо этого для каждого числа \(x\) записывается наименьший простой делитель \(p\). В программе записанный делитель помещается в ячейку массива \(\mathrm{lp}[x]\) (от англ. least prime). Если число простое, его наименьшим простым делителем служит оно само, а если составное, его наименьший простой делитель \(p\) уже встречался раньше. Более того, ранее встречалось и число \(i\), такое, что \(x = i \cdot p\). На шаге \(i\) необходимо пометить число \(x\) как составное и указать его наименьший простой делитель. Каждое число будет помечено только один раз, на шаге \(i = x/p\). Число \(i\) может быть выбрано только одним способом, поскольку у любого числа существует только один наименьший простой делитель \(p\).

Алгоритм состоит в следующем:

  • Для каждого числа i в lp[i] хранится минимальный простой делитель этого числа. Заводится массив lp длины n + 1, а также массив primes, в который складываются найденные простые числа.
  • Числа i перебираются по возрастанию.
  • Если lp[i] = 0, значит, число i простое, и его необходимо добавить в массив primes.
  • Рассматриваются все простые числа p, не превосходящие lp[i]. Обновляется lp[p * i] = p.
def get_least_primes_linear(n):
    lp = [0] * (n + 1)
    primes = []
    for i in range(2, n + 1):
        if lp[i] == 0:
            lp[i] = i
            primes.append(i)
        for p in primes:
            x = p * i
            if (p > lp[i]) or (x > n):
                break
            lp[x] = p
    return primes, lp
get_least_primes_linear(8)
([2, 3, 5, 7], [0, 0, 2, 3, 2, 5, 2, 7, 2])

Резюме

  • От алгоритма требуется шесть свойств: дискретность, детерминированность, понятность, завершаемость, массовость и результативность.
  • O-нотация описывает, как растёт время работы с ростом объёма данных, отбрасывая константы. Отброшенные множители зависят от процессора и компилятора, характер роста — нет.
  • На миллионе элементов \(O(n)\) обходится в двенадцать миллисекунд, тогда как \(O(\log n)\) — меньше чем в микросекунду. На больших данных выбранный алгоритм значит больше, чем любая микрооптимизация.
  • Пространственная сложность оценивается аналогично, и между временем и занятой памятью почти всегда приходится выбирать.
  • Прежде чем оптимизировать, необходимо измерить. Оценка сложности показывает, чего следует ожидать, но реальное время определяется измерением.

Задание. Требуется решить задачи и оценить сложность полученных решений: «Решение задач на Python и анализ сложности».

Основные структуры данных

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

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

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

Оперативная память и представление данных

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

Пространственную сложность, введённую в предыдущей главе, разложим на составляющие и рассмотрим, из чего она складывается на уровне памяти.

Устройство оперативной памяти

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

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

Оперативная память разбита на ячейки размером в 1 байт, и у каждой ячейки есть свой порядковый номер, называемый «адресом» (отдельные биты внутри ячейки адресов не имеют). Процессор, работающий с оперативной памятью напрямую, обращается к данным по адресам почти так же, как программа обращается к переменным по имени.

Чтобы понять, сколько памяти потребляет программа, необходимо выяснить, сколько места занимают отдельные созданные объекты. В разных языках программирования занятый объём может отличаться, но основные принципы остаются общими. Рассмотрение начнём с языка C++, позволяющего использовать память эффективно, гибко и предсказуемо.

Представление базовых типов данных в ОП

Наименьшая ячейка имеет размер в 1 байт, состоящий из 8 бит. Каждый бит принимает одно из двух значений: 0 или 1. Восемь бит позволяют закодировать \(2^8 = 256\) вариантов, поэтому в такую ячейку можно записать, например, целое число, лежащее в промежутке от 0 до 255.

Если считать записанные числа номерами символов, то в 1 байт умещается один символ ASCII (тип char): латиница, цифры и основные знаки препинания занимают меньше 128 кодов. Для кириллицы места уже не хватает: в однобайтовых кодировках наподобие CP1251 она размещена в верхней половине таблицы, а в UTF-8, принятом сегодня по умолчанию, каждая русская буква занимает два байта — отсюда расхождение между len(text) и len(text.encode()), о котором шла речь в главе «Объекты и память».

Самый распространённый тип целых чисел int занимает 4 байта и позволяет закодировать числа от –2 147 483 648 до 2 147 483 647.

Границы легко вычислить. В 4 байтах содержится 32 бита, один из которых кодирует знак. Оставшийся 31 бит позволяет закодировать \(2^{31} = 2;147;483;648\) чисел. Число «ноль» также занимает один из кодов, поэтому положительных чисел остаётся на одно меньше, чем отрицательных.

Если числа не превышают по модулю два миллиарда, этого типа достаточно. Всего значений около четырёх миллиардов, но половина из них отрицательные. Если точно известно, что переменная не может быть отрицательной, подходит тип беззнаковых целых unsigned int, хранящий числа от 0 до 4 294 967 295.

Вещественные (то есть дробные) числа чаще всего кодируются типом double, «числами с плавающей точкой», занимающими 8 байт. Восьми байт хватает и на очень большие числа, такие как \(\pm10^{308}\), и на близкие к нулю, например \(\pm10^{-308}\).

Диапазон, однако, не то же самое, что точность. Под мантиссу, цифровую часть числа, из восьми байт отведено 53 бита, что соответствует примерно шестнадцати значащим десятичным цифрам; всё, что за них не поместилось, отбрасывается округлением. Наименьшая добавка, которую единица ещё различает, называется машинным эпсилон и равна примерно \(2.2\cdot10^{-16}\), причём шаг между соседними представимыми числами не постоянен, а растёт вместе с самим числом, оставаясь относительным. Отсюда 0.1 + 0.2 != 0.3: ни одна из этих дробей в двоичной записи не конечна, и сумма отклоняется от ответа на последние биты. Отсюда же следует и потеря точности при вычитании близких величин: старшие цифры, которыми они совпадали, взаимно уничтожаются, и от разности остаётся столько верных цифр, сколько их было в различающейся младшей части.

Составные типы данных

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

Во-первых, потребуется хранить сам текст. Это займёт $$10; строк \cdot (20; \frac{символов}{строка}) \cdot (1;\frac{байт}{символ}) = 200; байт$$ Этого было бы достаточно, если бы строки располагались в памяти друг за другом, но выполнять с ними операции в таком случае было бы сложно.

Поэтому в массивы (и в другие составные структуры) зачастую помещаются не сами объекты, а только указатели на них, то есть адреса ячеек, отведённых под эти объекты в оперативной памяти.

Во многих других языках каждый объект в программе записан в специальную «обёртку», содержащую, помимо данных, вспомогательную информацию. Из-за такой обвязки в Python даже короткие целые числа занимают не 4 байта, а почти 30. Для хранения строки требуется около 40 байт служебных данных, а для хранения массива и того больше. Объекты в таких языках ведут себя как строки из примера выше: они могут находиться в произвольном месте памяти, и любое обращение к ним осуществляется по сохранённому адресу.

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

import sys
print(sys.getsizeof(42))  # => 28 байт занимает короткое целое число
print(sys.getsizeof([]))  # => 56 байт занимает пустой массив
print(sys.getsizeof([42]))  # => 64 = (56 + 8) байт занимает массив с одним элементом.
print(sys.getsizeof([1,2,3,4,5,6,7,8,9,10]))  # => 136 = (56 + 8*10) байт занимает массив
                               # с десятью элементами.
                               # сами данные хранятся отдельно
                               # и добавляют 280 = (28 * 10) байт

56 байт уходит на сам массив, далее по 8 байт на адрес каждого элемента и ещё по 28 байт на каждое записанное число. Итого массив из десяти чисел в Python занимает 56 + (8 + 28) * 10 байт = 416 байт против 40 байт в C++.

Освобождение памяти

Если программа перестала пользоваться объектом, это ещё не означает, что занимаемая им память освобождена. В языке C++ необходимо следить за тем, чтобы выделенная память освобождалась. Встроенные контейнеры наподобие std::vector сами выделяют память при создании и отдают её обратно, когда переменная, хранящая контейнер, выходит из области видимости.

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

Сборка мусора является трудоёмкой операцией, поэтому некоторые языки откладывают её до тех пор, пока свободное пространство не подойдёт к концу. Например, программы на Java (если их специально не ограничить при запуске) часто сталкиваются с высоким потреблением памяти из-за множества старых, уже неиспользуемых объектов, сохраняемых до очередной сборки.

Массивы постоянного размера

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

Самый простой тип массивов имеет фиксированный размер и хранит элементы одного и того же типа. Например, в созданный массив из десяти целых чисел нельзя добавить ещё один элемент или записать объект неподходящего типа. Такие массивы встречаются в языке C как int numbers[10], а в C++ как std::array<int, 10> numbers.

С массивами фиксированного размера можно выполнить только две операции:

  • получить значение элемента по заданному индексу,
  • перезаписать значение по указанному индексу.

Обе операции выполняются за \(O(1)\).

Устройство массива

Массив представляет собой набор однотипных данных, расположенных в памяти друг за другом, поэтому он всегда занимает один непрерывный участок. Точное местоположение выделенного участка операционная система сообщает программе в момент создания массива.

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

Пусть имеется массив numbers из 10 беззнаковых целых чисел, адрес нулевого элемента которого равен 1000. Адрес следующего элемента равен адресу начала плюс размер элемента в байтах. Каждое число занимает по 4 байта, и нулевой элемент займёт байты 1000, 1001, 1002, 1003. Следовательно, элемент с индексом 1 будет записан по адресу 1004.

Сложность вставки и удаления в динамических массивах

Динамические массивы иногда называют «векторами», потому что в C++ они реализуются классом std::vector. В Python динамический массив скрыт за классом list, и, несмотря на название, это не список, а массив.

Сложность вставки в динамический массив

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

Элемент можно вставить в любое место массива; вставка в начало и в конец являются частными случаями. Худший случай — вставка в начало, поскольку все лежащие правее элементы приходится сдвинуть на одну позицию, а это \(O(n)\). Добавление в конец — лучший случай: сдвигать ничего не нужно, и стоимость операции составляет \(O(1)\).

Сложность удаления из динамического массива

При удалении, как и при вставке, приходится сдвигать все элементы, стоящие правее затронутой ячейки: при добавлении — на один вправо, при удалении — на один влево. Стоимость та же: \(O(n)\) при работе с началом массива и \(O(1)\) при работе с концом.

Реаллокация в динамических массивах

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

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

Выделение памяти является долгой операцией, а копирование обходится ещё дороже: пройти придётся по всем записанным элементам, а это \(O(n)\). Возникает вопрос, почему добавление в конец массива при этом стоит \(O(1)\).

Рассмотрим программу, добавляющую в массив тысячу элементов по одному за раз:

values = []
for i in range(1000):
    values.append(i)

Операцию append() можно представить как две: реаллокацию и присваивание значения элементу массива. Операции присваивания учитывать не будем: их число равно количеству добавляемых элементов и оптимизации не поддаётся. Время же реаллокаций зависит от выбранного подхода.

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

Во-первых, чтобы не приходилось на каждом шаге выделять дополнительную память, необходимо держать запас свободного места в массиве. Например, если увеличивать размер сразу на 100 элементов, программа будет запрашивать память в 100 раз реже. Однако такая программа всё ещё работает за \(O(n^2)\), хотя и с небольшой константой при квадратичном члене.

Таким образом, у массива появляются два разных размера: size (количество занятых ячеек) и capacity (ёмкость выделенного участка), причём size ≤ capacity.

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

Пусть изначальная ёмкость массива равна 1, а при каждой реаллокации она удваивается. Тогда на первой реаллокации копируется 1 элемент, на второй — 2, на третьей — 4, затем 8, 16, 32, 64, 128, 256 и 512, после чего накопленная ёмкость равна 1024.

Общее число копирований к моменту добавления 1000-го элемента: \(S = 1 + 2 + 4 + \cdots + 512 = 1024 - 1\).

В общем случае, если необходимо добавить \(n\) элементов, где \(2^k \lt n \le 2^{k+1}\), последнее копирование затронет \(2^k\) элементов. Запишем сумму получившейся геометрической прогрессии: \(S = \sum_{i=0}^k 2^i = 2^{k+1} - 1 = 2 \cdot 2^k - 1 \le 2\cdot n\).

Следовательно, общее количество копирований при реаллокациях линейно зависит от числа добавленных элементов.

Связные списки

Элементы массива в памяти компьютера расположены последовательно. Чтобы вставить или удалить элемент из начала массива, требуется \(O(n)\) операций, потому что передвинуть придётся каждый элемент, стоящий следом.

Структурой, не требующей при вставке или удалении сдвигать остальные элементы, является связный список (англ. linked list); он присутствует в стандартных библиотеках многих языков: std::list в C++, LinkedList в Java.

В языке Python list остаётся динамическим массивом, хотя и обозначен словом «список».

Устройство связного списка

В связном списке у каждого элемента, помимо его значения, есть ссылка на следующий элемент списка, за исключением последнего, ссылающегося в никуда. В зависимости от языка программирования ссылка в никуда может представлять собой объект None, нулевой указатель или аналогичную сущность. У связного списка определяют точку старта, называемую «головой списка».

Достоинства и недостатки связного списка

Элементы связного списка располагаются в памяти произвольно, а не подряд, как в массиве. Это удобно, когда элементов много и разместить накопленные данные единым блоком не удаётся.

Платой за это является доступ по индексу: элементы лежат вразброс, и до нужного приходится идти от головы списка, перебирая все звенья по дороге, а это \(O(n)\) операций.

Двигаться по односвязному списку можно только в одном направлении. Отсюда ещё один недостаток: быстро удалить элемент не удастся, потому что неизвестно, какой элемент на него ссылается. В худшем случае поиск предыдущего звена, хранящего эту ссылку, потребует \(O(n)\) операций, тогда как вставка элемента после текущего выполняется быстро.

Структура данных стек

Стек работает по принципу LIFO (англ. last in, first out, «последним пришёл, первым ушёл»). Извлечь можно только элемент, положенный последним.

На стеке реализована кнопка «назад» в браузере: одно нажатие возвращает на предыдущую страницу, следующее — на открытую перед ней.

Отмена последних операций в текстовых редакторах также реализована на стеке.

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

Интерфейс стека

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

  • push(item) добавляет элемент на вершину стека;
  • pop() возвращает элемент с вершины стека и удаляет его;
  • size() возвращает размер стека (количество лежащих в нём элементов).

Иногда присутствуют дополнительные операции:

  • peek() или top() возвращает элемент с вершины стека, не удаляя его;
  • isEmpty() определяет, пуст ли стек.

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

Реализация стека

Стек на основе массива: в конструкторе создаётся пустой массив, а методы push() и pop() его изменяют:

class Stack:
     def __init__(self):
         self.items = []

     def push(self, item):
         self.items.append(item)

     def pop(self):
         return self.items.pop()

     def peek(self):
         return self.items[-1]

     def size(self):
         return len(self.items)
stack = Stack()
stack.push('apple')
stack.push('banana')
stack.push('orange')
stack.pop()

Структуры данных: очередь и дек

Очередь, в отличие от стека с его LIFO, работает по принципу FIFO (англ. first in, first out): первым извлекается элемент, добавленный раньше всех.

Бытовые очереди подчиняются тому же правилу:

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

Такие сценарии моделируются структурой данных очередь.

Интерфейс очереди

Как и стек, очередь задаёт интерфейс взаимодействия с данными, гарантирующий набор методов, но не способ их хранения. Очередь принято реализовывать так, чтобы и вставка, и удаление элемента стоили \(O(1)\).

Методы очереди:

  • push(item) добавляет элемент в конец очереди;
  • pop() берёт элемент из начала очереди и удаляет его;
  • peek() берёт элемент из начала очереди без удаления;
  • size() возвращает количество элементов в очереди.

Дек: очередь с двумя концами

Очередь пропускает элементы только в одну сторону: вход через хвост, выход через голову. Дек (deque, от double-ended queue, двусторонняя очередь) позволяет добавлять и удалять элементы с любого конца, и обе операции выполняются за \(O(1)\).

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

Интерфейс дека объединяет стек и очередь:

  • append(item) добавляет элемент в конец;
  • appendleft(item) добавляет в начало;
  • pop() берёт элемент с конца и удаляет;
  • popleft() берёт с начала и удаляет.

Дек обобщает обе структуры: работа с одним концом даёт стек, вход с одного конца и выход с другого — очередь. Поэтому в Python нет отдельной простой очереди, её роль выполняет дек. Класс queue.Queue из стандартной библиотеки решает другую задачу, синхронизацию между потоками, и внутри содержит тот же дек.

В стандартной библиотеке дек находится в модуле collections:

from collections import deque

buf = deque()
buf.append(1)          # [1]
buf.append(2)          # [1, 2]
buf.appendleft(0)      # [0, 1, 2]

print(buf.popleft())   # 0, забрали из начала
print(buf.pop())       # 2, забрали с конца
print(buf)             # deque([1])

Преимущества дека перед списком

Список в Python поддерживает и insert(0, x), и pop(0); разница заключается в стоимости. Список устроен как динамический массив, его элементы лежат в памяти подряд; чтобы вставить элемент в начало, необходимо сдвинуть все остальные на одну позицию вправо, а это \(O(n)\) на каждую вставку. Дек устроен как связанные между собой блоки, и добавление к любому его концу не затрагивает уже уложенные элементы.

Замер на Apple M4, заполнение структуры с начала:

      N |      list |     deque | отношение
   1000 |    0.19 мс |  0.015 мс |     13x
  10000 |   13.09 мс |  0.122 мс |    107x
 100000 | 1358.53 мс |  1.364 мс |    996x

Важны не сами числа, а последний столбец. Отношение растёт вместе с \(N\), и это является признаком разной алгоритмической сложности. Если бы дек был просто реализован эффективнее, выигрыш оставался бы постоянным. Здесь же на тысяче элементов разница тринадцатикратная, на ста тысячах — тысячекратная, потому что время списка растёт как \(O(n^2)\), а время дека как \(O(n)\).

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

buf = []
for x in data:
    buf.append(x)
    if len(buf) > 1000:
        buf.pop(0)         # удаление из начала — O(n)

У дека ограничение длины встроено в конструктор, и при переполнении он сам удаляет элемент, лежащий на противоположном конце.

buf = deque(maxlen=1000)
for x in data:
    buf.append(x)          # старое значение уходит само, O(1)

На потоке из 200 000 отсчётов первый вариант отработал за 21 мс, а второй за 2 мс: порядок, выигранный одной строкой в выборе структуры данных.

Дек платит за это доступом по индексу. У списка элементы лежат подряд, поэтому lst[50000] вычисляется арифметикой по адресу за \(O(1)\). У дека до середины приходится добираться по блокам, перебирая сцепленные звенья, и это \(O(n)\). В том же замере обращение к середине заняло 0.005 мкс у списка против 0.525 мкс у дека, то есть почти в сто раз дольше.

Правило выбора, работающее почти всегда: если нужен произвольный доступ по индексу — список; если нужны быстрые операции с обоими концами, а середина лишь пробегается целиком — дек. insert(0, x) или pop(0) внутри цикла почти наверняка означают, что требуется дек.

Стек вызовов

Код программы разделён на функции: базовые объединяются в сложные, а те в ещё более сложные, и вся программа представима как одна функция, вызывающая множество других.

Устройство стека вызовов

Допустим, написана программа на C++. Первой вызывается функция main(), в которой вызывается функция A. Она вызывает функцию B, которая, в свою очередь, вызывает C.

Стек вызовов написанной программы выглядит следующим образом:

        вершина стека
   ┌──────────────┐
   │      C       │  ← выполняется сейчас
   ├──────────────┤
   │      B       │  вызвала C
   ├──────────────┤
   │      A       │  вызвала B
   ├──────────────┤
   │    main()    │  вызвала A
   └──────────────┘
         дно

После возврата из функции управление должно вернуться туда, откуда эта функция была вызвана. Для этого при вызове в стеке сохраняется адрес инструкции, ожидающей выполнения после возврата.

Пример: функция приветствует пользователя по имени и выдаёт гороскоп на сегодня, для простоты один и тот же для всех.

def say_hello(name):
    print(f"Привет, {name}")
    print_horoscope(name.upper())
    print(f"Пока, {name}, хорошего дня!")

def print_horoscope(name):
    print(f"{name}! Сегодня подходящий день для прогулок в парке и изучения рекурсии")

say_hello('Гоша')

В разных частях программы переменная name хранит разные значения: при входе в say_hello() в ней записано 'Гоша', в print_horoscope() — 'ГОША', а после возврата в say_hello() снова 'Гоша'.

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

Работа стека: шаг за шагом

Первая команда программы — вызов say_hello(), под который на стеке выделяется блок памяти.

   ┌────────────────────┐
   │  say_hello()       │  ← выделен блок под вызов
   └────────────────────┘

В блок записывается адрес возврата — адрес инструкции, следующей сразу за местом вызова функции.

Туда же помещаются аргументы, с которых начинается набор локальных переменных. Здесь один аргумент name = 'Гоша', других локальных переменных нет.

   ┌────────────────────┐
   │  say_hello()       │
   │    адрес возврата  │
   │    name = 'Гоша'   │
   └────────────────────┘

Первая инструкция say_hello() выводит «Привет, Гоша». Затем метод upper() возвращает строку 'ГОША', и она становится параметром print_horoscope().

Вызовы print() и upper() на схеме не показаны, хотя они также помещаются на стек при вызове и снимаются с него после завершения.

При вызове print_horoscope() с параметром 'ГОША' выделяется новый блок памяти с параметрами, адресом возврата и местом под локальные переменные. Он ложится поверх блока say_hello() — та же стопка книг в коробке, только из блоков памяти.

   ┌────────────────────┐
   │  print_horoscope() │  ← новый блок лёг сверху
   │    адрес возврата  │
   │    name = 'ГОША'   │
   ├────────────────────┤
   │  say_hello()       │
   │    адрес возврата  │
   │    name = 'Гоша'   │
   └────────────────────┘

Первая инструкция print_horoscope() выводит «ГОША! Сегодня подходящий день для прогулок в парке и изучения рекурсии».

Инструкций в print_horoscope() больше нет, и управление возвращается по адресу, записанному в её блоке. Блок снимается со стека, а значение переменной name восстанавливается.

   ┌────────────────────┐
   │  say_hello()       │  ← верхний блок снят,
   │    адрес возврата  │    name снова 'Гоша'
   │    name = 'Гоша'   │
   └────────────────────┘

Затем исполняется инструкция say_hello(), следующая за вызовом print_horoscope(), и выводит «Пока, Гоша, хорошего дня!»

Это последняя инструкция say_hello(), поэтому происходит возврат из функции, и её блок освобождается.

   ┌────────────────────┐
   │                    │  ← стек пуст
   └────────────────────┘

Рекурсия. Переполнение стека вызовов

Функции, вызывающие сами себя с изменёнными аргументами, называются рекурсивными, и они также используют стек вызовов.

Здесь речь идёт о цене, которую рекурсия платит стеком; как рекурсия записывается, из чего складываются рекурсивный и базовый случаи и какие ошибки при этом типичны, рассматривается далее, в главе «Рекурсия и сортировки».

Факториал

Факториал натурального числа можно вычислить как произведение всех натуральных чисел от 1 до n: \(n! = 1 \cdot 2 \cdot ... \cdot (n-1) \cdot n\)

Рекурсия и стек вызовов

Рекурсивная функция вычисления факториала:

def factorial(n):
    if n == 1 or n == 0:
        return 1
    return n * factorial(n - 1)

Вызов factorial(3):

   Погружение:                    Возврат:
   ┌──────────────┐               ┌──────────────┐
   │ factorial(1) │ вернёт 1      │ 1            │
   ├──────────────┤               ├──────────────┤
   │ factorial(2) │ ждёт          │ 2 * 1 = 2    │
   ├──────────────┤               ├──────────────┤
   │ factorial(3) │ ждёт          │ 3 * 2 = 6    │
   └──────────────┘               └──────────────┘

Пока рекурсия погружается, стек растёт: каждый вызов ожидает результата следующего. После базового случая стек разбирается в обратном порядке, и каждый уровень домножает полученное значение на своё n.

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

Чтобы хранить локальные переменные и адрес возврата на каждом уровне рекурсии, потребуется \(O(d)\) памяти на стеке, где d — глубина рекурсии. При вычислении факториала \(n!\) глубина равна n, поэтому приведённая выше функция расходует \(O(n)\) памяти на стеке.

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

Ограничений два. Первое устанавливает сам Python: по умолчанию около тысячи вложенных вызовов, далее RecursionError, а поднять порог можно через sys.setrecursionlimit. Второе — стек операционной системы, обычно 8 МБ; достичь его можно, только сняв первое. Здесь важна версия. До Python 3.10 кадр каждого вызова помещался на стек C, и глубже нескольких десятков тысяч вызовов рекурсию довести было нельзя. С 3.11 кадры питоновских функций переехали в кучу, поэтому чистая рекурсия с поднятым порогом доходит и до миллиона уровней. Однако когда цепочка вызовов уходит через C (например, через __repr__), стек C работает по-прежнему, и переполнение никуда не исчезает (англ. stack overflow). В одних языках при этом возникает исключение «Stack Overflow», в других ошибка «Segmentation Fault».

factorial(10_000)

Предотвращение переполнения стека

Приёмы против переполнения стека:

  • Иногда рекурсию можно заменить циклом. Например, факториал переписывается следующим образом:
def factorial(n):
    accumulator = 1
    i = n
    while i > 1:
        accumulator *= i
        i -= 1
    return accumulator
  • Другой способ — собственный стек, эмулирующий стек вызовов, но без ограничений встроенного.

Объёмом памяти под стек вызовов можно управлять. В некоторых языках — непосредственно во время работы программы: в Python метод setrecursionlimit() модуля sys параметром limit задаёт максимальную глубину рекурсии; наибольшее значение зависит от платформы и всё равно существенно ограничено. Текущее значение возвращает getrecursionlimit(). В Java и JavaScript размер стека вызовов задаётся настройками виртуальной машины при запуске и во время работы не меняется. Иногда размер стека вызовов можно изменить на уровне операционной системы.

sys.getrecursionlimit()

Как выбор структуры сказывается на работающем сервисе, показано в главе «Базы данных»: там одна задача переложена в четыре хранилища, от текстового файла до кеша.

Резюме

Сводная таблица для выбора структуры под задачу:

СтруктураДоступ по индексуВставка в конецВставка в началоПоиск
Массив постоянного размера\(O(1)\)нетнет\(O(n)\)
Динамический массив (list)\(O(1)\)\(O(1)^*\)\(O(n)\)\(O(n)\)
Связный список\(O(n)\)\(O(n)^{**}\)\(O(1)\)\(O(n)\)
Дек (deque)\(O(n)\)\(O(1)\)\(O(1)\)\(O(n)\)

\(^*\) Амортизированно: изредка происходит реаллокация, но в среднем на каждый добавленный элемент приходится константа.

\(^{**}\) В том виде, в каком список описан выше, с одной только головой: чтобы добавить элемент в конец, приходится пройти весь список. Если хранить ещё и ссылку на хвост, вставка в конец становится \(O(1)\), и так устроены реализации, принятые в стандартных библиотеках.

Универсальной структуры не существует, существует подходящая под задачу. Если элементы постоянно добавляются и снимаются с обоих концов, а в коде используется список, программа платит \(O(n)\) там, где могла бы платить \(O(1)\).

Ни одна из рассмотренных структур не позволяет быстро искать по значению, все дают \(O(n)\). Как обойти это ограничение, рассматривается в следующих главах, посвящённых хеш-таблицам и деревьям поиска.

Рекурсия и сортировки

Настоящая глава начинается с рекурсии, для многих первого трудного места в программировании, после чего рекурсия применяется к самой изученной задаче информатики — сортировке.

Введение. Примеры задач на рекурсию

Пусть требуется найти файл Kormen.pdf в папке C:\books\, заполненной вложенными подпапками, без поиска, встроенного в файловый менеджер. Возможны два варианта.

Первый — обычный обход списком:

   Обходим папки списком, вручную запоминая, куда ещё не заходили:

   очередь: [C:\books]
   → зашли в C:\books, нашли подпапки: algo, physics
   очередь: [algo, physics]
   → зашли в algo, нашли подпапку: sorting
   очередь: [physics, sorting]
   → зашли в physics … и так далее, пока очередь не опустеет

Второй — тот же поиск, записанный рекурсивно:

   найти(C:\books)
   ├── найти(algo)
   │   └── найти(sorting) ──▶ Kormen.pdf найден!
   ├── найти(physics)
   └── найти(math)

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

Рекурсия не ускоряет работу программы, а, наоборот, может её замедлить. Алгоритмы, реализованные через обычные циклы, часто исполняются быстрее своих рекурсивных аналогов. Зато рекурсия экономит время программиста: код, написанный рекурсивно, получается компактным и понятным, в нём труднее допустить ошибку, а изменения вносятся в него проще, чем в цикл, нагруженный счётчиками.

Рекурсивный и базовый случаи

Любая корректно работающая рекурсия состоит из двух обязательных частей:

  • рекурсивного случая, запускающего прямой ход рекурсии и её углубление;
  • базового случая, который в нужный момент останавливает это углубление и запускает обратный ход рекурсии.

Рекурсивный случай

Рассмотрим заготовку функции, строящей лестницу из n ступеней:

функция stairs_builder(n):
    построить 1 ступеньку
    print("Осталось построить {n} ступеней.")
    stairs_builder(n - 1)

Вызов функцией самой себя с изменёнными параметрами, stairs_builder(n - 1), и есть рекурсивный случай. Однако программа, написанная таким образом, приведёт к зависанию компьютера: рекурсия, лишённая условия остановки, уйдёт в бесконечный цикл.

На каждом следующем уровне рекурсии число непостроенных ступеней n уменьшается на 1. Но после n = 0 функция, не знающая базового случая, не остановит работу, а вызовется со значением −1, затем с −2 и так далее. При неограниченных ресурсах лестница строилась бы вечно.

Базовый случай

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

Рекурсия в stairs_builder() должна остановиться, когда построено заданное количество ступеней, то есть когда счётчик, уменьшаемый на каждом уровне, дойдёт до n = 0. Это и есть базовый случай.

функция stairs_builder(n):
  if n == 0:
    return
  построить 1 ступеньку
  print("Осталось построить {n} ступеней.")
  stairs_builder(n - 1)

Правильное построение рекурсии

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

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

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

Частой ошибкой в рекурсии является зацикливание, обычно из-за пропущенного базового случая. Другой распространённой причиной ухода в бесконечный цикл являются параметры, неверно изменённые в рекурсивном случае.

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

В большинстве случаев программа, ушедшая в бесконечную рекурсию, не будет работать вечно, а завершится с ошибкой переполнения стека вызовов или с ошибкой сегментации (англ. segmentation fault): каждый вызов, добавленный в стек, расходует память, а она ограничена. Поведение программы зависит и от самого алгоритма, и от используемого компилятора. Подробнее об этом можно прочитать в статье про оптимизацию хвостовой рекурсии (англ. tail call optimization).

Разбор задач. Рекурсивный перебор вариантов

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

Генерация последовательностей из 0 и 1

Рассмотрим генерацию всех последовательностей длины n, составленных из нулей и единиц.

Функция принимает число n и строку prefix, накапливающую уже выбранные символы. Вызовем её со значением prefix, равным пустой строке, чтобы далее дописывать в неё 0 и 1. В каждом рекурсивном вызове n означает, сколько символов ещё не дописано. Рекурсия останавливается, когда n = 0: строка, собранная к этому моменту, готова, и это базовый случай рекурсии.

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

Решение этой задачи удобно представлять в виде бинарного дерева, растущего вниз от пустой строки. Переход влево соответствует приписыванию 0, переход вправо — приписыванию 1. Пройдя от корня по всем ветвям, получим все последовательности длины 3, составленные из 0 и 1.

                    «»
              ┌─────┴─────┐
             0             1
          ┌──┴──┐       ┌──┴──┐
         00     01     10     11
        ┌─┴─┐  ┌─┴─┐  ┌─┴─┐  ┌─┴─┐
      000 001 010 011 100 101 110 111

Рекурсия часто используется для перебора вариантов. Пусть имеется n различных предметов, каждый из которых может быть взят или отложен в сторону. Тогда наборов, собираемых из них, ровно \(2^n\). Пронумеруем предметы числами от 1 до \(n\) и опишем набор строкой, где предмет, положенный в рюкзак, помечен цифрой 1, а отложенный — цифрой 0. Таким образом, задача перебора вариантов сводится к уже рассмотренной задаче генерации последовательностей.

def generate(n, prefix):
    if n == 0:
        print(prefix)
        return
    generate(n - 1, prefix + '0')
    generate(n - 1, prefix + '1')
generate(3, '')
000
001
010
011
100
101
110
111

Алгоритмы сортировки. Знакомство

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

Сортировки среди нас

В смартфоне контакты, записанные вперемешку, показываются по алфавиту, а расписание автобусов составляется по времени их отправления. Поисковая система выдаёт список ответов, ранжированный по степени их релевантности запросу.

Роль сортировок в решении задач

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

  • найти элемент в массиве быстрее чем за \(O(n)\), воспользовавшись бинарным поиском, возможно только на отсортированных данных;
  • таблицу «10 лучших студентов курса» проще собрать из списка, упорядоченного не по алфавиту, а по среднему баллу.

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

Лучшей сортировки в общем случае не существует: для данных разной природы и ограничений, наложенных задачей, выигрывают разные алгоритмы.

Выбор алгоритма сортировки

Сравним два алгоритма, решающих одну и ту же задачу:

  • один работает быстро, но требует \(O(n)\) дополнительной памяти;
  • другой алгоритм медленнее, но ему требуется лишь \(O(1)\) дополнительной памяти.

Пусть в компании стоят старые серверы, свободной памяти на которых почти не осталось. На одном из них лежат данные об удовлетворённости жизнью, собранные по всем городам страны. Для ежемесячного отчёта их необходимо отсортировать и выбрать названия 100 городов с самыми довольными жителями. Отчёт требуется сдать через неделю.

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

Другая ситуация: команде поручено сделать стенд, показывающий удовлетворённость жителей страны в реальном времени. Под эту задачу был приобретён новый мощный сервер. Каждую секунду на вход программы поступает обновлённый массив со значениями индекса, измеренными по всем городам. Алгоритм должен составить «Топ-5 самых довольных городов» и обновлять этот список без задержек.

Здесь предпочтительнее более быстрый алгоритм, но требующий больше памяти.

При решении задачи имеющиеся ресурсы оцениваются по трём обстоятельствам:

  • Срочность ответа. Если результат сортировки нужен не срочно, подходит более медленный алгоритм, зато не требующий лишней памяти.
  • Объём данных, поступающих на вход. Если в массиве всего 1000 элементов, то любой алгоритм сортировки упорядочит их за неощутимое время. На 5 000 000 элементов разница между алгоритмами становится решающей.
  • Объём доступной памяти. Если свободного места хватает на вторую копию массива, можно собирать отсортированный массив в выделенной памяти, а не переставлять элементы в исходном.

Устойчивость сортировок

Сортировку, сохраняющую взаимный порядок элементов с равным значением сравниваемого признака, называют устойчивой (англ. stable sort). Если же равные элементы могут поменяться местами, сортировку называют неустойчивой. Разница важна, когда данные сортируют несколько раз подряд по разным ключам: устойчивая сортировка сохраняет результат, полученный на предыдущем проходе, а неустойчивая его перемешивает.

Сортировка по ключу

Признак, по которому элементы сравниваются, называют «ключом сортировки». Сортировка по ключу уже применялась выше, когда города упорядочивались по индексу удовлетворённости, посчитанному для каждого из них.

С точки зрения алгоритма сортировки меняется только один этап — операция, сравнивающая элементы.

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

  • В одном варианте передаётся функция одного аргумента key(object). Она даёт значение ключа для каждого объекта, и объекты попарно сравниваются по вычисленным ключам.
  • В другом варианте передаётся функция двух аргументов less(object_1, object_2), сравнивающая два объекта напрямую: она возвращает true, если первый должен стоять раньше второго, и false в противном случае. Такую функцию называют «компаратор» (англ. compare, «сравнивать»).

Компаратор задаёт порядок гибче, чем ключ: сортировку по любому ключу легко переписать через компаратор:

функция less(object_1, object_2):
    return key(object_1) < key(object_2)

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

Способ передачи компаратора либо функции вычисления ключа в функцию сортировки, встроенную в конкретный язык, описан в её документации.

Сортировка слиянием

Сортировка слиянием (англ. merge sort) работает за \(O(n \log n)\) даже в худшем случае.

Принцип работы сортировки слиянием

Алгоритм складывается из трёх действий, повторяемых на каждом уровне:

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

Условием остановки рекурсии служит массив из одного элемента, уже отсортированный по определению: это базовый случай.

   Разбиение:                        Слияние:
        [5 2 9 1]                      [1 2 5 9]
        ┌────┴────┐                    ┌────┴────┐
      [5 2]     [9 1]                [2 5]     [1 9]
      ┌─┴─┐     ┌─┴─┐                ┌─┴─┐     ┌─┴─┐
     [5] [2]   [9] [1]              [5] [2]   [9] [1]

Дерево алгоритма сортировки слиянием: массив разбивается на части примерно равной длины. Каждая часть сортируется рекурсивно, после чего части, приведённые в порядок, объединяются в единый массив

Ниже приведены сначала слияние, собирающее из двух упорядоченных массивов один, затем сама сортировка, состоящая из разбиения и рекурсивного вызова:

def merge(left, right):
    # Идём по обоим массивам сразу, каждый раз забирая меньший из головных элементов
    merged = []
    i, j = 0, 0
    while i < len(left) and j < len(right):
        # При равенстве берём элемент из левой части: этим и держится устойчивость
        if right[j] < left[i]:
            merged.append(right[j])
            j += 1
        else:
            merged.append(left[i])
            i += 1
    # Хвост, оставшийся от одного из массивов, дописываем целиком
    merged.extend(left[i:])
    merged.extend(right[j:])
    return merged


def merge_sort(nums):
    # Массив из одного элемента уже отсортирован: это базовый случай
    if len(nums) <= 1:
        return nums
    middle = len(nums) // 2
    left = merge_sort(nums[:middle])
    right = merge_sort(nums[middle:])
    return merge(left, right)

Сложность алгоритма

Сортировка слиянием даже в худшем случае работает за \(O(n \log n)\).

На каждом шаге прямого хода рекурсии массив разбивается на две примерно равные по размеру части, и разбиение продолжается до тех пор, пока длина массива не станет равной 1. Следовательно, каждый элемент, попадающий в такую цепочку, будет задействован примерно в \(\log_2 n\) вызовах рекурсивной функции.

Иначе можно сказать, что глубина рекурсии составляет \(O(\log_2n)\). Глубиной рекурсии называют максимальную глубину стека вызовов, достигаемую за время её работы.

На каждом шаге обратного хода рекурсии выполняется слияние двух отсортированных массивов, а оно выполняется одним проходом. Следовательно, на каждом уровне рекурсии затрачивается \(O(n)\) операций: сколько бы частей там ни было, вместе они содержат все \(n\) чисел, распределённых по ним при разбиении.

Общая сложность алгоритма \(O(n \cdot \log_2 n)\).

Пространственная сложность алгоритма

На каждом уровне рекурсии слиянию требуется \(O(n)\) дополнительной памяти, куда копируются элементы объединяемых блоков в правильном порядке. Если выделять её на каждом уровне заново, суммарно придётся затратить \(O(n\log{n})\) дополнительной памяти.

Однако вспомогательный массив требуется лишь временно: элементы, собранные в нём, сразу переносятся обратно в исходный, а выделенная память освобождается. В каждый момент времени занято вспомогательное пространство лишь одного, текущего уровня рекурсии, поэтому достаточно \(O(n)\) дополнительной памяти.

Устойчивость алгоритма

Сортировка слиянием устойчива. При равенстве элементов в двух сливаемых массивах приоритет отдаётся элементу, лежащему в левой половине. Так происходит на каждом уровне, вплоть до объединения двух последних половин в целый массив. Поэтому равные элементы в массиве, собранном алгоритмом, стоят друг относительно друга так же, как и в исходном.

Быстрая сортировка

Быстрая сортировка (англ. quick sort) опирается на стратегию «разделяй и властвуй».

Стандартные библиотеки разных языков сделали разный выбор алгоритма сортировки: в C++ std::sort — интроспективная сортировка, то есть быстрая, переходящая в пирамидальную на неудачных входах, а в Python sorted() и list.sort() — Timsort, гибрид сортировки слиянием и вставками, устойчивый и использующий уже упорядоченные фрагменты без дополнительных затрат. Автором быстрой сортировки был Чарльз Хоар, поэтому в честь него quick sort иногда называют сортировкой Хоара.

Принцип работы алгоритма

Любой алгоритм, построенный по этой стратегии, состоит из трёх шагов:

  • разделение данных на части меньшего размера;
  • рекурсивный вызов алгоритма для полученных частей;
  • объединение результатов.

В merge sort шаг разделения прост, а шаг объединения нетривиален. В быстрой сортировке наоборот: разделение на части сложнее, зато части, отсортированные рекурсивно, объединяются дописыванием одного массива после другого.

  • Возьмём исходный массив:
   [7  2  9  4  3  8  1]
  • Выберем какое-нибудь число, например 4, и назовём его опорным. Все элементы, меньшие опоры, переложим в один массив, все большие — в другой, а саму опору (и равные ей) оставим посередине:
   [2  3  1]   4   [7  9  8]
    меньше   опора   больше
  • Отсортируем каждую из частей рекурсивно.
  • Соединим левую часть с правой.

Получен отсортированный массив:

   [1  2  3]   4   [7  8  9]   ──▶   [1  2  3  4  7  8  9]

Число, назначенное опорой, было выбрано произвольно.

Выбор опорного элемента

Если взять опорой максимум, разбиение выродится: слева окажется весь массив, справа пусто. Длина, уменьшенная всего на единицу, даст \(n\) уровней рекурсии, а работы на каждом линейно. Итого \(O(n^2)\), не лучше наивных квадратичных сортировок.

Следовательно, опора должна делить массив примерно пополам. Разумной эвристикой служит элемент, взятый из середины: на отсортированном или почти отсортированном массиве (а такие входы часты) он даёт ровное разбиение. Однако под любое фиксированное правило выбора можно подобрать массив, вырождающий разбиение, и в олимпиадных тестах такие массивы появляются намеренно.

При опоре, выбранной случайно, плохое разбиение зависит не от массива, а от жребия. Гарантии это по-прежнему не даёт: при очень неудачной череде жребиев быстрая сортировка отработает за \(O(n^2)\). Однако вероятность такого исхода исчезающе мала, а в среднем получается \(O(n \log n)\).

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

import random


def partition(nums, pivot):
    # Раскладываем элементы по трём массивам, сравнивая каждый с опорой
    less, equal, greater = [], [], []
    for num in nums:
        if num < pivot:
            less.append(num)
        elif num > pivot:
            greater.append(num)
        else:
            equal.append(num)
    return less, equal, greater


def quick_sort(nums):
    # Массив из нуля или одного элемента уже отсортирован: это базовый случай
    if len(nums) <= 1:
        return nums
    # Опору выбираем случайно, чтобы входные данные не могли выродить разбиение
    pivot = random.choice(nums)
    less, equal, greater = partition(nums, pivot)
    return quick_sort(less) + equal + quick_sort(greater)

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

Сложность быстрой сортировки

Разбиение массива вокруг опоры выполняется одним проходом за \(O(n)\) операций. Если опора, выбираемая на каждом шаге, делит массив примерно пополам, уровней рекурсии будет \(O(\log n)\), и на каждом суммарно затрачивается \(O(n)\), откуда и получается \(O(n \log n)\) в среднем. При вырожденных разбиениях уровней становится \(n\), и сложность возрастает до \(O(n^2)\).

Схема с тремя новыми массивами стоит \(O(n)\) дополнительной памяти, и от этой платы на практике избавляются. Разбиение выполняют на месте: поддерживается граница «меньших», массив проходится одним проходом, и очередной элемент, меньший опоры, меняется местами с элементом на границе, а в конце опора ставится на место границы.

Памяти такой быстрой сортировке почти не требуется: сверх исходного массива расходуется только стек вызовов, \(O(\log n)\) при удачных разбиениях. Этим она и выигрывает у сортировки слиянием, требующей вспомогательный массив на \(n\) элементов.

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

Сортировка подсчётом

Отсортировать быстрее чем за \(O(n \log n)\) возможно, но лишь для узкого класса задач, устроенных особым образом.

Принцип работы алгоритма

Дан массив чисел:

nums = [5, 7, 1, 0, 1, 5, 11, 1]

Требуется отсортировать его по неубыванию. Известно, что числа в нём обозначают номера месяцев, то есть лежат в диапазоне от 0 до 11.

Заведём массив из 12 элементов, заполненный нулями.

months = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Теперь пройдём по массиву nums и для каждого числа увеличим на 1 счётчик, отвечающий этому месяцу:

months[5] += 1
months[7] += 1
months[1] += 1
months[0] += 1
months[1] += 1
months[5] += 1
months[11] += 1
months[1] += 1

Получим:

months = [1, 3, 0, 0, 0, 2, 0, 1, 0, 0, 0, 1]

Теперь пройдём по массиву months и допишем в nums столько копий каждого номера, сколько единиц накоплено в счётчике, отвечающем этому месяцу:

nums = []
for i in range(len(months)):
    for j in range(months[i]):
        nums.append(i)

Получим:

nums = [0, 1, 1, 1, 5, 5, 7, 11]

Код для случая, когда значения элементов лежат в полуинтервале от 0 до k:

def counting_sort(nums, k):
    # Создаём массив из k элементов, заполненный нулями
    counts = [0] * k
    # Проходим по массиву nums и увеличиваем соответствующий элемент в массиве counts
    for num in nums:
        counts[num] += 1
    # Проходим по массиву counts и добавляем в массив nums столько элементов, сколько встречается в counts
    nums = []
    for i in range(len(counts)):
        for j in range(counts[i]):
            nums.append(i)
    return nums

Алгоритм сортировки подсчётом использует \(O(k)\) дополнительной памяти, где \(k\) — мощность множества значений, которые могут встретиться в массиве. Память, выделенная под вспомогательный массив, и есть вся плата за скорость. Чтобы создать этот массив, необходимо знать диапазон возможных значений. В примере потребовался дополнительный массив всего из 12 элементов.

Элементы здесь ни разу не сравниваются друг с другом, поэтому сортировка подсчётом и обходит границу \(O(n \log n)\), доказанную для сортировок сравнением. Работает она за \(O(n + k)\), и выигрыш сохраняется, только пока \(k\) сопоставимо с \(n\). Сортировать таким способом миллион чисел, разбросанных по диапазону до миллиарда, бессмысленно: массив, заведённый под счётчики, окажется в тысячу раз длиннее исходного.

Устойчивость алгоритма

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

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

Резюме

  • Рекурсия складывается из базового случая, останавливающего спуск, и рекурсивного, сводящего задачу к меньшей; без базового случая происходит переполнение стека.
  • Сортировка слиянием работает за \(O(n \log n)\) даже в худшем случае и устойчива, но требует \(O(n)\) дополнительной памяти под вспомогательный массив.
  • Быстрая сортировка в среднем также \(O(n \log n)\) и почти не требует памяти, зато на неудачных разбиениях деградирует до \(O(n^2)\) и порядок равных элементов не сохраняет.
  • Опору в быстрой сортировке следует выбирать случайно: любое фиксированное правило можно обойти массивом, подобранным намеренно.
  • Сортировка подсчётом не сравнивает элементы вовсе, благодаря чему обходит границу \(O(n \log n)\), но применима лишь на небольшом диапазоне значений и стоит \(O(k)\) памяти.
  • Устойчивость является критерием выбора: она необходима всякий раз, когда данные сортируют несколько раз подряд по разным ключам.
  • Собственную сортировку в рабочем коде писать незачем, потому что встроенная почти всегда быстрее и лучше отлажена; знать, как она устроена, необходимо, чтобы понимать, чего от неё ожидать.

Хеш-функции

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

Последовательный перебор всех элементов является слишком медленным. Номер ячейки, отведённой под пару «ключ — значение», можно вычислять непосредственно из ключа. Эту задачу решают хеш-функции, на которых построены словари и множества.

В настоящей главе рассматривается, каким образом ключ преобразуется в номер корзины, почему различные ключи иногда попадают в одну корзину, как разрешаются столкновения ключей и за счёт чего словарь обеспечивает сложность \(O(1)\).

Понятие отображения

В интерфейсе массива, рассмотренном в главе про структуры данных, имеются две основные операции:

  • get(index: int) -> value возвращает значение value из ячейки с индексом index;
  • set(index: int, value: ValueType) записывает значение value в ячейку с индексом index.

Каждая из этих операций выполняется за \(O(1)\). Однако у массивов есть ограничение, накладываемое устройством памяти: в ячейках можно расположить объекты любого типа, а индексы могут быть только целыми числами.

В программе часто требуется получить объект не по порядковому номеру, а по другому признаку, например по названию, или, в терминологии программистов, получить значение (англ. value) по ключу (англ. key).

Пусть заданы множество городов и множество стран, причём одному городу отвечает единственная страна.

   города                страны
   ──────                ──────
   Новосибирск  ──┐
   Москва       ──┼───▶  Россия
   Казань       ──┘
   Минск        ─────▶   Беларусь
   Астана       ─────▶   Казахстан

Требуется написать программу-справочник, определяющую по городу страну. Если справочник будет пополняться, потребуется функция, добавляющая новую пару «город, страна».

Здесь город служит ключом, а страна — значением: каждому городу отвечает одна страна, а на страну может указывать любое количество городов.

Сопоставление, устроенное так, что каждому объекту первого множества (множество городов) отвечает единственный объект второго (множество стран), называется «отображением» (англ. map).

Отображение — другое название математического термина «функция», или «функциональная зависимость». Чаще всего функции отображают числа на оси X в числа на оси Y: для каждого числа из области определения можно выписать другое число — значение функции в этой точке.

Массивы также представляют собой частный случай отображения, сопоставляющий целому числу произвольное значение. Например, массив ["яблоко", "груша", "яблоко"] возвращает по числам 0 и 2 строку яблоко, а по числу 1 строку груша.

Для работы с отображениями необходима похожая на массив структура данных, позволяющая получать и сохранять значения:

  • get(key: KeyType) -> value возвращает значение value по ключу key;
  • set(key: KeyType, value: ValueType) записывает значение value по ключу key.

Однако вместо целочисленного индекса она должна допускать ключ произвольного типа.

Интерфейс, описывающий две этих операции, называют Map, а любую структуру, которая его реализует, называют «ассоциативным массивом» (англ. associative array).

Ассоциативные массивы

В большинстве языков программирования ассоциативные массивы встроены в язык.

Чаще всего их называют так же, как интерфейс, то есть Map, «отображение», или используют производные от слова «хеш-таблица»: Hash, HashMap, HashTable и даже просто Table. Эти названия подчёркивают способ реализации интерфейса отображения. Встречаются и другие: в Python ассоциативные массивы называют «словарями» (англ. dictionary).

Существует два распространённых способа реализовать (имплементировать) отображение в памяти: хеш-таблицы, которым посвящена настоящая глава, и деревья поиска, рассматриваемые в следующей. В некоторых языках имеются обе реализации: в Java это HashMap и TreeMap, а в C++ — std::unordered_map и std::map.

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

Наивная реализация ассоциативного массива

Простейший способ реализовать ассоциативный массив — завести обыкновенный массив или список пар (key, value).

Для получения элемента по ключу осуществляется проход по всем элементам списка, находится пара с подходящим ключом и возвращается её значение. Для сохранения значения ищется пара с указанным ключом и изменяется хранимое в ней значение, а если пара не найдена, новая пара добавляется в конец массива.

class NaiveMap:
    def __init__(self):
        self.pairs = []

    def get(self, key):
        for stored_key, value in self.pairs:
            if stored_key == key:  # пара с указанным ключом найдена
                return value
        return None

    def set(self, key, value):
        for i, (stored_key, _) in enumerate(self.pairs):
            if stored_key == key:  # пара с указанным ключом найдена
                # обновить значение в найденной паре
                self.pairs[i] = (key, value)
                return
        # пара с заданным ключом не найдена
        self.pairs.append((key, value))

Такая реализация является медленной: на поиск элемента уходит в среднем \(O(n)\) операций.

Хеш-таблица и хеш-функция

Хеш-таблица (англ. hash table) — способ реализации ассоциативного массива, при котором данные хранятся в виде пар (key, value), разложенных по ячейкам обыкновенного массива. Эти ячейки называются корзинами (англ. bucket) и пронумерованы. Номер корзины зависит от ключа, поэтому можно ввести функцию, которая вычисляет этот номер непосредственно по ключу, без перебора.

Принцип работы хеш-функции и хеш-таблицы

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

Эта проблема решается хешированием, для которого необходима хеш-функция.

Хеш-функцией (англ. hash function) называют функцию, преобразующую входные данные в целое число, причём для объектов разного типа применяются разные хеш-функции. Число, полученное на выходе, называется «хешем» или «хеш-суммой» (англ. hash, hash code или digest).

Пусть у каждой буквы алфавита есть индекс, равный её порядковому номеру. Хеш-функция может возвращать индекс первой буквы ключа. Тогда для яблока функция вернёт 33, для груши 4, а для сливы 19.

   ключ      первая буква   хеш
   ────      ────────────   ───
   яблоко         я          33
   груша          г           4
   слива          с          19

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

   корзины
   ┌────┬───────────────────┐
   │  4 │ груша: 120 кг     │  ← сюда попадает «груша»
   ├────┼───────────────────┤
   │ 19 │ слива: 45 кг      │
   ├────┼───────────────────┤
   │ 33 │ яблоко: 300 кг    │
   └────┴───────────────────┘

Другой пример хеш-функции — сумма номеров всех букв названия. Тогда для «яблоко» она даст 92, для «груша» 70, а для «слива» 46.

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

Пусть корзин \( M = 11 \), а хеш вычисляется второй функцией — суммой номеров букв. Для «груши» она даёт 70, и остаток \( 70 \bmod 11 = 4 \):

   ключ  ──[хеш-функция]──▶  хеш  ──[% M]──▶  номер корзины  ──▶  значение
  «груша»                     70              70 % 11 = 4          120 кг

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

Отображение из ключа в значение разделилось на три независимых отображения. 1. Сначала хеш-функция отображает ключ в число, то есть в хеш. 2. Затем полученный хеш преобразуется в индекс корзины. 3. Наконец, в массиве корзин по индексу находится нужная корзина; в ней лежит значение.

Коллизии

Ситуация, при которой хеш-функция для разных входных данных возвращает одно и то же значение, называется «коллизией».

Например, и у «арбуза», и у «абрикоса» хеш, вычисленный по первой букве, равен 1, и оба ключа указывают на одну корзину.

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

При методе цепочек в корзине лежит не одна пара, а связный список всех пар, относимых к этому номеру. Вычисляется корзина, после чего перебирается её список. При открытой адресации пара помещается в ближайшую свободную корзину, например в следующую по порядку; поиск идёт по правилу, выбранному при вставке, пока не встретится нужный ключ или пустая ячейка.

В обоих случаях стоимость операции зависит от числа ключей, делящих одну корзину. Пока хеш-функция распределяет ключи равномерно, а корзин достаточно, цепочки остаются короткими, и поиск, вставка и удаление стоят в среднем \(O(1)\). Если же коллизий много, хеш-таблица вырождается в список пар, с которого начиналось рассмотрение, и поиск снова обходится в \(O(n)\).

Коэффициент заполнения и перехеширование

Условие «корзин достаточно» выражается числом. Отношение количества ключей в таблице к количеству корзин называют коэффициентом заполнения (англ. load factor): пока он мал, цепочки коротки и почти все корзины пусты, а когда он приближается к единице, ключи теснятся и каждая операция сопровождается перебором.

Как только коэффициент превышает порог, выбранный реализацией (в CPython у словаря это две трети, у множества — три пятых), заводится новый массив корзин, в несколько раз больший прежнего, и все ключи раскладываются по нему заново: номер корзины вычисляется по модулю \(M\), а \(M\) изменилось. Эту перестройку называют перехешированием (англ. rehashing).

Стоит она \(O(n)\), но происходит редко и с каждым разом всё реже, поскольку таблица растёт не на одну корзину, а кратно. Та же арифметика рассмотрена в главе «Основные структуры данных» под заголовком «Реаллокация в динамических массивах»: редкие дорогие перестройки, распределённые по множеству дешёвых операций, дают амортизированную \(O(1)\).

Выбор размера хеш-таблицы и вычисление номера корзины

Вычисление номера корзины методом деления

Простейший способ получить номер корзины \(\mathrm{bucket}(k)\) для целого ключа \(k\) — взять остаток от деления \(k\) на число корзин \(M\). Остаток от деления обозначается \(k \bmod M\) и произносится «\(k\) по модулю \(M\)». В языках программирования остаток вычисляется оператором %.

Любое целое число \(k\) может быть представлено в форме \(k = j\cdot M + r\), где все числа целые, а \(r \in[0,M)\). Число \(r\) и называется остатком.

Остатки от деления на \(M\) лежат в диапазоне от \(0\) до \((M-1)\) — это те же числа, что служат номерами корзин в хеш-таблице длины \(M\).

В разных языках программирования остаток от деления по-разному работает с отрицательными числами (см. подробности), не всегда так, как математическая операция взятия по модулю. Например, выражение \(-13 \bmod 5\) в одних языках даст \(2\), в других \(-3\). Если этого не учесть, можно получить отрицательный номер корзины.

Выбор числа корзин

В качестве \(M\) лучше брать простое число, то есть делящееся без остатка только на себя и на 1.

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

Чем больше делителей у \(M\), тем чаще закономерности в ключах приводят к коллизиям; у простого числа делителей всего два, поэтому простое \(M\) уменьшает число столкновений.

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

Вычисление номера корзины методом умножения

Другой распространённый способ вычисления номера ячейки — метод умножения. Целое число обозначим на этот раз буквой \(h\), чтобы подчеркнуть, что это хеш ключа. Пусть количество корзин равно \(M\), а также зададим константу \(\alpha \in (0,1)\).

Тогда \(\mathrm{bucket}(h) = \left [ M \cdot \left { h \cdot \alpha \right } \right ]\) , где \(\left [ x \right ]\) обозначает целую часть числа \(x\), а \(\left { x \right }\) его дробную часть.

Алгоритм построения хеш-функции, опирающийся на метод умножения:

  • Хеш ключа \(h\) домножается на выбранную константу \(\alpha\).
  • От результата берётся дробная часть. Получается значение из диапазона \([0,1)\). Числа для разных ключей окажутся равномерно рассеяны.
  • Полученное значение домножается на размер таблицы \(M\). Числа получаются распределёнными в диапазоне \([0, M)\)
  • От результата берётся целая часть. С равными вероятностями получаются числа от \(0\) до \((M−1)\) — номера корзин. Равномерно заполняемые корзины дают меньше коллизий.

Дональд Кнут показал, что хеш-функция получается хорошей, если брать в качестве \(\alpha\) число, обратное золотому сечению. $$ \displaystyle \alpha = \phi^{-1} = \left ( \frac{\sqrt 5 -1}{2} \right ) = 0.6180339887... $$

Свойства хеш-функций

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

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

Например, пусть требуется выяснить стоимость товара в магазине. Если функция потратит на вычисление позиции элемента больше времени, чем ушло бы на перебор всех товаров на полках, применять её не имеет смысла.

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

  • Детерминизм. Для одного и того же ключа функция всегда возвращает одинаковое значение. Если внутри используется случайное число, повторный вызов может вернуть новый индекс ячейки, и данные из хеш-таблицы окажутся недоступны. Например, взятие числа по модулю другого числа детерминировано: результат, вычисленный дважды, не изменится.
  • Эффективность. Хеш-функция, вызываемая на каждой операции, должна вычисляться быстро. Сложность операции поиска складывается из сложности вычисления хеша и сложности поиска данных по хешу.
  • Ограниченность. Результат функции должен принадлежать диапазону, задаваемому размером хеш-таблицы. Поэтому обычно сначала вычисляется хеш-функция, возвращающая произвольное число, а затем результат берётся по модулю \(M\), равного размеру хеш-таблицы. Тогда любой ключ окажется в интервале от \(0\) до \(M-1\), и обращения к индексу, не отвечающему ни одной из корзин, не произойдёт.
  • Равномерность. Данные должны быть распределены по хеш-таблице равномерно, то есть каждое выходное значение равновероятно: при запуске функции для каждого элемента большого списка разных объектов получится примерно равное число ответов на каждое значение от \(0\) до \(M-1\). В противном случае в некоторые ячейки запись будет производиться чаще, чем в остальные, и обращение к таблице замедлится. Пример неравномерной функции — среднесуточная температура по городу и дате: большую часть времени она держится на одном уровне, а аномальные морозы или жара случаются редко.

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

  • Лавинность. При незначительном изменении входных данных выходное значение должно меняться значительно. При хешировании паролей функция, лишённая лавинности, взламывается проще. Если злоумышленнику известно, что \(h(aa) = 22\), а \(h(bb) = 33\), то он может предположить такой \(password\), что \(h(password) = 44\). Перебрав небольшое число вариантов, он подберёт пароль по его хешу.
  • Необратимость. Невозможно восстановить ключ по значению функции. Например, при регистрации на сайте к паролю пользователя применяется хеш-функция, и полученный хеш записывается в базу данных. Каждый раз, когда пароль вводится заново, к нему применяется та же функция, и новый хеш совпадёт с записью в базе, если введён исходный пароль. Даже получив доступ к базе, злоумышленник не должен иметь возможности восстановить пароль по украденным хешам.

Построение хеш-функций для строк

Хеш-функции для строк вычисляются «полиномиальным хешированием» (от слова «полином», то есть многочлен). Хеш строки \(s\) вычисляется по формуле: $$ h(s)=\left(s_{1} q^{n-1}+s_{2} q^{n-2}+\cdots+s_{n-1} q+s_{n}\right) \bmod R $$ Здесь \(s_i\) обозначает код \(i\)-го символа (например, ASCII-код), \(n\) — длину строки, а \(R\) и \(q\) — выбранные константы.

Все вычисления производятся по модулю большого числа \(R\), поскольку для длинных строк сумма в скобках не помещается в целочисленный тип: в большинстве языков программирования произойдёт переполнение. В языках с «длинной арифметикой» переполнения не случится, однако с ростом чисел вычисления будут замедляться.

Выбор числа \(R\) зависит от типа переменной, отведённой под хеши. Например, для беззнаковых 4-байтовых целых чисел удобно выбрать \(R=2^{32}\), а для 8-байтовых можно взять \(R=2^{64}\).

Если строки длинные, выбор величины \(q\) не столь важен, поскольку в сумму войдут значения \(q\), возведённые в достаточно большие степени. Однако ради универсальности предпочтительно взять большое простое число.

Номер корзины получается как остаток от деления хеша на число корзин. Чем меньше общих делителей у \(q\), \(M\) и \(R\), тем реже коллизии. В качестве \(R\) выше было условлено использовать \(2^{32}\), поэтому любое нечётное число не будет иметь с \(R\) общих делителей.

Число \(M\) меняется вслед за размером хеш-таблицы, ещё не известным в момент вычисления хеша, поэтому нельзя гарантировать, что заданное заранее \(q\) и число корзин \(M\) окажутся взаимно простыми. Однако большое простое \(q\) делает нетривиальные общие делители маловероятными, поскольку у простого числа нет делителей, кроме него самого и единицы. \(M\) обычно также выбирают простым, поэтому общие делители появятся, только когда \(M=q\).

Реализация хеш-таблицы на цепочках

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

ALPHABET = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя'


def letters_hash(key):
    # Хеш — сумма номеров букв, входящих в ключ
    return sum(ALPHABET.index(letter) + 1 for letter in key)


class HashMap:
    def __init__(self, buckets_count=11):
        # В каждой корзине лежит список пар: это и есть метод цепочек
        self.buckets = [[] for _ in range(buckets_count)]

    def _bucket(self, key):
        # Хеш превращается в номер корзины остатком от деления
        return self.buckets[letters_hash(key) % len(self.buckets)]

    def get(self, key):
        # Перебираем цепочку, привязанную к корзине, а не всю таблицу
        for stored_key, value in self._bucket(key):
            if stored_key == key:
                return value
        return None

    def set(self, key, value):
        bucket = self._bucket(key)
        for i, (stored_key, _) in enumerate(bucket):
            if stored_key == key:
                bucket[i] = (key, value)
                return
        bucket.append((key, value))

У «груши» сумма номеров букв равна 70, и остаток \( 70 \bmod 11 = 4 \), а у «яблока» сумма 92, и остаток \( 92 \bmod 11 = 4 \) — тот же. Это коллизия: два ключа делят одну корзину и хранятся в ней списком:

warehouse = HashMap()
warehouse.set('груша', 120)
warehouse.set('яблоко', 300)

print(letters_hash('груша') % 11)   # 4
print(letters_hash('яблоко') % 11)  # 4
print(warehouse.buckets[4])         # [('груша', 120), ('яблоко', 300)]
print(warehouse.get('яблоко'))      # 300

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

bucket_index = hash('груша') % 11

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

Поисковый индекс

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

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

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

Отображение, которое ставит каждому объекту в соответствие его расположение, называют «поисковым индексом».

Хеш-таблица должна находить позиции любой подстроки, а значит, в неё необходимо заранее добавить все подстроки текста. Если подстроки в таблице нет, нет её и в тексте.

Позиция при этом получается сразу, однако такой способ расточителен по памяти. Подстрок в тексте столько, сколько способов выбрать две границы: \(N\cdot(N+1)/2\), где \(N\) — длина текста. Следовательно, в хеш-таблицу требуется добавить \(O(N^2)\) пар (key, value). Поскольку ключами служат сами подстроки, каждая пара займёт в среднем \(O(N)\) памяти, а всего — \(O(N^3)\).

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

На практике индексируют не все подстроки, а слова, разделённые пробелами. Текст разбивается на слова, и в хеш-таблицу помещаются пары из слова и списка его позиций. Таких пар \(O(N)\), памяти требуется линейное количество, а поиск по целому слову остаётся мгновенным. Так устроены поисковые системы и полнотекстовый поиск в базах данных, только вместо списка позиций там хранятся более сложные структуры.

Резюме

  • Хеш-таблица превращает поиск по ключу в вычисление: хеш-функция вычисляет по ключу число, остаток от деления этого числа на количество корзин даёт номер ячейки, а далее работает обычный массив.
  • Коллизии неизбежны у любой хеш-функции, и разрешаются они либо методом цепочек, когда в корзине хранится список пар, либо открытой адресацией, когда пара помещается в ближайшую свободную ячейку.
  • Ожидаемая \(O(1)\) обеспечивается двумя условиями: хеш-функция распределяет ключи равномерно, а корзин достаточно. Второе обеспечивается перехешированием, стоящим \(O(n)\) на редких перестройках и потому не ухудшающим среднюю стоимость.
  • От хеш-функции требуются детерминизм, эффективность, ограниченность и равномерность, а от криптографической — дополнительно лавинность и необратимость.
  • Число корзин выбирается простым, чтобы закономерности, скрытые во входных ключах, не приводили раз за разом в одну ячейку.
  • Строки хешируются полиномиально, по модулю большого числа \(R\), выбранного взаимно простым с основанием \(q\) и числом корзин.
  • Поисковый индекс представляет собой ту же хеш-таблицу, где ключом служит слово, а значением — список его позиций; на нём основан и полнотекстовый поиск в базах данных.

Деревья

В массиве, связном списке, стеке и очереди элементы выстроены друг за другом. Однако файловая система на диске, структура HDF5-файла с результатами измерений, иерархия объёмов детектора в Geant4, дерево вызовов, собранное профилировщиком, устроены как иерархии. Для их описания существует отдельная структура данных — дерево.

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

Понятие дерева

Деревом называют набор узлов (вершин), соединённых рёбрами, в котором выполнены три условия:

  • есть один выделенный узел, корень (англ. root);
  • у каждого узла, кроме корня, в точности один родитель (англ. parent);
  • от корня до любого узла можно дойти по рёбрам, причём единственным способом, ведущим туда.

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

Основная терминология:

  • потомками (детьми) узла называют узлы, которым он приходится родителем;
  • листом (англ. leaf) называется узел без потомков;
  • внутренний узел имеет хотя бы одного потомка;
  • поддерево составляют узел вместе со всеми его потомками, потомками потомков и так далее; каждый узел можно считать корнем собственного поддерева, подвешенного под ним;
  • глубина узла равна числу рёбер, пройденных на пути от корня до узла (у корня глубина 0);
  • уровень объединяет узлы одной глубины;
  • высота дерева равна наибольшей глубине, найденной среди всех узлов.

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

Главным свойством дерева является рекурсивность. Дерево складывается из корня и поддеревьев, каждое из которых само является деревом. Поэтому почти все алгоритмы для деревьев записываются рекурсивно и сводятся к тому, чтобы обработать корень, а затем рекурсивно обработать его поддеревья. Базовый случай — пустое дерево.

Двоичные деревья и класс Node

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

В Python узел дерева описывается классом со ссылками на потомков, как элемент связного списка, только ссылок теперь две:

class Node:
    def __init__(self, value, left=None, right=None):
        self.value = value  # данные, хранящиеся в узле
        self.left = left    # ссылка на левого потомка (или None)
        self.right = right  # ссылка на правого потомка (или None)

Соберём дерево из пяти узлов:

      5
     / \
    3   8
   / \
  1   4
root = Node(5, Node(3, Node(1), Node(4)), Node(8))

Всё дерево представлено одной ссылкой на корень, от которого достижим любой узел. Пустое дерево — None. Поэтому функции, работающие с деревьями, принимают корень и первым делом проверяют, не равен ли он None; это и есть базовый случай рекурсии.

Обходы дерева

Обойти дерево значит побывать в каждом узле по одному разу. В отличие от массива, у дерева есть несколько естественных порядков обхода. Они делятся на два семейства: обходы в глубину (DFS, англ. depth-first search), уходящие вниз по ветви, и обход в ширину (BFS, англ. breadth-first search), идущий по уровням.

Обходы в глубину

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

def preorder(root):        # корень -> левое -> правое
    if root is None:
        return
    print(root.value)      # обрабатываем узел до потомков
    preorder(root.left)
    preorder(root.right)


def inorder(root):         # левое -> корень -> правое
    if root is None:
        return
    inorder(root.left)
    print(root.value)      # обрабатываем узел между потомками
    inorder(root.right)


def postorder(root):       # левое -> правое -> корень
    if root is None:
        return
    postorder(root.left)
    postorder(root.right)
    print(root.value)      # обрабатываем узел после потомков

Для дерева, изображённого выше, preorder выведет 5 3 1 4 8, inorder выдаст 1 3 4 5 8, а postorder даст порядок 1 4 3 8 5.

Pre-order подходит для копирования и сериализации сверху вниз: сначала создаётся узел, затем его потомки. Post-order применяется для удаления и для вычислений снизу вверх, когда результат в узле зависит от значений, вычисленных в поддеревьях; так находят высоту, размер поддеревьев, значение арифметического выражения, разобранного в дерево. In-order выдаёт элементы дерева поиска в отсортированном порядке.

Обход в ширину

Обход в ширину идёт по уровням: сначала корень, затем все узлы глубины 1, затем глубины 2 и так далее. Рекурсией здесь не обойтись: требуется очередь из главы о базовых структурах:

from collections import deque


def level_order(root):
    if root is None:
        return
    queue = deque([root])
    while queue:
        node = queue.popleft()      # берём узел из начала очереди
        print(node.value)
        if node.left:               # и ставим его потомков в конец
            queue.append(node.left)
        if node.right:
            queue.append(node.right)

Для того же дерева получится 5 3 8 1 4, по уровням. Любой обход посещает каждый узел по одному разу (обход в глубину вдобавок проходит по каждому ребру дважды, вниз и обратно), поэтому все они работают за \( O(n) \), где \( n \) — число узлов. Дополнительной памяти требуется \( O(h) \) на стек рекурсии у DFS (где \( h \) — высота дерева) и до \( O(n) \) на очередь у BFS, вмещающую самый широкий уровень.

Двоичное дерево поиска

Двоичным деревом поиска (англ. binary search tree, BST) называют двоичное дерево, в каждом узле которого выполняется свойство порядка: все значения в левом поддереве строго меньше значения узла, а все значения в правом строго больше.

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

def find(root, key):
    while root is not None and root.value != key:
        root = root.left if key < root.value else root.right
    return root  # узел с ключом key или None, если ключа нет

При вставке спуск выполняется так же, как при поиске, а новый узел подвешивается там, где спуск достиг None:

def insert(root, key):
    if root is None:
        return Node(key)          # нашли свободное место
    if key < root.value:
        root.left = insert(root.left, key)
    elif key > root.value:
        root.right = insert(root.right, key)
    return root                   # ключ уже есть — ничего не меняем

Поиск, вставка и удаление стоят \( O(h) \), поскольку проходят один путь от корня вниз. In-order-обход дерева поиска выдаёт ключи по возрастанию: сначала всё меньшее (левое поддерево), затем узел, затем всё большее. Получается структура, обеспечивающая одновременно быстрый поиск, вставку, удаление и перебор по порядку. Ни отсортированный массив с его медленной вставкой, ни хеш-таблица, лишённая порядка, этого не обеспечивают.

Сбалансированность

Эффективность BST определяется высотой \( h \), задающей цену любой операции. Если вставлять ключи в случайном порядке, \( h \approx \log_2 n \). Но если вставить ключи 1, 2, 3, ..., n по порядку, каждый новый узел уйдёт направо, и дерево выродится в связный список высоты \( n \). Все операции станут линейными.

Дерево называется сбалансированным, если у каждого узла высоты левого и правого поддеревьев отличаются не больше чем на единицу. У такого дерева \( h = O(\log n) \), а значит, все операции работают за \( O(\log n) \).

Сохранять сбалансированность при любых вставках и удалениях позволяют самобалансирующиеся деревья (АВЛ-дерево, красно-чёрное дерево), выполняющие после каждой операции небольшие локальные перестройки, называемые поворотами. Именно они стоят за std::map в C++ и TreeMap в Java, упомянутыми в главе про хеш-таблицы. В стандартной библиотеке Python дерева поиска нет, поскольку обычно достаточно dict, set и модуля bisect для отсортированного списка.

Куча

Куча (англ. heap) представляет собой структуру данных для задач, в которых требуется быстро находить максимум (или минимум) в наборе элементов, меняющемся в процессе работы. В невозрастающей куче (max-heap) выполняется основное свойство кучи: значение любого узла не меньше значений его потомков. Следовательно, в корне всегда находится максимум. В min-heap наоборот, в корне минимум.

В отличие от дерева поиска, куча не упорядочивает элементы полностью: между левым и правым поддеревьями никакого порядка нет. Взамен куча всегда остаётся почти полным двоичным деревом. Все уровни, кроме последнего, заполнены целиком, а последний заполняется слева направо по мере роста. Такое дерево не бывает вырожденным, и его высота всегда \( O(\log n) \).

Куча в массиве

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

Куча:                Массив:

      9              [9, 7, 8, 3, 1, 4]
     / \              0  1  2  3  4  5
    7   8
   / \  /
  3  1 4

У узла с индексом \( i \) потомки лежат в ячейках \( 2i + 1 \) и \( 2i + 2 \), а родитель в ячейке \( \lfloor (i-1)/2 \rfloor \). Классы и указатели не требуются: любой массив чисел уже представляет собой дерево, разложенное по уровням.

Куча основана на двух операциях, называемых просеиванием:

  • просеивание вверх (sift up): элемент, дописанный в конец массива, меняется местами с родителем, пока он больше родителя;
  • просеивание вниз (sift down): корень, ставший меньше потомков, меняется местами с бóльшим из них и опускается, пока свойство кучи не восстановится.

Оба просеивания проходят не больше одного пути от корня до листа, то есть стоят \( O(\log n) \). На них строится приоритетная очередь: добавление элемента за \( O(\log n) \), просмотр максимума за \( O(1) \), извлечение максимума (последний элемент переставляется в корень и просеивается вниз) за \( O(\log n) \).

В Python куча реализована модулем heapq как min-heap над обычным списком, передаваемым в функции модуля:

import heapq

distances = [5.2, 1.1, 3.7, 0.4]
heapq.heapify(distances)          # превращает список в кучу за O(n)
heapq.heappush(distances, 2.6)    # добавление за O(log n)
print(heapq.heappop(distances))   # 0.4 — минимум, за O(log n)

Если требуется max-heap, в куче хранят элементы с обратным знаком. Этот приём применяется в следующей главе, в алгоритме, строящем остовное дерево.

Пирамидальная сортировка

Куча даёт ещё один способ сортировать за \( O(n \log n) \), пирамидальную сортировку (англ. heap sort). Она состоит из двух этапов:

  1. Превратить массив в max-heap, просеяв вниз все внутренние узлы, начиная с последнего и заканчивая корнем.
  2. Повторять \( n - 1 \) раз: обменять корень (текущий максимум) с последним элементом ещё не отсортированной части и просеять новый корень вниз. Снятые максимумы один за другим встают в конец массива на окончательные места.

Дополнительной памяти heap sort, в отличие от сортировки слиянием, не требует: всё происходит в исходном массиве. Неудачных входов у него, в отличие от быстрой сортировки, не бывает: любой массив обрабатывается за \( O(n \log n) \). Платой являются неустойчивость и несколько большая константа, из-за которой на практике heap sort обычно немного медленнее quick sort. Полная реализация рассматривается ниже в задачах.

Деревья в физических задачах

Ниже приведены три типичных сюжета из вычислительной физики.

Поиск соседей и k-d деревья. В молекулярной динамике, методе SPH или при кластеризации точек, измеренных в эксперименте, постоянно требуется выяснять, какие частицы находятся рядом с выбранной. Перебор всех пар стоит \( O(N^2) \). k-d дерево (k-dimensional tree) рекурсивно делит пространство плоскостями пополам, и запрос ближайших соседей точки выполняется в среднем за \( O(\log N) \). В SciPy оно уже реализовано:

import numpy as np
from scipy.spatial import KDTree

points = np.random.rand(100_000, 3)      # частицы в единичном кубе
tree = KDTree(points)                    # построение за O(N log N)

dist, idx = tree.query(points[0], k=10)          # 10 ближайших соседей
neighbors = tree.query_ball_point(points[0], r=0.05)  # все соседи в радиусе

Гравитационная задача N тел и октодеревья. Прямое суммирование сил между \( N \) телами требует \( O(N^2) \) операций на шаг, что для галактики из миллиардов звёзд неприемлемо. Алгоритм Барнса — Хата строит октодерево (англ. octree): куб пространства, вмещающий всю систему, рекурсивно делится на 8 октантов, пока в каждом листе не останется по частице. Для далёкой группы частиц незачем суммировать вклады по одной, достаточно слагаемого от её суммарной массы, приложенной в центре масс и записанной в узле дерева. Критерий: если угловой размер ячейки \( s/d \) меньше порога \( \theta \), ячейка не раскрывается. Сложность снижается до \( O(N \log N) \), и такие деревья вместе с более сложным быстрым мультипольным методом работают внутри современных астрофизических кодов наподобие GADGET.

Иерархические данные. Формат HDF5, стандарт де-факто для больших численных данных, устроен как дерево: группы содержат подгруппы и датасеты, как каталоги и файлы. Деревом являются и геометрия детектора в Geant4 из объёмов, вложенных в объёмы, и символьное выражение в SymPy, где узлами служат операции, а листьями — переменные и константы. Иерархия почти наверняка означает, что в основе лежат дерево и рекурсивные обходы.

Разбор задач

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

Задача 1. Максимальная глубина

Условие. Дан корень двоичного дерева. Требуется найти его максимальную глубину, то есть наибольшее число узлов, лежащих на пути от корня до листа (включая корень и лист).

Идея решения. Глубина дерева равна глубине более глубокого из поддеревьев плюс единица за сам корень. Базовый случай — пустое дерево глубины 0.

def max_depth(root):
    # Базовый случай: пустое дерево не добавляет к глубине ничего
    if root is None:
        return 0
    # Более глубокое из поддеревьев плюс сам корень
    return 1 + max(max_depth(root.left), max_depth(root.right))

Сложность. По времени \( O(n) \), так как каждый узел посещается один раз. По памяти \( O(h) \) на стек рекурсии, где \( h \) — высота дерева.

Задача 2. Сбалансированное дерево

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

Идея решения. Прямолинейное решение: в каждом узле сравнивать высоты поддеревьев, вычисленные функцией из предыдущей задачи. Но тогда высота каждого узла будет вычисляться заново на каждом уровне выше него, и в худшем случае получится \( O(n^2) \).

Лучше совместить оба вычисления в одном обходе. Пусть функция возвращает высоту поддерева, а для несбалансированного поддерева значение-сигнал -1, немедленно передаваемое наверх без дальнейших проверок.

def check_height(root):
    """Высота поддерева или -1, если оно несбалансировано."""
    if root is None:
        return 0

    left = check_height(root.left)
    if left == -1:                 # слева уже нашли дисбаланс
        return -1

    right = check_height(root.right)
    if right == -1:                # справа уже нашли дисбаланс
        return -1

    if abs(left - right) > 1:      # дисбаланс в текущем узле
        return -1

    return 1 + max(left, right)


def is_balanced(root):
    return check_height(root) != -1

Сложность. По времени \( O(n) \): один post-order-обход, высота каждого узла вычисляется однократно. По памяти \( O(h) \) на стек рекурсии.

Задача 3. Дерево поиска

Условие. Требуется проверить, является ли двоичное дерево деревом поиска: значения, лежащие в левом поддереве каждого узла, строго меньше значения узла, а в правом строго больше.

Идея решения. Частая ошибка — проверять только соседние узлы: left.value < node.value < right.value. Этого недостаточно:

Дерево поиска:        Не дерево поиска:
      5                     5
     / \                   / \
    3   8                 3   8
   / \                   / \
  1   4                 1   6

В правом дереве узел 6 больше своего родителя 3, и локально условие выполнено. Однако 6 находится в левом поддереве корня 5, а значит, обязан быть меньше 5. Условие является глобальным: каждый узел ограничен всеми своими предками.

Будем спускаться по дереву и передавать вниз интервал разрешённых узлу значений \( (\text{low}, \text{high}) \). При переходе налево ужесточается верхняя граница значением узла, при переходе направо — нижняя.

def is_bst(root, low=float('-inf'), high=float('inf')):
    if root is None:
        return True
    # Значение узла обязано лежать строго внутри интервала предков
    if not (low < root.value < high):
        return False
    return (is_bst(root.left, low, root.value)      # слева всё < root.value
            and is_bst(root.right, root.value, high))  # справа всё > root.value

Альтернатива: выполнить in-order-обход и проверить, что выданные им значения идут строго по возрастанию. Для дерева поиска это является критерием.

Сложность. По времени \( O(n) \), по памяти \( O(h) \) на стек рекурсии.

Задача 4. Сколько существует деревьев поиска

Условие. Требуется определить, сколько различных двоичных деревьев поиска можно построить из всех чисел от 1 до \( n \). Число \( n \) не превосходит 20.

Идея решения. Выберем корнем число \( k \). Тогда числа \( 1, \dots, k-1 \) обязаны попасть в левое поддерево, а \( k+1, \dots, n \) в правое, причём каждое из поддеревьев само является деревом поиска. Число деревьев из \( m \) последовательных чисел зависит только от \( m \), поэтому получаем рекуррентность

\[ C_n = \sum_{k=1}^{n} C_{k-1} , C_{n-k}, \qquad C_0 = 1. \]

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

\[ C_n = \frac{1}{n+1} \binom{2n}{n} = \frac{(2n)!}{n! , (n+1)!}. \]

from math import comb


def count_bst(n):
    # Число Каталана: C(2n, n) / (n + 1), деление всегда нацело
    return comb(2 * n, n) // (n + 1)

Если формула неизвестна, ответ вычисляется по рекуррентности динамическим программированием:

def count_bst_dp(n):
    counts = [1] + [0] * n           # counts[0] = 1: пустое дерево одно
    for size in range(1, n + 1):
        for root in range(1, size + 1):
            # root - 1 чисел уходит налево, size - root — направо
            counts[size] += counts[root - 1] * counts[size - root]
    return counts[n]

Вычисления необходимо вести в целых числах. Наивное fac(2*n) / (fac(n) * fac(n+1)) считает через float с примерно 16 значащими цифрами, а промежуточный \( 40! \approx 8{,}16 \cdot 10^{47} \) точно в нём не представим. До \( n = 30 \) ответ всё же получается верным: ошибки в числителе и знаменателе компенсируют друг друга при делении. А \( C_{31} \) наивная формула уже занижает на единицу. Целочисленный вариант ничем не сложнее.

Сложность. Формула с биномиальным коэффициентом требует \( O(n) \) умножений; вариант с динамическим программированием обходится в \( O(n^2) \) операций и \( O(n) \) памяти.

Задача 5. Удаление узла из дерева поиска

Условие. Дано дерево поиска с уникальными целыми ключами. Требуется удалить узел с заданным ключом так, чтобы дерево осталось корректным деревом поиска, и вернуть корень изменённого дерева. Если ключа нет, дерево возвращается нетронутым. Создавать новые узлы нельзя, сложность должна быть \( O(h) \).

Идея решения. Спускаемся к удаляемому узлу, как при обычном поиске. Далее возможны три случая:

  1. Если у узла нет потомков, он убирается, и родитель начинает ссылаться на None.
  2. При одном потомке он подвешивается на место удаляемого узла.
  3. При двух потомках находится преемник, минимальный узел правого поддерева (переход направо, затем налево до упора). Преемник больше всего левого поддерева и меньше остального правого, поэтому может встать на освободившееся место. Он вырезается со старого места, где у него нет левого потомка и работает случай 1 или 2, и переставляется наверх.
def remove(root, key):
    if root is None:                     # ключа в дереве нет
        return None

    if key < root.value:                 # ищем слева
        root.left = remove(root.left, key)
    elif key > root.value:               # ищем справа
        root.right = remove(root.right, key)
    else:                                # нашли узел с ключом key
        if root.left is None:            # случаи 1 и 2: нет левого потомка
            return root.right
        if root.right is None:           # случай 2: нет правого потомка
            return root.left

        # Случай 3: ищем преемника — минимум правого поддерева
        parent, succ = root, root.right
        while succ.left is not None:
            parent, succ = succ, succ.left

        # Вырезаем преемника со старого места...
        if parent.left is succ:
            parent.left = succ.right
        else:
            parent.right = succ.right

        # ...и ставим его на место удаляемого узла
        succ.left = root.left
        succ.right = root.right
        return succ

    return root

Сложность. По времени \( O(h) \), поскольку спуск к узлу и спуск к преемнику вместе образуют один путь от корня вниз. По памяти \( O(h) \) на рекурсию поиска (её несложно переписать циклом до \( O(1) \)).

Задача 6. Пирамидальная сортировка

Условие. По результатам соревнования дан список участников: логин, число решённых задач \( P \) и штраф \( F \). Требуется отсортировать таблицу: выше стоит участник, у которого больше решённых задач; при равенстве тот, у кого меньше штраф; если и штрафы равны, тот, чей логин идёт раньше по алфавиту. Кучу необходимо реализовать самостоятельно, встроенными сортировками и heapq пользоваться нельзя.

Ввод:                  Вывод:
5                      gena
alla 4 100             timofey
gena 6 1000            alla
gosha 2 90             gosha
rita 2 90              rita
timofey 4 80

Идея решения. Это сортировка по составному ключу (см. раздел о сортировке по ключу в главе о сортировках). Подберём ключ так, чтобы обычное сравнение кортежей «меньше» означало «выше в таблице»: число решённых задач берётся с минусом (чем больше задач, тем меньше ключ), а штраф и логин остаются как есть:

def sort_key(user):
    login, solved, penalty = user
    return (-solved, penalty, login)

Остаётся отсортировать массив по этому ключу пирамидальной сортировкой. Строим max-heap в исходном массиве, затем раз за разом переставляем максимум, то есть худшего из оставшихся участников, в конец неотсортированной части:

def sift_down(items, start, end):
    """Просеивает элемент start вниз внутри items[start:end + 1]."""
    root = start
    while True:
        child = 2 * root + 1               # левый потомок
        if child > end:                    # потомков нет — дно кучи
            break
        # Из двух потомков выбираем большего
        if child + 1 <= end and sort_key(items[child]) < sort_key(items[child + 1]):
            child += 1
        if sort_key(items[root]) < sort_key(items[child]):
            items[root], items[child] = items[child], items[root]
            root = child                   # спускаемся за элементом дальше
        else:
            break                          # свойство кучи восстановлено


def heap_sort(items):
    n = len(items)
    # Этап 1: строим кучу — просеиваем все внутренние узлы справа налево
    for start in range((n - 2) // 2, -1, -1):
        sift_down(items, start, n - 1)
    # Этап 2: снимаем максимум с вершины кучи в конец массива
    for end in range(n - 1, 0, -1):
        items[0], items[end] = items[end], items[0]
        sift_down(items, 0, end - 1)


def main():
    n = int(input())
    users = []
    for _ in range(n):
        login, solved, penalty = input().split()
        users.append((login, int(solved), int(penalty)))

    heap_sort(users)

    print('\n'.join(user[0] for user in users))


if __name__ == '__main__':
    main()

После этапа 1 худший по ключу участник стоит в корне кучи, в ячейке 0. Обмен с последним элементом ставит его на окончательное место, а однократное просеивание вниз восстанавливает кучу в укоротившейся части массива. В итоге массив отсортирован по возрастанию ключа, и лучшие участники стоят в начале.

Сложность. Построение кучи занимает \( O(n) \), поскольку низкие узлы просеиваются на малую глубину, а затем выполняются \( n - 1 \) просеиваний по \( O(\log n) \). Итого \( O(n \log n) \) по времени в худшем случае и \( O(1) \) дополнительной памяти: сортировка выполняется на месте. В рабочем коде достаточно sorted(users, key=sort_key).

Резюме

  • Дерево является рекурсивной структурой из корня и поддеревьев, каждое из которых само дерево. Базовый случай алгоритмов для него — пустое дерево None.
  • Обходы в глубину (pre-, in-, post-order) записываются рекурсией, обход в ширину — очередью, хранящей текущий уровень; все стоят \( O(n) \).
  • Дерево поиска обеспечивает поиск, вставку и удаление за \( O(h) \); чтобы высота \( h \) была \( O(\log n) \), дерево должно быть сбалансированным.
  • Куча укладывается в массив как почти полное дерево, отдаёт максимум за \( O(1) \), вставку и извлечение за \( O(\log n) \); на ней построены приоритетная очередь и heap sort.
  • В физических расчётах деревья ускоряют поиск соседей (k-d деревья), задачу N тел (октодеревья Барнса — Хата) и хранят иерархические данные в HDF5.

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

Графы

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

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

К графам сводится множество практических задач, на первый взгляд не имеющих к ним отношения.

Определения

Граф \( G = (V, E) \) складывается из множества вершин \( V \) (англ. vertices) и множества рёбер \( E \) (англ. edges), каждое из которых соединяет пару вершин.

Основные разновидности:

  • Неориентированный граф имеет рёбра, не снабжённые направлением: если из \( u \) можно попасть в \( v \), то и обратно тоже (дороги с двусторонним движением).
  • Ориентированный граф (орграф, англ. directed graph) снабжает рёбра направлением, и ребро \( (u, v) \), проведённое в одну сторону, не означает существования ребра \( (v, u) \) (односторонние улицы, ссылки между веб-страницами, зависимости, связывающие задачи).
  • Взвешенный граф приписывает каждому ребру число: длину дороги, сопротивление проводника, пропускную способность канала, энергию связи.

Терминология:

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

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

Способы хранения графа

От способа хранения зависят объём памяти и сложность операций; основных вариантов два.

Матрица смежности

Квадратная таблица \( n \times n \), где на пересечении \( i \)-й строки и \( j \)-го столбца стоит единица (или вес ребра), если ребро, ведущее из \( i \) в \( j \), существует, и ноль в противном случае. У неориентированного графа матрица симметрична.

n = 5
matrix = [[0] * n for _ in range(n)]  # граф без рёбер

def add_edge_matrix(matrix, u, v, directed=False):
    """Добавляет ребро в матрицу смежности (вершины нумеруются с нуля)."""
    matrix[u][v] = 1
    if not directed:
        matrix[v][u] = 1

Проверка наличия ребра между \( u \) и \( v \) выполняется за \( O(1) \). Однако памяти требуется \( O(n^2) \) независимо от числа рёбер, а перебор всех соседей вершины требует \( O(n) \), поскольку приходится просмотреть всю строку, даже если сосед всего один.

Список смежности

Для каждой вершины хранится список соседей, связанных с ней ребром.

from collections import defaultdict

graph = defaultdict(list)

def add_edge_list(graph, u, v, directed=False):
    """Добавляет ребро в список смежности."""
    graph[u].append(v)
    if not directed:
        graph[v].append(u)

Памяти требуется \( O(n + m) \), где \( m \) — число рёбер, а перебор соседей занимает время, пропорциональное их количеству. Платой является проверка конкретного ребра за \( O(\deg v) \) вместо \( O(1) \).

Выбор между ними. Для плотного графа, где число рёбер сравнимо с \( n^2 \), применяется матрица, для разреженного, где рёбер порядка \( n \), — список. Реальные графы почти всегда разреженные: у человека в социальной сети сотни друзей, а не миллионы, у атома в молекуле единицы связей. По умолчанию выбирается список смежности.

ОперацияМатрица смежностиСписок смежности
Память\( O(n^2) \)\( O(n + m) \)
Проверить ребро \( (u,v) \)\( O(1) \)\( O(\deg u) \)
Перебрать соседей \( u \)\( O(n) \)\( O(\deg u) \)
Добавить ребро\( O(1) \)\( O(1) \)

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

Обходы графа

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

Поиск в глубину (DFS)

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

def dfs_recursive(graph, vertex, visited=None):
    """Рекурсивный обход в глубину. Возвращает вершины в порядке обхода."""
    if visited is None:
        visited = set()
    visited.add(vertex)
    order = [vertex]
    for neighbour in sorted(graph[vertex]):
        if neighbour not in visited:
            order.extend(dfs_recursive(graph, neighbour, visited))
    return order

Рекурсивная запись удобна для чтения, но опасна на практике. Глубина рекурсии в Python по умолчанию ограничена примерно 1000, и на графе из ста тысяч вершин программа завершится с RecursionError. Поэтому DFS реализуют итеративно, заменяя стек вызовов явным стеком:

def dfs_iterative(graph, start):
    """Итеративный обход в глубину через явный стек."""
    visited = set()
    order = []
    stack = [start]
    while stack:
        vertex = stack.pop()
        if vertex in visited:
            continue
        visited.add(vertex)
        order.append(vertex)
        # соседей кладём в обратном порядке, чтобы снимать по возрастанию
        for neighbour in sorted(graph[vertex], reverse=True):
            if neighbour not in visited:
                stack.append(neighbour)
    return order

Проверка if vertex in visited стоит после снятия со стека, а не только перед добавлением. Одна и та же вершина попадает в стек по нескольку раз от разных соседей, поэтому отсеивать её необходимо в момент обработки.

Поиск в ширину (BFS)

Сначала посещаются все соседи стартовой вершины, затем соседи соседей и так далее, по расходящимся кругам. От DFS отличие в одну строку: вместо стека используется очередь.

from collections import deque

def bfs(graph, start):
    """Обход в ширину. Возвращает вершины в порядке обхода."""
    visited = {start}
    order = []
    queue = deque([start])
    while queue:
        vertex = queue.popleft()          # берём из начала очереди
        order.append(vertex)
        for neighbour in sorted(graph[vertex]):
            if neighbour not in visited:
                visited.add(neighbour)    # помечаем сразу при добавлении
                queue.append(neighbour)
    return order

В BFS вершина помечается посещённой в момент добавления в очередь, иначе она успеет попасть туда несколько раз.

Оба обхода работают за \( O(n + m) \): каждая вершина и каждое ребро обрабатываются один раз. Вызов sorted внутри приведённого обхода добавляет \( O(m \log n) \) и присутствует там ради воспроизводимого вывода. В рабочем коде списки смежности сортируют один раз заранее, как в задачах в конце главы. Выбор между двумя обходами определяется задачей:

  • BFS посещает вершины в порядке возрастания расстояния, отсчитанного от старта, поэтому он находит кратчайшие пути в невзвешенном графе;
  • DFS естественно записывается через рекурсию и применяется для поиска циклов, топологической сортировки, компонент связности и анализа структуры графа.

Кратчайшие пути

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

def bfs_shortest_paths(graph, start):
    """Расстояния (в рёбрах) и предки на кратчайших путях от start."""
    dist = {start: 0}
    parent = {start: None}
    queue = deque([start])
    while queue:
        vertex = queue.popleft()
        for neighbour in graph[vertex]:
            if neighbour not in dist:
                dist[neighbour] = dist[vertex] + 1
                parent[neighbour] = vertex
                queue.append(neighbour)
    return dist, parent

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

Если рёбра взвешенные, BFS уже не подходит, так как путь, составленный из трёх коротких рёбер, может оказаться короче одного длинного. Здесь применяется алгоритм Дейкстры, являющийся жадным: поддерживаются текущие оценки расстояний, и на каждом шаге берётся ещё не обработанная вершина с наименьшей оценкой, уже не подлежащей улучшению, после чего предпринимается попытка улучшить оценки её соседей; эта операция называется релаксацией.

import heapq

def dijkstra(graph, start):
    """Кратчайшие расстояния во взвешенном графе с неотрицательными весами.

    graph — список смежности вида {вершина: [(сосед, вес), ...]}.
    """
    dist = {start: 0}
    heap = [(0, start)]           # приоритетная очередь (расстояние, вершина)
    while heap:
        d, vertex = heapq.heappop(heap)
        if d > dist.get(vertex, float("inf")):
            continue              # устаревшая запись, вершина уже обработана
        for neighbour, weight in graph[vertex]:
            new_dist = d + weight
            if new_dist < dist.get(neighbour, float("inf")):
                dist[neighbour] = new_dist
                heapq.heappush(heap, (new_dist, neighbour))
    return dist

Куча, рассмотренная в главе про деревья, обеспечивает сложность \( O(m \log n) \). Веса обязаны быть неотрицательными: на отрицательных рёбрах жадная стратегия перестаёт работать, и требуется алгоритм Беллмана — Форда.

Топологическая сортировка

Пусть ориентированный граф описывает зависимости: ребро \( u \to v \) означает «\( u \) должно быть выполнено раньше \( v \)». Компиляция модулей, порядок расчётных этапов в пайплайне, установка пакетов ставят одну и ту же задачу — выстроить вершины в линию так, чтобы все рёбра шли слева направо. Это и есть топологическая сортировка, возможная тогда и только тогда, когда в графе нет циклов, иначе зависимости противоречивы.

Простейший способ — DFS: вершина добавляется в результат после всех потомков, а список в конце разворачивается.

def topological_sort(graph, vertices):
    """Топологическая сортировка ориентированного ациклического графа."""
    visited = set()
    order = []

    def visit(vertex):
        visited.add(vertex)
        for neighbour in graph[vertex]:
            if neighbour not in visited:
                visit(neighbour)
        order.append(vertex)      # добавляем на выходе из вершины

    for vertex in vertices:
        if vertex not in visited:
            visit(vertex)
    return order[::-1]            # разворачиваем

Минимальное остовное дерево

Остовным деревом (англ. spanning tree) связного графа называется подмножество рёбер, связывающее все вершины и не содержащее циклов, то есть дерево, натянутое на все \( n \) вершин и собранное из \( n-1 \) ребра. Минимальным остовным деревом (MST) называется то, у которого суммарный вес рёбер наименьший. Классическая постановка требует соединить все дома оптоволокном, затратив минимум кабеля.

Два классических алгоритма, оба жадные:

  • Алгоритм Прима наращивает дерево из одной вершины: на каждом шаге добавляется самое лёгкое ребро, ведущее из уже построенной части наружу. Удобно реализуется через кучу, сложность \( O(m \log n) \). По существу это алгоритм Дейкстры, только вместо расстояния от старта минимизируется вес одного ребра.
  • Алгоритм Краскала исходит от рёбер: сортирует их все по весу и добавляет по очереди те, которые не образуют цикл. Проверка на цикл выполняется структурой «система непересекающихся множеств» (DSU). Сложность \( O(m \log m) \).

Если в алгоритме Прима заменить «минимальное ребро» на «максимальное», получится максимальное остовное дерево; эта задача рассматривается в конце главы.

Графы в физических задачах

  • Электрические цепи. Схема представляет собой граф из узлов и ветвей. Законы Кирхгофа записываются через матрицу инцидентности, а число независимых контурных уравнений равно \( m - n + 1 \), то есть количеству рёбер, не вошедших в остовное дерево.
  • Модель Изинга и перколяция. Спины на решётке служат вершинами, а взаимодействия соседей рёбрами. Алгоритмы кластерного обновления (Свендсена — Ванга, Вольфа) сводятся к поиску компонент связности, а перколяция — вопрос о существовании компоненты, соединяющей края решётки.
  • Молекулы и структуры. Атомы и химические связи образуют граф, а поиск колец в молекуле сводится к поиску циклов. На этом же представлении работают графовые нейронные сети, предсказывающие свойства веществ.
  • Марковские цепи и графы состояний. Состояния системы становятся вершинами, а переходы, снабжённые вероятностями, взвешенными рёбрами орграфа.
  • Трассировка и планирование. Разводка кабелей на установке, маршрут манипулятора, план эксперимента сводятся к задачам о кратчайшем пути или об остовном дереве.

Для практических расчётов реализовывать алгоритмы вручную не обязательно: готовые собраны в библиотеке NetworkX.

import networkx as nx

# граф-решётка 4 x 4 — например, узлы двумерной модели Изинга
lattice = nx.grid_2d_graph(4, 4)

print(nx.number_connected_components(lattice))       # компоненты связности
print(nx.shortest_path(lattice, (0, 0), (3, 3)))     # кратчайший путь
mst = nx.minimum_spanning_tree(lattice)              # остовное дерево

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

Разбор задач

Задачи рекомендуется сначала решить самостоятельно; они собраны в тренажёре.

Во всех задачах вершины нумеруются с единицы, а граф подаётся на вход списком рёбер: в первой строке числа \( n \) и \( m \), задающие количество вершин и рёбер, далее \( m \) строк с парами вершин.

Задача 1. Построить список смежности

Условие. Дан ориентированный граф, заданный списком рёбер. Требуется построить его список смежности: для каждой вершины \( i \) вывести число исходящих рёбер, а затем номера вершин, в которые они ведут, в порядке возрастания.

Идея решения. Прямая реализация определения. Заведём список списков и пройдём по рёбрам, добавляя конец каждого ребра в список его начала. Сортировка выполняется один раз в конце.

n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]        # вершины нумеруются с единицы

for _ in range(m):
    u, v = map(int, input().split())
    graph[u].append(v)                    # граф ориентированный: только u -> v

for vertex in range(1, n + 1):
    neighbours = sorted(graph[vertex])
    print(len(neighbours), *neighbours)

Сложность. По времени \( O(n + m \log n) \), поскольку чтение рёбер стоит \( O(m) \), а сортировка списков смежности даёт в сумме \( \sum_i d_i \log d_i \le m \log n \). По памяти \( O(n + m) \).

Задача 2. Перевести список рёбер в матрицу смежности

Условие. Дан ориентированный граф, заданный списком рёбер. Требуется вывести его матрицу смежности \( n \times n \): на пересечении \( i \)-й строки и \( j \)-го столбца стоит единица, если существует ребро, ведущее из \( i \) в \( j \).

Идея решения. Заведём нулевую матрицу и расставим единицы. Единственная тонкость заключается в том, что матрицу нельзя создавать через [[0] * n] * n. В этом случае получится \( n \) ссылок на один и тот же список, и запись в одну строку изменит все разом; устройство этой ловушки рассмотрено в главе про объекты и память.

n, m = map(int, input().split())
matrix = [[0] * n for _ in range(n)]      # именно так, а не [[0] * n] * n

for _ in range(m):
    u, v = map(int, input().split())
    matrix[u - 1][v - 1] = 1              # переходим к нумерации с нуля

for row in matrix:
    print(*row)

Сложность. По времени \( O(n^2 + m) \), поскольку один вывод матрицы уже стоит \( O(n^2) \); по памяти \( O(n^2) \).

Задача 3. Обход в глубину

Условие. Задан неориентированный граф. Требуется обойти с помощью DFS все вершины, достижимые из заданной вершины \( s \), и вывести их в порядке обхода. Соседи каждой вершины рассматриваются в порядке возрастания номеров.

Идея решения. Итеративный DFS со стеком, поскольку граф может содержать до \( 10^5 \) вершин, и рекурсия, ограниченная тысячей кадров, с этим не справится. Чтобы соседи обрабатывались по возрастанию, поместим их в стек в обратном порядке: снимается всегда последний положенный.

n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]

for _ in range(m):
    u, v = map(int, input().split())
    graph[u].append(v)
    graph[v].append(u)                    # граф неориентированный

start = int(input())
for neighbours in graph:
    neighbours.sort(reverse=True)         # сортируем один раз заранее

visited = [False] * (n + 1)
stack, order = [start], []

while stack:
    vertex = stack.pop()
    if visited[vertex]:                   # вершина могла попасть в стек дважды
        continue
    visited[vertex] = True
    order.append(vertex)
    for neighbour in graph[vertex]:
        if not visited[neighbour]:
            stack.append(neighbour)

print(*order)

Сложность. По времени \( O(n + m) \) плюс сортировка списков смежности, выполненная заранее; по памяти \( O(n + m) \).

Задача 4. Обход в ширину

Условие. Постановка та же, но обход выполняется в ширину.

Идея решения. Заменим стек на deque и будем снимать элементы с начала; соседей отсортируем уже по возрастанию, а вершину, добавляемую в очередь, сразу пометим посещённой.

from collections import deque

n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]

for _ in range(m):
    u, v = map(int, input().split())
    graph[u].append(v)
    graph[v].append(u)

start = int(input())
for neighbours in graph:
    neighbours.sort()

visited = [False] * (n + 1)
visited[start] = True
queue, order = deque([start]), []

while queue:
    vertex = queue.popleft()              # берём из начала очереди
    order.append(vertex)
    for neighbour in graph[vertex]:
        if not visited[neighbour]:
            visited[neighbour] = True     # помечаем сразу при добавлении
            queue.append(neighbour)

print(*order)

Сложность. Та же, что и у DFS: \( O(n + m) \) по времени и памяти. Код двух обходов отличается только концом контейнера, с которого снимаются вершины.

Задача 5. Компоненты связности

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

Идея решения. Запустим обход из каждой ещё не посещённой вершины: всё, собранное обходом за один запуск, и есть одна компонента. Если перебирать стартовые вершины по возрастанию, компоненты автоматически получатся в нужном порядке.

n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]

for _ in range(m):
    u, v = map(int, input().split())
    graph[u].append(v)
    graph[v].append(u)

visited = [False] * (n + 1)
components = []

for vertex in range(1, n + 1):
    if visited[vertex]:
        continue
    component, stack = [], [vertex]       # новая компонента
    while stack:
        current = stack.pop()
        if visited[current]:
            continue
        visited[current] = True
        component.append(current)
        stack.extend(graph[current])
    components.append(sorted(component))

print(len(components))
for component in components:
    print(*component)

Сложность. По времени \( O(n + m) \) плюс сортировка найденных компонент, по памяти \( O(n + m) \). Заметим, что внешний цикл по вершинам, вопреки первому впечатлению, не делает алгоритм квадратичным: каждая вершина обрабатывается по одному разу, а цикл лишь ищет стартовые точки.

Задача 6. Самая дорогая сеть

Условие. Дан связный взвешенный неориентированный граф. Требуется найти вес максимального остовного дерева — набора рёбер, связывающего все вершины и имеющего наибольший суммарный вес. Если граф несвязен, дерева, натянутого на все вершины, не существует.

Идея решения. Алгоритм Прима, в котором на каждом шаге выбирается не минимальное, а максимальное ребро. Модуль heapq реализует лишь min-heap, поэтому в кучу помещается вес со знаком минус: «минимальный» элемент кучи окажется максимальным по модулю.

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

import heapq

def add_vertex(vertex, graph, added, heap):
    """Добавляет вершину в остов и кладёт её рёбра в кучу."""
    added[vertex] = True
    for neighbour, weight in graph[vertex]:
        if not added[neighbour]:
            heapq.heappush(heap, (-weight, neighbour))   # минус: нужен max-heap

n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]

for _ in range(m):
    u, v, weight = map(int, input().split())
    graph[u].append((v, weight))
    graph[v].append((u, weight))

added = [False] * (n + 1)
added[0] = True                           # нулевой вершины не существует
heap, total = [], 0

add_vertex(1, graph, added, heap)         # растим остов из первой вершины
in_tree = 1                               # сколько вершин уже в остове

while in_tree < n and heap:
    weight, vertex = heapq.heappop(heap)
    if not added[vertex]:                 # ребро внутрь остова пропускаем
        total += -weight
        add_vertex(vertex, graph, added, heap)
        in_tree += 1

print(total if in_tree == n else "Oops! I did it again")

Сложность. По времени \( O(m \log n) \), поскольку каждое ребро попадает в кучу не более одного раза, а операции с кучей стоят \( O(\log n) \). По памяти \( O(n + m) \).

Резюме

  • Граф является самой общей структурой, описывающей связи; дерево и список — его частные случаи.
  • Разреженные графы хранятся списком смежности, занимающим \( O(n+m) \) памяти, а плотные матрицей смежности: \( O(n^2) \), зато проверка ребра за \( O(1) \).
  • DFS и BFS отличаются только контейнером для вершин, стеком против очереди, и оба работают за \( O(n + m) \).
  • BFS находит кратчайшие пути в невзвешенном графе, а алгоритм Дейкстры во взвешенном с неотрицательными весами за \( O(m \log n) \).
  • Посещённые вершины необходимо помечать: в отличие от дерева, у графа есть циклы, и без пометок обход не завершится.
  • Компоненты связности, топологическая сортировка и остовные деревья — три классических применения обходов, к которым сводится множество практических задач.

Жизненный цикл программного обеспечения

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

Жизненный цикл программного обеспечения (ПО) охватывает всё происходящее с программой, от разговора, в котором задача только формулируется, до дня, когда её снимают с поддержки. Каждая стадия закрывает вопрос, иначе возникающий позже и по другой цене: чего от программы требуют, как она устроена, работает ли она и кто исправляет её в три часа ночи.

Основные стадии жизненного цикла

Жизненный цикл ПО состоит из семи стадий:

  1. Анализ. Выясняется, что именно требуется построить.
  2. Проектирование. Определяется, как это будет устроено.
  3. Разработка. Пишется код.
  4. Тестирование. Проверяется, что получилось именно задуманное.
  5. Релиз. Продукт доводится до пользователя.
  6. Поддержка. Сопровождается работающая система.
  7. Завершение жизненного цикла. Продукт снимается с поддержки.

Стадии повторяются циклами: требования, выясняющиеся после релиза, запускают всё заново. У научного кода цикл короче, а роль заказчика, аналитика и приёмщика играет сам разработчик.

Анализ и планирование

На этапе анализа собираются требования к будущему продукту и сводятся в документ Software Requirements Specification (SRS).

IEEE 830-1998

Структуру такого документа задавал стандарт IEEE 830-1998, отменённый и заменённый на ISO/IEC/IEEE 29148. Предложенная им структура сохранилась:

  1. Назначение
  2. Общее описание
  3. Конкретные требования
  4. Интерфейсы
  5. Ограничения
  6. Атрибуты качества

Ключевые задачи этапа анализа

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

Этим этапом пренебрегают из ощущения, что «и так всё понятно». Цена ошибки здесь самая высокая из всех стадий: неверно понятое требование обнаруживается на защите, когда переделывать некогда, и увлекает за собой все написанные поверх него страницы.

Проектирование

Из требований выводится устройство системы: деление на части и интерфейсы между ними. Решения этого этапа переживают весь написанный впоследствии код. Переписать отдельную функцию дёшево; изменить деление на части в собранной системе почти невозможно.

Design-review

Прежде чем писать код, замысел, оформленный в пару страниц, показывают коллегам. На design-review разбираются:

  • что планируется сделать;
  • мотивация, то есть цели, заказчики и пользователи;
  • сравнение с уже существующими аналогами;
  • roadmap.

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

Security-review

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

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

MVP (Minimum Viable Product)

Минимально жизнеспособный продукт — самая маленькая версия, которой уже можно пользоваться. Смысл заключается в том, чтобы как можно раньше выяснить, тот ли продукт создаётся. Для лабораторного инструмента MVP обычно выглядит так: одна команда, один формат входных файлов, никаких настроек. Она вычисляет задуманное, и её можно передать коллеге.

Разработка

Интерес здесь представляет не сам код, а то, как несколько человек работают над одним проектом, не затирая сделанное друг другом.

Системы контроля версий

Система контроля версий (VCS) хранит историю изменений с указанием того, кто, что и когда правил, и позволяет вернуться к любому прошлому состоянию. Решать эту задачу начали в 1960-х с IEBUPDTE, затем были SCCS в 1970-х, RCS и CVS в 1980-х, а SVN и Git появились в 2000-х.

Поколения различаются тем, где хранится история. Централизованные системы (CVS, SVN) хранят её на сервере, и без связи с ним нельзя ни просмотреть историю, ни сделать коммит. Распределённые системы (Git, Mercurial) дают каждому участнику полную копию репозитория со всей историей, поэтому работать можно без сети, а синхронизироваться впоследствии.

Стандартом де-факто является Git, написанный Линусом Торвальдсом в 2005 году для разработки ядра Linux, когда команда лишилась доступа к использовавшейся до того проприетарной системе. Главной его особенностью является дешёвое ветвление. Создание ветки ничего не копирует, заводится лишь указатель на коммит, поэтому ветвиться и сливать можно постоянно. Устройству Git и повседневным командам посвящена отдельная глава.

Вокруг ветвления сложились схемы работы. Git Flow поддерживает долгоживущие ветки релизов, более простой GitHub Flow сливает всё в основную ветку через pull request'ы, принятые ревьюером; между ними находится GitLab Flow. Выбор определяется тем, как часто выходят релизы. Научной группе почти всегда достаточно самой простой схемы.

Ревью кода

Pull request (или merge request) — предложение влить в общую ветку сделанные разработчиком изменения. Он решает три задачи: делает правку видимой для всей команды, предоставляет место для обсуждения до того, как код попал в основную ветку, и служит точкой, к которой привязываются автоматические проверки.

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

Монорепозиторий

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

Лаборатории разумно держать в одном репозитории всё, что выпускается и версионируется вместе, и разносить части, развивающиеся независимо.

Тестирование

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

Проверки выстроены пирамидой, от самых дешёвых и частых к самым дорогим и редким.

Статический анализ

Самая дешёвая проверка не запускает программу. Анализатор читает исходный код и ищет в нём ошибки, отклонения от стиля и подозрительные места. Так обнаруживаются неиспользуемые переменные, опечатки в именах, потенциальные None, измеряются цикломатическая сложность и доля дублированных строк. Покрытие тестами также попадает в отчёты о качестве, но получают его иначе, выполняя тесты под coverage.py (pytest --cov): узнать, какие строки выполнялись, не выполнив их, нельзя.

Анализатор, встроенный в редактор и в CI, в дальнейшем работает самостоятельно.

«Самое важное, что я сделал как программист за последние годы, — начал агрессивно применять статический анализ кода» — Джон Кармак

Модульные тесты

Модульный тест (unit test) проверяет одну функцию или класс, отделённые от всего остального. Их пишут много, они выполняются за секунды и запускаются на каждое изменение.

Для научного кода это самая ценная разновидность тестов. Формула, проверенная на случае с известным аналитическим ответом, остаётся проверенной и после того, как через полгода её начнут оптимизировать.

Интеграционные и функциональные тесты

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

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

Нагрузочное тестирование

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

ПрофильЧто проверяет
Performance testingкак меняются задержки при постепенном росте нагрузки
Load testingвыдерживает ли система штатную нагрузку долгое время
Stress testingчто происходит за пределами расчётной нагрузки
Spike testingпереживает ли система резкий всплеск
Warm-up testingкак система ведёт себя сразу после запуска, пока кеши пусты

Последний пункт актуален и для физика: программа, быстро считающая на прогретых кешах, на первом запуске ведёт себя иначе.

Релиз

Главной заботой при выкатке является возможность быстро откатиться в случае поломки.

Стратегии развёртывания

Существуют стратегии выкатки, каждая из которых по-своему страхует от неудачного релиза:

  • Blue-green. Поддерживаются две одинаковые среды. Новая версия выкатывается на неактивную, проверяется, и трафик переключается одним действием. Откат — переключение обратно.
  • Canary. Новая версия сначала показывается небольшой доле пользователей. Если метрики не ухудшились, доля увеличивается.
  • Rolling update. Экземпляры приложения заменяются по одному, сервис всё это время продолжает отвечать.
  • A/B-тестирование. Две версии работают одновременно на разных группах, и выбор между ними делается по измеренному результату.

Feature-флаги

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

Момент выпуска

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

CI/CD

Перечисленные проверки выполняет конвейер CI/CD. За тремя похожими аббревиатурами скрываются три степени автоматизации.

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

Непрерывная поставка (CD, Continuous Delivery) добавляет автоматическую сборку дистрибутивов и развёртывание на тестовые среды. Система в любой момент готова к выкатке и ожидает решения человека.

Непрерывное развёртывание (CD, Continuous Deployment): прошедшие все проверки изменения попадают в продакшен без участия человека. Это требует уверенности в тестах.

Научной группе обычно достаточно первого уровня. CI, выполняющий на каждый push тесты и линтер, окупается на второй месяц.

Устройство реального конвейера

Ниже приведён конвейер научной библиотеки REDPIC, кода для расчёта динамики пучка, которому отведена отдельная глава. Написанный для GitHub Actions, он умещается в одну страницу.

Первая работа проверяет качество кода. Она запускается на каждый push и на каждый pull request. Подготовка: получить исходники, установить нужную версию Python, установить зависимости.

steps:
  - name: Checkout sources
    uses: actions/checkout@v4

  - name: Set up Python
    uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}

Строка ${{ matrix.python-version }} задаёт матричную сборку. Один и тот же набор шагов выполняется на нескольких версиях Python, и если библиотека перестала работать на 3.12, но работает на 3.10, это выясняется сразу. Для библиотеки, передаваемой незнакомым людям, такая проверка обязательна: разработчик не управляет тем, какой Python установлен у пользователя.

Анализаторов два, поскольку обнаруживают они разное. flake8 следит за стилем и очевидными ошибками, pylint анализирует глубже и сообщает о сомнительных конструкциях.

  - name: Install deps
    run: |
      python -m pip install --upgrade flake8 pylint
      python -m pip install -r requirements.txt

  - name: Run flake8
    run: python -m flake8 redpic tests setup.py

  - name: Run pylint
    run: python -m pylint redpic tests setup.py

Проверяется не только код библиотеки, но и tests с setup.py. Тесты также являются кодом, и приходят в негодность они не реже основного.

Вторая работа выполняет тесты:

  - name: Run functional test
    run: python -m pytest tests/functional/src

Третья работа выпускает релиз. Она запускается только на поставленный автором тег версии. Собранный пакет отправляется в PyPI и становится доступен по pip install redpic:

  - name: Publish distribution to PyPI
    uses: pypa/gh-action-pypi-publish@release/v1

  - name: Upload Release Asset (sdist) to GitHub
    uses: softprops/action-gh-release@v2
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
      files: dist/${{ env.name }}

Пароли и токены в конвейере никогда не записываются открытым текстом: они помещаются в хранилище секретов репозитория и подставляются по имени (secrets.GITHUB_TOKEN). Секрет, закоммиченный в репозиторий, считается скомпрометированным навсегда: история git хранит всё, и удаление следующим коммитом ничего не исправляет.

Каждое чужое действие — код, выполняемый в конвейере разработчика с доступом к его секретам, поэтому у него должен быть указан тег или хеш коммита. Запись вида @master означает «взять из чужой ветки то, что там находится сегодня», и подменить это может любой, имеющий доступ к тому репозиторию. В примере выше @release/v1 — также ветка, однако PyPA поддерживает её под стабильные выпуски и сама рекомендует такую запись; для строгости лучше указывать хеш коммита.

Четвёртая работа собирает документацию из исходников, публикует её на GitHub Pages и прикладывает PDF-версию к релизу:

  - name: Upload Release Asset (pdf) to GitHub
    uses: softprops/action-gh-release@v2
    with:
      files: manual/main.pdf

  - name: Push documentation changes
    uses: peaceiris/actions-gh-pages@v4
    with:
      github_token: ${{ secrets.GITHUB_TOKEN }}
      publish_dir: gh-pages

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

Это главный аргумент в пользу CI/CD в науке. Релиз, собираемый вручную, рано или поздно окажется собран не из того коммита, без одного теста или с забытым файлом, и обнаружится это через полгода, когда результат уже опубликован.

Задание. Выступить в роли рецензента: «Ревью чужого кода».

Сопровождение

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

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

Для лабораторного сервиса минимум следующий: программа ведёт журнал, а об отказавшем сервисе кто-то узнаёт раньше, чем это обнаружат пользователи.

Завершение жизненного цикла

Рано или поздно программу перестают поддерживать. Об этом следует объявить заранее, дать пользователям время и путь перехода на замену, а данные выгрузить в формате, переживающем сам сервис.

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

Резюме

Таким образом, семь стадий сохраняются, даже если код пишется в одиночку: кто-то должен понять, что нужно построить; продумать устройство; написать; проверить; довести до пользователей; поддерживать; а когда придёт время, закрыть, оставив после себя работающий репозиторий.

Разница между научной группой и большой компанией заключается в масштабе церемоний. Заказчиком, аналитиком, тестировщиком и дежурным оказывается один и тот же человек. Стадии пропускают обычно по незнанию. Ценой являются потерянные данные, невоспроизводимые результаты и код, в котором через год не разберётся даже автор.

Требования к коду на Python

Требования к коду на Python, действующие в этой книге, рекомендуется соблюдать и в собственных проектах:

  • Строгое соответствие PEP 8.
  • Длина строки — 79 символов.
  • Импорты корректно отсортированы, неиспользуемых импортов нет.
  • Отступы делаются в 4 пробела.
  • Переносы строк выполнены с правильными отступами.
  • Обратные слеши для переносов не используются.
  • Консистентность (одинаковые кавычки, одинаковые способы решения одинаковых задач).
  • Отсутствие закомментированного кода и стандартных комментариев-заглушек (# Create your views here. и т. п.).
  • Комментарии к функциям оформлены как docstring в соответствии с Docstring Conventions, то есть начинаются с заглавной буквы, заканчиваются точкой и содержат описание того, что делает функция.
  • Комментарии к коду краткие и информативные.
  • Длинные куски кода логически разделены пустыми строками, как абзацы в тексте.
  • Нет лишних операций.
  • Нет лишних else (если в if происходит return/raise); используется guard block.
  • В репозитории нет лишних файлов наподобие __pycache__ и .vscode, попадающих туда по невнимательности.
  • Исполняемый код в .py-файлах закрыт конструкцией if __name__ == "__main__":.
  • Для неизменяемых последовательностей данных предпочтительны кортежи, а не списки.
  • В f-строках используется только подстановка переменных, без логических и арифметических операций, вызовов функций и прочей динамики, мешающей читать шаблон.
  • Имена переменных, выбранные по смыслу, записаны по-английски; однобуквенных имён нет (кроме счётчиков в коротких циклах и общепринятых математических обозначений).
  • Функции и методы названы глаголами или глагольными фразами (get_data, compute_energy), а классы существительными (Particle, FieldSolver); функция-формула, возвращающая величину, допускает имя этой величины, как mean и exp в NumPy.
  • Магические числа вынесены в именованные константы.

Большинство перечисленных пунктов проверяются автоматически линтерами и форматтерами ruff, flake8, black, isort, работающими в редакторе. Если они настроены в редакторе и в CI, следить за этими правилами вручную почти не требуется. black и ruff format по умолчанию форматируют по 88 символов, а не по 79, поэтому длину необходимо задать явно в pyproject.toml ([tool.black] line-length = 79, [tool.ruff] line-length = 79), иначе форматтер будет конфликтовать с линтером на каждом коммите.

Задание. Применить этот список к чужому коду и оставить замечания: «Ревью чужого кода».

От скрипта к приложению

Типичная история начинается с файла analysis.py на пятьдесят строк, написанного вечером перед семинаром: загрузить осциллограммы, вычесть базовую линию, найти пик, построить график. Скрипт работает, график попадает в отчёт.

Дальнейшее развитие событий, как правило, выглядит следующим образом:

  • через полгода в скрипте, разросшемся до 800 строк, появляются калибровка, три формата входных файлов и ветка if experiment == "march":;
  • рядом лежат analysis_new.py, analysis_final.py и analysis_final_v2_REAL.py, и никто не помнит, чем они отличаются;
  • пути к данным (/home/vasya/data/2026-03-12/) и параметры установки зашиты в код, и перед каждым запуском их правят в исходнике;
  • на другой машине скрипт не запускается, и начинается выяснение версий numpy;
  • приходит новый студент, и автору скрипта приходится полчаса объяснять, какие строки закомментировать, чтобы «просто посчитать спектр».

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

Разница между скриптом и приложением заключается не в количестве строк, а в свойствах:

  • приложение устанавливается на чистую машину одной-двумя командами;
  • его запускают, не открывая исходники в редакторе, и запуск описан в README;
  • оно внятно сообщает об ошибках и ведёт журнал, сохраняющийся после завершения запуска;
  • его можно изменять, не опасаясь незаметно нарушить физику, поскольку за этим следят тесты;
  • в нём способен разобраться другой человек или сам автор через полгода.

Глава про жизненный цикл ПО рассматривала разработку сверху. В настоящей главе весь путь проходится на одном примере: скрипт обработки осциллограмм превращается в пакет beamtool для всей группы.

В примерах встретится NumPy: loadtxt читает таблицу чисел из файла, argmax возвращает индекс максимума, trapezoid вычисляет интеграл методом трапеций. Устройство массивов разбирается в главе «NumPy и pandas»; здесь важна обвязка вокруг библиотеки.

Структура проекта

Любой файл .py представляет собой модуль, пригодный для импорта. Каталог с файлом __init__.py является пакетом, набором модулей под общим именем. Как только код перестаёт помещаться в один файл, его раскладывают по модулям по смыслу: чтение данных отдельно, формулы отдельно, графики отдельно. На один модуль приходится одна зона ответственности.

Типовая структура:

beamtool/                  # корень git-репозитория
├── pyproject.toml         # метаданные проекта и зависимости
├── README.md              # что это, как поставить, как запустить
├── .gitignore
├── configs/
│   └── default.toml       # параметры обработки по умолчанию
├── src/
│   └── beamtool/          # сам пакет
│       ├── __init__.py
│       ├── io.py          # чтение и запись данных
│       ├── physics.py     # формулы и численные методы
│       ├── plotting.py    # вся работа с matplotlib
│       └── cli.py         # интерфейс командной строки
└── tests/
    ├── test_io.py
    └── test_physics.py

Это src-layout: пакет расположен в каталоге src/. Существует и плоский вариант (flat layout), при котором beamtool/ лежит в корне репозитория; для небольших проектов допустим и он. Преимущество src-layout заключается в том, что пакет нельзя случайно импортировать из рабочего каталога, минуя установку, и тесты проверяют именно то, что получит пользователь.

Сырые данные с установки в репозиторий не помещают: git плохо переносит гигабайты бинарных файлов, а история изменений для них бессмысленна. Данные хранятся на сервере или дисках группы, в репозитории остаются путь к ним в конфигурации и небольшие файлы-образцы для тестов (tests/data/). Каталоги data/, .venv/, __pycache__/ и файлы *.log необходимо добавить в .gitignore до первого коммита, иначе в историю попадут два гигабайта данных.

Виртуальные окружения и зависимости

Системным Python пользуются все программы на машине, и обновление библиотеки ради одного проекта нарушает работу другого. Поэтому у каждого проекта должно быть своё виртуальное окружение — набор пакетов, изолированный от системного.

cd beamtool
python -m venv .venv
source .venv/bin/activate    # в Windows: .venv\Scripts\activate
pip install numpy matplotlib

Окружение является расходным материалом: повреждённое окружение удаляется вместе с каталогом .venv и создаётся заново за минуту. Это работает только при условии, что список зависимостей хранится в репозитории. Способов записать его два.

Классический способ — файл requirements.txt, список, передаваемый команде pip install -r requirements.txt.

numpy==2.1.3
matplotlib==3.9.2

Современный способ — секция dependencies в pyproject.toml, едином файле с метаданными проекта (он рассматривается в разделе про упаковку). Для нового проекта предпочтительнее pyproject.toml; requirements.txt остаётся как точный слепок собранного окружения.

Режимов закрепления версий два. В библиотеке, устанавливаемой другими, версии ограничивают мягко (numpy>=2.0), чтобы не конфликтовать с чужими зависимостями. В приложении для обработки данных важнее воспроизводимость: pip freeze > requirements.txt фиксирует всё окружение до последней цифры, что позволяет через год пересоздать его и повторить расчёт из статьи. Обновление той же scipy иногда изменяет численные результаты в последних знаках, а для науки это является основанием зафиксировать версии жёстко.

Вместо связки venv + pip можно использовать менеджер проектов, poetry или uv. Они выполняют те же действия: окружение, зависимости и lock-файл с точными версиями, но одной командой и с автоматическим разрешением конфликтов; uv, написанный на Rust, к тому же работает на порядок быстрее pip. Начинать следует с venv и pip, чтобы понимать механику, а переход на менеджеры целесообразен тогда, когда становится ясно, какую рутину они устраняют.

Точки входа

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

if __name__ == "__main__":
    main()

Переменная __name__ равна "__main__" только у файла, запущенного как программа, а не импортированного. Это уже входило в наши требования к коду; следующий шаг — интерфейс командной строки, чтобы параметры передавались аргументами, а не правкой исходника. В стандартной библиотеке для этого предусмотрен модуль argparse:

"""Интерфейс командной строки beamtool."""

import argparse
from pathlib import Path

import numpy as np


def parse_args() -> argparse.Namespace:
    """Разбирает аргументы командной строки."""
    parser = argparse.ArgumentParser(
        prog="beamtool",
        description="Обработка осциллограмм: базовая линия, пик, заряд.",
    )
    parser.add_argument(
        "--input", type=Path, required=True,
        help="входной CSV-файл с колонками time, voltage",
    )
    parser.add_argument(
        "--output", type=Path, required=True,
        help="файл для записи результатов",
    )
    parser.add_argument(
        "--plot", action="store_true",
        help="показать график после обработки",
    )
    parser.add_argument(
        "--baseline-points", type=int, default=100,
        help="сколько первых точек считать базовой линией",
    )
    return parser.parse_args()


def main() -> None:
    """Точка входа приложения."""
    args = parse_args()
    time, voltage = np.loadtxt(
        args.input, delimiter=",", skiprows=1, unpack=True)

    # Базовая линия — среднее по первым точкам до прихода сигнала.
    baseline = voltage[:args.baseline_points].mean()
    signal = voltage - baseline
    peak_index = int(np.argmax(signal))
    area = float(np.trapezoid(signal, time))

    np.savetxt(
        args.output,
        [[time[peak_index], signal[peak_index], area]],
        delimiter=",",
        header="peak_time,peak_voltage,area",
    )

    if args.plot:
        # Ленивый импорт: без --plot matplotlib даже не загружается,
        # и скрипт работает на сервере без дисплея.
        from beamtool.plotting import show_signal
        show_signal(time, signal, peak_index)


if __name__ == "__main__":
    main()

Запуск: python -m beamtool.cli --input shot_042.csv --output results.csv --plot. Команда заработает после установки пакета в окружение (pip install -e ., см. раздел про упаковку); до установки при src-layout запуск осуществляется командой PYTHONPATH=src python -m beamtool.cli …. Дополнительно предоставляется --help: argparse самостоятельно генерирует справку, проверяет обязательные аргументы и приводит типы к объявленным. Пользователю не требуется открывать код, а значит, он не сможет случайно его повредить.

Для интерфейсов с подкомандами (как git commit, git push) существуют сторонние библиотеки click и typer, решающие ту же задачу декораторами и аннотациями типов. Однако argparse из стандартной библиотеки достаточно на долгое время, и он всегда доступен.

Конфигурация

Магические числа и пути в коде являются основным источником хаоса в лабораторных скриптах. Порог дискриминатора, частота дискретизации АЦП, путь к данным меняются от запуска к запуску и, будучи зашитыми в исходник, требуют правки кода. История в git превращается в набор записей «поменял порог обратно», а воспроизводимость теряется, потому что никто не помнит, с какими параметрами получен график из статьи.

Код отвечает на вопрос «как считать», конфигурация — «что и с какими параметрами». Вынесенные параметры помещают в конфигурационный файл; удобным форматом является TOML, читаемый стандартной библиотекой (Python 3.11+).

# configs/default.toml

[detector]
sampling_rate_hz = 2.5e9   # частота дискретизации АЦП
impedance_ohm = 50.0

[analysis]
baseline_points = 100      # точек на оценку базовой линии
peak_threshold_v = 0.05    # порог отбора событий
import tomllib

with open("configs/default.toml", "rb") as f:
    config = tomllib.load(f)

threshold = config["analysis"]["peak_threshold_v"]

YAML устроен аналогично (требуется пакет pyyaml); достаточно выбрать один формат и придерживаться его. Конфигурация в репозитории образует историю параметров обработки: видно, кем, когда и на сколько сдвинут любой порог.

Для того, что зависит от конкретной машины или является секретным, используют переменные окружения:

import os

data_dir = os.environ.get("BEAMTOOL_DATA_DIR", "/data/beam")

Пароли и токены, например доступ к базе данных группы (о них в главе про базы данных), не попадают ни в код, ни в git. Допустимы только переменные окружения или локальный файл .env, добавленный в .gitignore. Общепринятый приоритет источников: значения по умолчанию → конфигурационный файл → переменные окружения → аргументы командной строки; каждый следующий уровень перекрывает предыдущий.

Логирование вместо print

print пригоден для кратковременной отладки. Далее возникают проблемы: отладочный вывод, разбросанный по коду, нельзя отключить, не удаляя его; у сообщений нет ни времени, ни уровня важности; всё уходит в консоль и исчезает вместе с закрытым окном. Когда же ночная обработка останавливается с ошибкой на 3742-м файле из 5000, необходим журнал, сохранивший, что происходило, с какими файлами и когда.

Всё это обеспечивает logging из стандартной библиотеки. У сообщений пять уровней (DEBUG, INFO, WARNING, ERROR, CRITICAL) и глобальный порог, определяющий, что показывать. Настройка, пишущая одновременно в консоль и в файл:

import logging

logger = logging.getLogger(__name__)


def setup_logging(logfile: str = "beamtool.log") -> None:
    """Включает вывод логов в консоль и в файл одновременно."""
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
        handlers=[
            logging.StreamHandler(),
            logging.FileHandler(logfile, encoding="utf-8"),
        ],
    )

Использование в коде:

logger.info("Загружен файл %s: %d точек", path, n_points)
logger.warning("Пик ниже порога %.3f В, файл пропущен", threshold)
logger.error("Не удалось разобрать файл %s", path, exc_info=True)

getLogger(__name__) в каждом модуле создаёт именованный логгер, и в журнале видно, какой модуль пишет. Флаг --verbose в CLI может понижать порог до DEBUG, не затрагивая код; отладочные сообщения пишутся всегда, а показываются по требованию. exc_info=True добавляет к сообщению полный traceback, так что наутро в журнале обнаружится не только «файл пропущен», но и причина.

Обработка ошибок

Главным принципом научного кода является fail fast: при обнаружении некорректных данных программа должна останавливаться сразу и явно. Тихий NaN проходит через все стадии обработки и превращается в правдоподобный, но неверный график. Остановившуюся программу исправляют; красивую кривую с ошибкой отправляют в статью.

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

import numpy as np


class BeamtoolError(Exception):
    """Базовый класс ошибок beamtool."""


class InvalidSignalError(BeamtoolError):
    """Сигнал не пригоден для обработки."""


def find_peak(signal: np.ndarray) -> int:
    """Возвращает индекс максимума сигнала."""
    if signal.size == 0:
        raise InvalidSignalError("пустой сигнал")
    if not np.all(np.isfinite(signal)):
        raise InvalidSignalError("в сигнале есть NaN или inf")
    return int(np.argmax(signal))

Собственные классы отделяют ожидаемые ошибки от дефектов кода. На верхнем уровне, в main(), перехватывается except BeamtoolError, и некорректный файл в пачке из тысячи оказывается штатной ситуацией: запись в журнал и переход к следующему. KeyError или TypeError, напротив, означают дефект в самом коде; такое исключение должно пройти наружу с полным traceback, чтобы его исправили.

Худшее, что можно сделать с исключением, — подавить его:

# Так делать нельзя: ошибка исчезает бесследно,
# а в результатах молча появляется дыра.
try:
    result = process(path)
except Exception:
    pass

Исключение следует перехватывать только там, где известно, что с ним делать, а в остальных местах не перехватывать.

Тесты

Научному коду тесты необходимы в большей степени, чем сайтам и мессенджерам. Главный риск здесь не «программа остановилась с ошибкой», а «программа выдала правдоподобное, но неверное число». Особенно опасны рефакторинги формул: выражение «слегка упростили», потеряли двойку в знаменателе, все результаты сместились, а код работает без единой ошибки. Тесты фиксируют поведение, и любое изменение, сдвинувшее численный результат, обнаруживается немедленно, что является защитой от регрессий.

Стандартом де-факто является pytest: тесты лежат в tests/test_*.py, называются test_* и состоят из обычных assert. Рассмотрим функцию из beamtool:

# src/beamtool/physics.py

ELECTRON_REST_ENERGY_MEV = 0.511


def lorentz_gamma(kinetic_energy_mev: float) -> float:
    """Возвращает лоренц-фактор по кинетической энергии электрона."""
    if kinetic_energy_mev < 0:
        raise ValueError("кинетическая энергия отрицательна")
    return 1.0 + kinetic_energy_mev / ELECTRON_REST_ENERGY_MEV

И тесты к ней:

# tests/test_physics.py

import pytest

from beamtool.physics import lorentz_gamma


def test_gamma_at_rest():
    # Покоящийся электрон: гамма-фактор равен единице.
    assert lorentz_gamma(0.0) == pytest.approx(1.0)


def test_gamma_one_mev():
    assert lorentz_gamma(1.0) == pytest.approx(2.9569, abs=1e-4)


def test_negative_energy_rejected():
    with pytest.raises(ValueError):
        lorentz_gamma(-1.0)

Команда pytest в корне проекта самостоятельно находит и выполняет все тесты. Сравнивать float через == нельзя из-за ошибок округления в последних знаках; pytest.approx сравнивает с заданным допуском, абсолютным (abs=) или относительным (rel=).

В первую очередь тестируют функции-формулы на входах с известным ответом (аналитические пределы, симметрии, законы сохранения), парсеры форматов данных на файлах-образцах из tests/data/, граничные случаи (пустой сигнал, один отсчёт, отрицательная энергия). Для численных методов решатель прогоняют на упрощённой задаче с аналитическим решением и сравнивают результаты.

Типизация

Аннотации типов присутствовали во всех примерах выше:

def rebin(spectrum: np.ndarray, factor: int = 2) -> np.ndarray:
    """Огрубляет спектр, суммируя соседние каналы."""
    n_bins = spectrum.size // factor * factor
    return spectrum[:n_bins].reshape(-1, factor).sum(axis=1)

Во время выполнения Python их не проверяет: аннотации представляют собой документацию, читаемую машиной. Редактор подсказывает атрибуты и обнаруживает опечатки, а статический анализатор mypy командой mypy src/ находит несоответствия до запуска: в одном месте передан str вместо Path, в другом функция может вернуть None, а вызывающий код об этом не знает. Аннотировать имеет смысл хотя бы функции публичного интерфейса пакета, а mypy добавить в CI рядом с линтерами.

Документация

Docstring у каждой публичной функции сообщает, что она делает, что принимает, что возвращает и в каких единицах (перепутанные мэВ и МэВ не обнаружит ни один тайпчекер). README в корне репозитория отвечает на три вопроса: что это за проект, как его установить, как запустить, и содержит один работающий пример команды; это первое, что увидит коллега, и часто единственное, что он прочтёт. Когда проект вырастает, из docstring'ов и markdown-файлов собирают сайт документации при помощи mkdocs или sphinx; документация хранится в том же репозитории и обновляется вместе с кодом, а не в отдельном текстовом документе на сетевом диске.

Упаковка и распространение

Центральным файлом современного Python-проекта является pyproject.toml.

[project]
name = "beamtool"
version = "0.1.0"
description = "Обработка осциллограмм с датчиков пучка"
requires-python = ">=3.11"
dependencies = [
    "numpy>=2.0",
    "matplotlib>=3.9",
]

[project.scripts]
beamtool = "beamtool.cli:main"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

Установка в окружение в режиме разработки:

pip install -e .

Флаг -e (editable) устанавливает пакет ссылкой на рабочий каталог, и правки видны сразу, без переустановки. После установки работают импорты from beamtool.physics import ... из любого места, в том числе из тестов. Секция [project.scripts] создаёт в окружении консольную команду, и функция main() из cli.py вызывается как beamtool --input shot_042.csv --output results.csv.

Коллегам из группы достаточно git-репозитория: pip install git+https://github.com/mygroup/beamtool устанавливает пакет со всеми объявленными зависимостями. Команда python -m build собирает wheel-файл в dist/, который можно приложить к релизу на GitHub или передать на машину без интернета. Публикация на PyPI через twine upload (или uv publish) требуется, когда инструментом предполагают пользоваться незнакомые автору люди; для внутренних инструментов группы она не обязательна.

Рост приложения в систему

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

Такую программу нельзя писать «плоско». Рассмотрим, как устроена библиотека SCAUT, оркеструющая эксперименты на линейном ускорителе инжектора ЦКП «СКИФ». Она согласованно управляет всеми компонентами установки, от магнитов до детекторов.

Ключевой идеей является разделение на слои, где каждый слой знает только о соседнем:

Интерфейс эксперимента   что именно сканируем и в каких пределах
        │
Управление сканированием планировщик точек, обработчики событий, обработка аварий
        │
Абстракция оборудования  «мотор» и «датчик» — независимо от того, что за железо
        │
Коммуникация             EPICS, Tango, OPC UA — или расчётная модель вместо железа
        │
Данные                   сериализация, метаданные эксперимента, хранилище
        │
Анализ                   визуализация, статистика, постобработка

Наиболее ценной границей является абстракция оборудования. Верхние слои оперируют понятиями «мотор» (нечто, принимающее заданное значение) и «датчик» (нечто, у чего значение можно запросить). Что стоит за мотором, источник питания квадруполя, шаговый двигатель экрана или регулятор фазы СВЧ, для процедуры сканирования безразлично. Добавление нового прибора не требует правок в логике эксперимента: пишется новый адаптер, и всё выше него остаётся нетронутым.

Ниже расположен слой коммуникации, отделяющий протокол от смысла. Ускорительными комплексами управляют разные системы, EPICS, Tango, OPC UA, и код эксперимента не должен знать, какая из них находится внизу. На место реального оборудования можно подставить расчётную модель. Тот же сценарий эксперимента, не изменённый ни в одной строке, прогоняется сначала на модели пучка, а потом на установке. Ошибка в логике сканирования обнаруживается на модели, где она ничего не стоит, а не в смену, где она стоит пучкового времени всей группы.

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

Процедура сканирования всегда проходит одни и те же шесть этапов:

  1. Инициализация. Проверить, что необходимое оборудование на связи, выставить начальные параметры.
  2. Планирование. Построить последовательность точек в пространстве параметров.
  3. Выполнение. На каждом шаге выставить параметры и снять данные.
  4. Обработка событий. Вызвать пользовательские функции анализа.
  5. Сериализация. Сохранить результаты и метаданные.
  6. Завершение. Освободить ресурсы, сформировать отчёт.

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

Принцип формулируется так: границы проводят там, где ожидаются изменения. Оборудование на установке меняется, поэтому необходим слой его абстракции. Протоколы управления в разных лабораториях разные, отсюда отдельный слой коммуникации. Чаще всего меняется способ анализа данных, и потому анализ, отделённый от сбора, можно переписывать, не затрагивая остального. Слой, за которым ничего не меняется, только мешает, и вводить его «на будущее» не следует.

Чек-лист: скрипт стал приложением

  • Код разложен по модулям со смыслом: ввод-вывод, физика, графика, CLI.
  • Проект устанавливается на чистую машину командами git clone и pip install -e ..
  • Зависимости с версиями записаны в pyproject.toml, точный слепок собранного окружения можно получить и восстановить.
  • Запуск не требует открывать исходники, все параметры передаются через аргументы CLI и конфигурационный файл.
  • Пути, калибровки и пороги хранятся в конфигурации, а не в коде; секретов в репозитории нет.
  • Вместо print используется logging, и после ночного прогона остаётся журнал, размеченный временем и уровнями.
  • Некорректные данные вызывают внятную ошибку сразу, а не тихий NaN, всплывающий в результатах.
  • Ключевые формулы покрыты тестами; pytest проходит перед каждым коммитом.
  • Публичные функции аннотированы типами и снабжены docstring; mypy не выдаёт замечаний.
  • README отвечает на вопросы «что это», «как поставить», «как запустить».
  • Файл analysis_final_v2_REAL.py удалён, поскольку версии хранятся в git, а не в именах файлов.

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

Задание. Довести чужой недописанный проект до состояния, в котором его можно развернуть: «Деплой стартапа „Котики в мир“».

Основы компьютерных сетей и веб-технологий

Разбираться в сетях физику приходится потому, что почти любые данные, необходимые для работы, сегодня получаются через сеть. Установка отдаёт телеметрию по TCP, каталог наблюдений находится за HTTP-интерфейсом, расчёт запускается на кластере по SSH, а результаты отправляются на сервер группы. Когда что-либо из этого перестаёт работать, место разрыва быстро находит тот, кто представляет путь от программы до провода.

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

Сетевые модели: OSI и TCP/IP

Сеть устроена слоями. Каждый слой решает свою узкую задачу и пользуется услугами слоя, лежащего ниже, ничего не зная о его внутреннем устройстве. Приложение формулирует задачу «отправить эти байты на такой-то адрес» и не учитывает ни маршрутов, ни того, медный там кабель или оптика.

Благодаря такому разделению Wi-Fi был изобретён без переписывания браузеров и протокола IP.

Классическая модель OSI, созданная как эталонная, насчитывает семь уровней:

№УровеньЧем занимаетсяЕдиница данных
7Прикладнойпротоколы приложений HTTP, DNS, FTPданные
6Представлениякодировки, сжатие, шифрованиеданные
5Сеансовыйуправление сессиями связиданные
4Транспортныйдоставка между процессами, TCP и UDPсегмент
3Сетевоймаршрутизация между сетями по IPпакет
2Канальныйпередача между соседними узламикадр
1Физическийбиты через провод или эфирбит

На практике же применяется более компактная модель TCP/IP из четырёх уровней. Физический и канальный в ней объединены в «уровень сетевого доступа», а три верхних уровня OSI слиты в один прикладной. Так устроен интернет; далее изложение ведётся по этой модели снизу вверх.

Уровень сетевого доступа

Самый нижний уровень отвечает на вопрос «как физически доставить биты соседнему узлу».

Физический уровень

Средой передачи здесь являются витая пара и коаксиальный кабель, оптоволокно (одномодовое для дальних линий, многомодовое для коротких), радио в виде Wi-Fi по стандарту 802.11, Bluetooth и сотовой связи. Уровень преобразует единицы и нули в сигнал и обратно.

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

Канальный уровень

Уровнем выше принятые биты собираются в кадры (frames) и передаются между устройствами, находящимися внутри одной локальной сети. Здесь работают Ethernet (стандарт IEEE 802.3) и Wi-Fi (IEEE 802.11), а для соединений «точка-точка» PPP.

У каждого сетевого интерфейса есть MAC-адрес — идентификатор, заданный производителем. Программа знает IP-адрес получателя, а отправить кадр необходимо на MAC-адрес; соответствие между ними выясняет протокол ARP, опрашивая локальную сеть. В институтских сетях встречается VLAN — логическое разделение одной физической сети на независимые сегменты, не видящие друг друга, чтобы, например, сеть управления установкой не пересекалась с офисной.

Просмотреть состояние этого уровня можно следующими командами.

ip link show          # список сетевых интерфейсов и их состояние
ethtool eth0          # скорость, режим дуплекса, статистика ошибок
arp -a                # таблица соответствия IP-адресов и MAC-адресов

Сетевой уровень

Канальный уровень доставляет кадры только внутри одной локальной сети. Чтобы достичь машины, находящейся в другом городе, необходим уровень выше, сетевой, с главным протоколом IP.

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

Адресация

IPv4 задаёт 32-битный адрес, записываемый четырьмя октетами, как 192.168.1.1. Всего таких адресов около четырёх миллиардов, и они давно исчерпаны, отсюда трансляция адресов в любой домашней или институтской сети.

Часть диапазонов зарезервирована под частные сети: их адреса не маршрутизируются в интернет и могут повторяться в разных организациях:

  • 10.0.0.0/8 для больших корпоративных сетей;
  • 172.16.0.0/12 для сетей средних размеров;
  • 192.168.0.0/16 для домашних роутеров.

Запись вида /24 после адреса называется префиксом и указывает, сколько старших битов адреса отведено под номер сети. Запись 192.168.1.0/24 описывает сеть из 256 адресов, различающихся только последним октетом.

IPv6 решает проблему нехватки адресов. Адрес занимает 128 бит и записывается восемью группами по четыре шестнадцатеричные цифры, как 2001:0db8:85a3:0000:0000:8a2e:0370:7334. Длинные цепочки нулей разрешено сокращать двойным двоеточием, и записанный выше адрес превращается в 2001:db8:85a3::8a2e:370:7334.

Маршрутизация

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

$ ip route show
default via 192.168.1.1 dev eth0
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100
10.0.0.0/8 via 192.168.1.254 dev eth0

Выбирается всегда самая конкретная из подходящих строк. Пакет в сеть 192.168.1.0/24 уходит непосредственно в интерфейс eth0, пакет в 10.0.0.0/8 идёт через шлюз 192.168.1.254, а всё, не подошедшее ни под одну строку, попадает в default. Когда сервер соседнего корпуса не отвечает, а до машины, стоящей в той же комнате, ping проходит, проверять нужно именно здесь: чаще всего до нужной сети отсутствует маршрут.

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

$ ip rule
0:    from all lookup local
32766: from all lookup main
32767: from all lookup default

Когда маршрут есть, а пакеты не доходят, требуется выяснить, где они теряются. ip route get показывает маршрут, выбираемый ядром для конкретного адреса; traceroute перечисляет узлы на пути пакета; mtr делает то же непрерывно и подсчитывает потери на каждом переходе — так проще всего показать сетевым администраторам, на каком узле происходит разрыв:

traceroute 8.8.8.8
mtr 8.8.8.8
ip route get 8.8.8.8

Транспортный уровень

IP доставляет пакет до машины, однако на ней работают десятки программ. Какой из них предназначен пакет, указывает номер порта — первая забота транспортного уровня. Вторая — надёжность доставки; здесь выбирают из двух протоколов в зависимости от того, что дороже: потеря данных или задержка.

UDP: быстро и без гарантий

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

Зато у UDP минимальные накладные расходы и задержка. Поэтому на нём работают DNS-запросы (проще переспросить, чем поддерживать соединение), голосовая связь, онлайн-игры и видеотрансляции. Там потерянный фрагмент уже не нужен: пока его запрашивают повторно, разговор уходит вперёд.

TCP: надёжно, но дороже

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

Соединение открывается тройным рукопожатием:

  1. клиент отправляет серверу SYN, означающий «хочу соединиться»;
  2. сервер отвечает SYN-ACK, означающим «согласен, и я тоже хочу»;
  3. клиент отвечает ACK, то есть «принято». Соединение, установленное так, готово к передаче.

Три пакета до первого байта полезных данных и составляют задержку, которой избегает UDP.

Гарантии TCP основаны на полях его заголовка.

| Порт отправителя      | Порт получателя       |
| Номер последовательности (Sequence Number)    |
| Номер подтверждения (Acknowledgment Number)   |
| Смещение | Резерв | Флаги | Размер окна      |
| Контрольная сумма     | Указатель срочности   |

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

Контроль перегрузок

TCP не знает пропускной способности пути заранее и определяет её опытным путём, наращивая скорость, пока не начнутся потери, и снижая её, когда они появились. Алгоритмов для этого разработано много:

  • Tahoe и Reno, классические алгоритмы, реагирующие на потерянный пакет резким снижением скорости;
  • CUBIC, установленный в Linux по умолчанию, восстанавливает потерянную скорость агрессивнее;
  • BBR, современный подход от Google, вместо потерь ориентируется на измеренную полосу и задержку, что заметно лучше работает на длинных линиях между городами.

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

Просмотреть состояние открытых соединений можно следующими командами.

ss -tn                      # открытые TCP-соединения
ss -tlnp                    # какие порты слушают и какие процессы
tcpdump -i any tcp port 80  # перехват пакетов на порту 80

Прикладной уровень

На прикладном уровне работают сами программы и их протоколы: DNS переводит имена в адреса, HTTP переносит данные, TLS их шифрует.

DNS: из имён в адреса

Компьютеры адресуют друг друга числами, а люди именами. Связывает их система доменных имён — распределённая база, возвращающая по имени example.com его IP-адрес.

Чаще всего встречаются четыре типа записей:

ТипЧто содержит
A / AAAAадрес IPv4 / IPv6 для имени
MXпочтовые серверы домена
CNAMEпсевдоним, отсылающий к другому имени
TXTпроизвольный текст; используется для проверки владения доменом

Из Python имя разрешается одной строкой.

import socket

ip_address = socket.gethostbyname("example.com")

DNS является самым частым источником жалоб «ничего не работает». Прежде чем искать ошибку в собственной программе, необходимо проверить командой dig example.com или nslookup example.com, разрешается ли имя вообще.

URL: адрес ресурса

Адрес, вводимый в браузер, устроен по единой схеме.

протокол://хост:порт/путь?запрос#якорь
   https :// api.example.com : 443 /v1/data ?limit=10 #results

Каждая часть предназначена своему уровню: протокол определяет способ взаимодействия, хост разрешается через DNS в IP-адрес, порт указывает транспортному уровню программу, слушающую на сервере, путь и параметры запроса адресуют конкретный ресурс, а якорь не отправляется на сервер и нужен только браузеру, чтобы прокрутить страницу к нужному месту.

Схем существует много, и не все они относятся к вебу: https://api.example.com/v1/data, ftp://ftp.example.com/files, mailto:user@example.com.

HTTP: обмен с сервером

На HTTP основано почти всё сетевое взаимодействие программ. Клиент отправляет текстовый запрос, сервер отвечает текстовым ответом. Состояния между запросами протокол не хранит, каждый запрос самодостаточен.

HTTP-запрос

Запрос представляет собой обычный текст. Первая строка указывает, что и откуда требуется получить, далее следуют заголовки, пары «имя: значение»:

GET /index.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
Connection: keep-alive

Первое слово запроса — метод, он сообщает серверу, что требуется сделать с ресурсом:

МетодДействие
GETполучить ресурс; ничего не меняет на сервере
POSTотправить данные, создать новый ресурс
PUTзаменить ресурс целиком
PATCHизменить часть ресурса
DELETEудалить ресурс

GET считается безопасным и повторяемым, поэтому результат разрешено кешировать, а запрос повторять при обрыве связи. С POST так поступать нельзя: повторённый запрос создаст вторую запись.

Ответ сервера

Ответ устроен так же и содержит статусную строку, заголовки и тело:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
Server: nginx
Date: Mon, 21 Nov 2022 00:18:53 GMT

Самым информативным в ответе является трёхзначный код в первой строке. Достаточно запомнить значение первой цифры:

КодСмыслТипичные представители
1xxинформационные100 Continue
2xxвсё получилось200 OK, 201 Created
3xxперенаправление или ответ из кеша301 Moved Permanently, 304 Not Modified
4xxошибся клиент404 Not Found, 401 Unauthorized, 429 Too Many Requests
5xxошибся сервер500 Internal Server Error, 503 Service Unavailable

При 4xx повторять запрос бессмысленно, пока клиент не исправит его сам; при 5xx имеет смысл подождать и повторить попытку.

Работа с HTTP в Python

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

Использование сокетов

Открывается сокет, в него записываются две строки запроса, читается ответ.

import socket

# TCP-соединение
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect(('example.com', 80))
    request = b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'
    sock.sendall(request)
    response = sock.recv(4096)

Далее требуется найти конец заголовков, разобрать кодировку, дочитать тело по Content-Length, обработать редирект. Поэтому применяют готовое решение.

Библиотека requests

requests берёт на себя соединения, редиректы, кодировки и разбор JSON.

import requests

response = requests.get('https://api.example.com/data')
print(response.status_code)
print(response.headers)
print(response.json())

В коде, работающем ночью без присмотра, добавляют timeout= к каждому запросу — без него зависший сервер остановит сбор данных навсегда — и повтор с нарастающей паузой при ошибках 5xx, чтобы временный отказ сервера не стоил ночи работы.

TLS: буква S в HTTPS

Обычный HTTP передаёт всё открытым текстом, включая пароли и токены; любой узел на пути видит содержимое. TLS (прежде SSL) добавляет к соединению шифрование и проверку подлинности сервера по сертификату: браузер убеждается, что взаимодействует с тем сайтом, за который тот себя выдаёт.

В Python шифрование добавляется обёрткой поверх обычного сокета.

import ssl
import socket

context = ssl.create_default_context()
with socket.create_connection(('example.com', 443)) as sock:
    with context.wrap_socket(sock, server_hostname='example.com') as ssock:
        ssock.sendall(b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n')
        response = ssock.recv(4096)

Веб-скрапинг и парсинг HTML

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

Библиотека BeautifulSoup

Разбирать HTML регулярными выражениями бесполезно: разметка вложенная и часто некорректная. BeautifulSoup строит из страницы дерево и ищет в нём по тегам и атрибутам:

from bs4 import BeautifulSoup
import requests

html = requests.get('http://example.com').text
soup = BeautifulSoup(html, 'html.parser')

# Извлечение данных
title = soup.find('title').text
links = soup.find_all('a', href=True)
for link in links:
    print(link['href'], link.text)

Корректный скрапинг

Скрапингом легко навредить и себе, и чужому серверу. Правила следующие:

  1. Необходимо ознакомиться с robots.txt, файлом в корне сайта, где владелец указывает, что разрешено обходить роботам, а что нет. Технически он ничего не запрещает — это договорённость, но нарушать её не следует.
  2. Между запросами необходимы паузы. Сотня запросов в секунду с одного адреса неотличима от атаки и приводит к блокировке.
  3. Программа должна представляться в заголовке User-Agent. Следует указать, что это за программа и как связаться с её автором: администратору, читающему логи, это упрощает работу, а автору снижает вероятность блокировки.
  4. Ошибки должны обрабатываться. Сеть ненадёжна, и код должен переживать таймаут, 503 и изменившуюся вёрстку, а не завершаться посреди ночного сбора данных.
  5. Скачанное кешируется. Повторный запуск разбора не должен снова загружать те же страницы: так и быстрее, и корректнее по отношению к серверу.

Если у сайта есть API, лучше пользоваться им, а не разбором HTML: вёрстка меняется без предупреждения, объявленный интерфейс гораздо реже.

Работа с API

Если у источника есть API, взаимодействие ведётся на языке данных: запрос с параметрами, ответ в JSON, оговорённый набор полей вместо вёрстки, переделываемой при каждой смене дизайна. Как сервисы договариваются между собой и что в таком договоре нарушается, когда сервисов становится много, подробно рассмотрено у Клеппмана [16].

Публичные API

Устроены такие интерфейсы одинаково независимо от того, что они отдают, курсы валют, переводы слов или каталог наблюдений: адрес сервиса, словарь параметров и ключ доступа. Пример — словарь Яндекса:

import requests
import json

# Пример работы с API словаря
DICTIONARY_API = 'https://dictionary.yandex.net/api/v1/dicservice.json/lookup'
params = {
    'key': 'your_api_key',
    'lang': 'en-ru',
    'text': 'hello'
}

response = requests.get(DICTIONARY_API, params=params)
data = response.json()
translations = data['def'][0]['tr']

Ключ доступа является личным паролем пользователя к сервису, и в коде ему не место: его следует хранить в переменной окружения или в конфигурации вне репозитория. Вторая забота — квота на число запросов. Превысивший её клиент получает 429 Too Many Requests, а при упорстве и блокировку по адресу, поэтому цикл по десяти тысячам записей необходимо замедлять паузой.

OAuth авторизация

Когда речь идёт о личных данных пользователя, простого ключа недостаточно. OAuth описывает схему, при которой пользователь не передаёт программе свой пароль. Вместо него сервис выдаёт ей токен, ограниченный по правам и по времени и отзываемый в любой момент. Токен прикладывается к каждому запросу в заголовке Authorization:

class ApiClient:
    def __init__(self, token):
        self.session = requests.Session()
        self.session.headers['Authorization'] = f'Bearer {token}'
    
    def get_user_info(self):
        response = self.session.get('https://api.example.com/user')
        return response.json()

requests.Session() хранит заголовки между запросами и переиспользует открытое TCP-соединение, поэтому серия обращений к одному серверу выполняется заметно быстрее, чем столько же отдельных вызовов requests.get.

JSON формат

JavaScript Object Notation — текстовый формат обмена данными, почти взаимно однозначно соответствующий словарям и спискам Python.

import json

# Сериализация
data = {'name': 'John', 'age': 30}
json_string = json.dumps(data)

# Десериализация
parsed_data = json.loads(json_string)

В стандарте JSON нет ни NaN, ни бесконечностей. json.dumps запишет их как NaN и Infinity, однако это уже не JSON, и чужой разборщик на такой строке завершится ошибкой. Пропуски в измерениях надёжнее кодировать явным null.

Асинхронные HTTP-запросы

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

Библиотека httpx

httpx повторяет интерфейс requests, но поддерживает оба режима, синхронный и асинхронный, так что при переходе код почти не переписывается:

import asyncio

import httpx

# Синхронные запросы
with httpx.Client() as client:
    response = client.get('https://example.com')
    print(response.status_code)

# Асинхронные запросы
async def fetch_data():
    async with httpx.AsyncClient() as client:
        response = await client.get('https://example.com')
        return response.json()

print(asyncio.run(fetch_data()))

Устройство async и await рассматривается в главе про асинхронность. Сто одновременных запросов ускоряют сбор данных в десятки раз, тысяча выглядит для чужого сервера как атака. Параллелизм необходимо ограничивать явно, семафором на десяток-другой запросов.

Резюме

Таким образом, запоминать следует не столько детали протоколов, сколько идею слоёв: каждый уровень пользуется услугами нижнего и ничего не знает о его устройстве. Поэтому один и тот же код на requests одинаково работает и по Wi-Fi, и по оптике.

Когда что-либо не работает, уровни проверяются в порядке цены проверки, а не строго снизу вверх: сначала самое дешёвое и самое частое. Разрешение имени стоит в таблице рано по этой причине: оно проверяется одной командой, а нарушается чаще прочего, хотя уровень у него прикладной.

Что проверяемУровеньЧем смотреть
Наличие линкафизический, канальныйip link, ethtool
Разрешение имениприкладной (DNS)dig, nslookup
Прохождение пакетовсетевойping, traceroute, mtr
Открытый порт и слушающий его процесстранспортныйss -tlnp
Содержимое ответа сервераприкладнойcurl -v, tcpdump

Бессмысленно отлаживать код запроса, если имя сервера не разрешается, и проверять DNS, если не поднят сетевой интерфейс.

Из инструментов Python достаточно двух: requests для обычных запросов и httpx там, где требуется асинхронность. Сокеты необходимы для понимания того, что происходит под библиотеками.

Задание. Поставить перед приложением nginx и выяснить, почему запрос не доходит: «Деплой стартапа „Котики в мир“».

Базы данных

Файлов для хранения данных достаточно до тех пор, пока накопленное не потребовалось нескольким людям одновременно.

Ограничения хранения в файлах

Хранение данных в лаборатории почти всегда развивается по одному сценарию. Сначала появляется result.csv. Затем result_final.csv, result_final_v2.csv и папка 2026-08-20/ с двадцатью файлами, названными по энергии пучка, номеру захода и фамилии оператора. Через год накапливаются десятки гигабайт CSV-файлов, и вопрос «показать все измерения канала BPM01 при энергии выше 10 МэВ» превращается в вечер работы со скриптом, рекурсивно обходящим папки и разбирающим имена файлов.

Файлы и папки оказываются непригодными в трёх отношениях:

  • Конкурентный доступ. Скрипт сбора данных записывает файл, а исследователь в этот момент открывает его для анализа и читает недописанную строку. Два процесса записывают одновременно, и файл оказывается повреждённым.
  • Целостность. CSV не проверяет ничего. В столбец с температурой попадает строка "error", порядок столбцов от файла к файлу различается, а измерение ссылается на заход, удалённый кем-то вместе с папкой.
  • Поиск. Чтобы найти нужные записи, приходится читать все файлы целиком в память. На миллионах записей это занимает минуты; при правильно организованных данных тот же запрос выполняется за миллисекунды.

Все три задачи решает СУБД — система управления базами данных. Она берёт на себя хранение, разграничивает одновременный доступ, следит за целостностью и предоставляет язык, приспособленный для запросов, на котором описывается, что нужно найти, а не как перебирать файлы.

Реляционная модель

Реляционная модель, предложенная Эдгаром Коддом в 1970 году, до сих пор считается основной. Данные укладываются в таблицы, называемые в теории отношениями (relations). Строка таблицы содержит запись об одном объекте, а столбец задаёт атрибут, наделённый фиксированным типом.

Спроектируем схему для типичного эксперимента. Имеются измерительные кампании, внутри каждой находятся заходы (runs), различающиеся параметрами установки, а внутри каждого захода — точки измерений, снятые с датчиков.

experiments ──< runs ──< measurements
   (1)          (N)          (N)
  • experiments описывает кампанию, её название, установку и дату начала;
  • runs описывает заход, его кампанию, номер, энергию пучка и оператора;
  • measurements описывает отдельную точку, её заход, канал, время и значение.

Знак ──< читается как связь «один ко многим»: у одной кампании много заходов, а у одного захода много снятых точек.

Ключи и связи

Первичным ключом (primary key) называется столбец или набор столбцов, однозначно идентифицирующий строку. Обычно это суррогатный целочисленный id, выдаваемый базой самостоятельно.

Внешним ключом (foreign key) называется столбец, ссылающийся на первичный ключ другой таблицы. runs.experiment_id хранит id кампании, и таким образом строки связываются между собой. СУБД следит за валидностью ссылки: нельзя вставить заход с experiment_id = 42, если кампании с таким id нет, и нельзя удалить кампанию, на которую ссылаются заходы.

Связь «многие ко многим» (например, публикации и авторы) выражается через промежуточную таблицу, снабжённую двумя внешними ключами, и отдельного механизма для неё не требуется.

Нормализация

Соблазнительно поместить всё в одну широкую таблицу, повторяя в каждой строке измерения название кампании, энергию и фамилию оператора. Такая схема работает до первой правки. Если оператор оказался «Ивановым», а не «Ивановым А.», обновлять приходится миллион строк, а при сбое скрипта на середине данные остаются противоречивыми. Это явление называется аномалиями обновления.

Нормализацией называется устранение дублирования, при котором каждый факт хранится один раз, а всё остальное на него ссылается. Формально она описывается нормальными формами, а для практики достаточно первых трёх, сводящихся к мнемонике: неключевые атрибуты зависят «от ключа, от всего ключа и ни от чего, кроме ключа». Энергия является свойством захода, а не точки измерения, следовательно, ей место в runs. Название установки принадлежит кампании, поэтому оно хранится в experiments.

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

Основы SQL на примере SQLite

SQL (Structured Query Language) — декларативный язык для реляционных баз. Пользователь описывает нужный результат, а СУБД самостоятельно решает, как его получить.

Изучение ведётся на SQLite, встраиваемой СУБД без сервера: вся база лежит в одном файле, а драйвер входит в стандартную библиотеку Python (модуль sqlite3). При этом SQLite остаётся полноценной реляционной СУБД с транзакциями, работающей с базами в десятки гигабайт. Для лабораторных данных этого достаточно.

Открыть консоль SQLite можно командой sqlite3 lab.db, которая создаёт файл самостоятельно, а просмотреть базу в графическом интерфейсе позволяет программа DB Browser for SQLite.

CREATE TABLE: создание таблиц

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

CREATE TABLE experiments (
    id         INTEGER PRIMARY KEY,   -- первичный ключ, база выдаёт сама
    name       TEXT NOT NULL UNIQUE,  -- название кампании, без повторов
    setup      TEXT,                  -- описание установки
    started_at TEXT NOT NULL          -- дата в ISO-формате: '2026-08-20'
);

CREATE TABLE runs (
    id            INTEGER PRIMARY KEY,
    experiment_id INTEGER NOT NULL REFERENCES experiments(id),  -- внешний ключ
    run_number    INTEGER NOT NULL,
    energy_mev    REAL NOT NULL,      -- энергия пучка в заходе, МэВ
    operator      TEXT,
    started_at    TEXT NOT NULL,
    UNIQUE (experiment_id, run_number)  -- номер захода уникален внутри кампании
);

CREATE TABLE measurements (
    id      INTEGER PRIMARY KEY,
    run_id  INTEGER NOT NULL REFERENCES runs(id),
    channel TEXT NOT NULL,            -- имя канала: 'BPM01', 'T_magnet', ...
    t       REAL NOT NULL,            -- время от начала захода, с
    value   REAL NOT NULL             -- измеренное значение
);

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

Внешние ключи SQLite проверяет только при явно включённой проверке, и включать её необходимо в каждом новом соединении:

PRAGMA foreign_keys = ON;

Без этой строки вставка ссылки на несуществующую кампанию пройдёт без ошибок, и база незаметно накопит нарушенные связи. PostgreSQL и MySQL проверяют внешние ключи по умолчанию, однако MySQL без предупреждения игнорирует объявленные ключи в таблицах MyISAM, а сессионная переменная foreign_key_checks=0, выставляемая всеми дампами mysqldump, снимает проверку и в InnoDB.

INSERT: вставляем данные

Заполняемые базой столбцы перечислять не требуется: id она выдаст очередной. experiment_id для захода необходимо указать, иначе неизвестно, к какой кампании он относится.

INSERT INTO experiments (name, setup, started_at)
VALUES ('Калибровка датчиков положения', 'стенд ЛИУ', '2026-08-20');

INSERT INTO runs (experiment_id, run_number, energy_mev, operator, started_at)
VALUES (1, 1, 12.5, 'Иванов', '2026-08-20T10:15:00');

-- несколько строк одним запросом
INSERT INTO measurements (run_id, channel, t, value) VALUES
    (1, 'BPM01',    0.0, 0.412),
    (1, 'BPM01',    0.1, 0.418),
    (1, 'T_magnet', 0.0, 42.3);

Последняя форма, со списком кортежей после VALUES, вставляет группу строк одним запросом; для тысячи точек с АЦП это на порядки быстрее тысячи отдельных INSERT; причина рассматривается в разделе про транзакции.

SELECT: выборка

Вопрос, требовавший вечера работы со скриптом-обходчиком папок, записывается в три строки:

-- все точки канала BPM01 из первого захода, по возрастанию времени
SELECT t, value
FROM measurements
WHERE run_id = 1 AND channel = 'BPM01'
ORDER BY t;

-- десять последних заходов с энергией выше 10 МэВ
SELECT run_number, energy_mev, started_at
FROM runs
WHERE energy_mev > 10
ORDER BY started_at DESC
LIMIT 10;

WHERE фильтрует строки, ORDER BY сортирует отобранное (DESC задаёт убывание), LIMIT ограничивает результат. Звёздочка SELECT * возвращает все столбцы, что удобно в консоли, однако в скриптах нужные столбцы перечисляются явно.

JOIN: соединение таблиц

Нормализация разложила данные по трём таблицам, а JOIN собирает разложенное обратно. Соединение сопоставляет строки по условию, обычно «внешний ключ равен первичному»:

-- каждая точка вместе с номером захода и названием кампании
SELECT e.name, r.run_number, m.t, m.value
FROM measurements AS m
JOIN runs        AS r ON r.id = m.run_id
JOIN experiments AS e ON e.id = r.experiment_id
WHERE m.channel = 'BPM01'
ORDER BY e.name, r.run_number, m.t;

AS m задаёт псевдоним, избавляющий от длинных имён. Кроме обычного (внутреннего) JOIN существует LEFT JOIN, сохраняющий строки левой таблицы даже при отсутствии пары справа. Таким образом находят заходы без измерений.

GROUP BY: агрегация

Агрегатные функции (COUNT, AVG, SUM, MIN, MAX) сворачивают отобранную группу строк в одно число, а GROUP BY задаёт признак группировки:

-- сводка по каждому заходу: число точек и статистика сигнала
SELECT r.run_number,
       r.energy_mev,
       COUNT(*)     AS n_points,
       AVG(m.value) AS mean_value,
       MIN(m.value) AS min_value,
       MAX(m.value) AS max_value
FROM measurements AS m
JOIN runs AS r ON r.id = m.run_id
WHERE m.channel = 'BPM01'
GROUP BY r.id
HAVING COUNT(*) >= 100   -- отбрасываем слишком короткие заходы
ORDER BY r.energy_mev;

HAVING фильтрует после группировки, по вычисленным агрегатам; WHERE отбирает строки до неё.

Индексы: ускорение поиска

Без индекса запрос с WHERE заставляет базу просматривать всю таблицу, строку за строкой. Индекс — это дополнительная структура (обычно B-дерево), позволяющая находить строки по значению столбца за логарифмическое время, наподобие предметного указателя в книге.

CREATE INDEX idx_measurements_run_channel
ON measurements (run_id, channel);

После этого выборка точек конкретного канала конкретного захода не зависит от общего размера таблицы. Первичный ключ и UNIQUE индексируются автоматически, а внешние ключи и столбцы, часто участвующие в фильтрации, приходится индексировать отдельно. Платой за индекс являются место на диске и несколько более медленная вставка, поэтому создавать индексы на все столбцы подряд не следует. Проверить, использует ли запрос построенный индекс, можно, приписав перед ним EXPLAIN QUERY PLAN.

Тот же SQL из Python

Модуль sqlite3, входящий в стандартную библиотеку, устанавливать не требуется.

import sqlite3

# открываем (или создаём) файл базы; ":memory:" — временная база в памяти
con = sqlite3.connect("lab.db")

# в SQLite проверка внешних ключей по умолчанию выключена — включаем
con.execute("PRAGMA foreign_keys = ON")

cur = con.cursor()  # курсор выполняет запросы и отдаёт результаты

# создаём таблицу, если её ещё нет (текст запроса — тот же SQL)
cur.execute("""
    CREATE TABLE IF NOT EXISTS measurements (
        id      INTEGER PRIMARY KEY,
        run_id  INTEGER NOT NULL,
        channel TEXT NOT NULL,
        t       REAL NOT NULL,
        value   REAL NOT NULL
    )
""")

# одиночная вставка: значения передаём кортежем, в запросе — знаки вопроса
cur.execute(
    "INSERT INTO measurements (run_id, channel, t, value) VALUES (?, ?, ?, ?)",
    (1, "BPM01", 0.0, 0.412),
)

# массовая вставка: executemany принимает любой итерируемый объект;
# read_adc() здесь — пользовательская функция опроса АЦП
points = [(1, "BPM01", 0.1 * i, read_adc()) for i in range(1000)]
cur.executemany(
    "INSERT INTO measurements (run_id, channel, t, value) VALUES (?, ?, ?, ?)",
    points,
)
con.commit()  # фиксируем изменения на диске

# выборка: по курсору можно итерироваться, каждая строка — кортеж
for t, value in cur.execute(
    "SELECT t, value FROM measurements WHERE channel = ? ORDER BY t",
    ("BPM01",),
):
    print(t, value)

con.close()

Для небольших выборок удобны cur.fetchone() и cur.fetchall(), однако итерация по курсору экономнее: строки читаются по мере надобности и не оседают в памяти все сразу.

Параметризованные запросы и SQL-инъекции

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

channel = input("Канал: ")

# ТАК ДЕЛАТЬ НЕЛЬЗЯ: запрос склеивается из чужого текста
cur.execute(f"SELECT t, value FROM measurements WHERE channel = '{channel}'")

Если пользователь введёт ' OR '1'='1, итоговый запрос превратится в ... WHERE channel = '' OR '1'='1', где условие всегда истинно, фильтр исчезает, а наружу утекает вся таблица. Ввод вида '; DROP TABLE measurements; -- в СУБД, чей драйвер исполняет несколько команд подряд, уничтожит таблицу. Это явление называется SQL-инъекцией, одной из самых старых и до сих пор распространённых уязвимостей (см. xkcd про школьника Bobby Tables).

Параметризованный запрос неуязвим по построению: текст запроса и данные передаются драйверу раздельно, и введённая строка остаётся строкой при любом содержимом:

# правильно: запрос отдельно, данные отдельно
cur.execute("SELECT t, value FROM measurements WHERE channel = ?", (channel,))

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

Транзакции и ACID

Транзакция — группа запросов, выполняемая как единое целое: применяются либо все, либо ни один. Её гарантии описывает аббревиатура ACID:

  • Atomicity (атомарность) означает неделимость транзакции: сбой, случившийся на середине, откатывает всё сделанное;
  • Consistency (согласованность) требует, чтобы база переходила из одного корректного состояния в другое, не нарушая ограничений (ключи, NOT NULL, UNIQUE);
  • Isolation (изолированность) скрывает промежуточные состояния, возникающие в параллельных транзакциях, друг от друга;
  • Durability (долговечность) сохраняет подтверждённые (COMMIT) данные даже при внезапном отключении питания.

Заход и снятые в нём точки должны попасть в базу вместе: заход без точек и точки без захода одинаково бессмысленны.

def save_run(con, run_row, points):
    """Сохраняет заход и все его измерения одной транзакцией."""
    cur = con.cursor()
    try:
        # первый INSERT неявно открывает транзакцию
        cur.execute(
            "INSERT INTO runs "
            "(experiment_id, run_number, energy_mev, started_at) "
            "VALUES (?, ?, ?, ?)",
            run_row,
        )
        run_id = cur.lastrowid  # id только что вставленной строки
        cur.executemany(
            "INSERT INTO measurements (run_id, channel, t, value) "
            "VALUES (?, ?, ?, ?)",
            [(run_id, ch, t, v) for ch, t, v in points],
        )
        con.commit()    # всё получилось — фиксируем
    except sqlite3.Error:
        con.rollback()  # любая ошибка — откатываем целиком
        raise

Ту же пару commit/rollback обеспечивает конструкция with con:, подтверждающая транзакцию при выходе из блока и откатывающая её при исключении. Транзакции ускоряют и массовую вставку: миллион INSERT с commit после каждого означает миллион синхронизаций с диском, а те же вставки в одной транзакции выполняются на порядки быстрее.

SQLite, PostgreSQL или MySQL

Три реляционные СУБД, чаще всего встречающиеся в лаборатории, различаются не языком запросов (SQL везде почти один и тот же), а тем, кто и каким образом получает доступ к базе.

SQLitePostgreSQLMySQL / MariaDB
Архитектуравстраиваемая библиотека, вся база в одном файлеклиент-сервернаяклиент-серверная
Установкане нужна, есть в Pythonотдельный сервисотдельный сервис
Одновременная записьодин пишущий процессмного клиентовмного клиентов
Типизациядинамическая, мягкаястрогая, богатая (массивы, JSONB, диапазоны)строгая, скромнее
Права доступанет (права на файл)пользователи, роли, гранулярные правапользователи и права
Сильная сторонанулевая настройка, переносимостьфункциональность и расширения (PostGIS, TimescaleDB)распространённость в веб-хостинге
Типичный случайлокальные данные, прототипы, файлы до единиц ГБсерверное приложение, общая база группывеб-проекты, легаси-системы

Если данные лежат на локальном диске и работают с ними один исследователь и его скрипты, то выбирается SQLite. Если база нужна нескольким людям или сервисам по сети, если в неё пишут одновременно и требуются права доступа, то предпочтительнее PostgreSQL [17]: сегодня это выбор по умолчанию для серверной СУБД. MySQL чаще достаётся как данность вместе с унаследованным проектом или хостингом, чем выбирается осознанно для новой системы.

ORM: SQLAlchemy

SQL в виде строк внутри Python-кода не проверяется до запуска, результаты приходят кортежами, а логика, связывающая объект с его окружением, оказывается распределённой по запросам. ORM (Object-Relational Mapping) отображает таблицы на классы, строки на объекты, а внешние ключи на атрибуты-связи. Стандартом де-факто в Python является SQLAlchemy.

from sqlalchemy import ForeignKey, create_engine, select
from sqlalchemy.orm import (
    DeclarativeBase, Mapped, mapped_column, relationship, Session,
)

class Base(DeclarativeBase):
    pass

class Run(Base):
    __tablename__ = "runs"

    id: Mapped[int] = mapped_column(primary_key=True)
    energy_mev: Mapped[float]
    # связь "один ко многим": у захода — список измерений
    measurements: Mapped[list["Measurement"]] = relationship(
        back_populates="run"
    )

class Measurement(Base):
    __tablename__ = "measurements"

    id: Mapped[int] = mapped_column(primary_key=True)
    run_id: Mapped[int] = mapped_column(ForeignKey("runs.id"))
    channel: Mapped[str]
    value: Mapped[float]
    run: Mapped[Run] = relationship(back_populates="measurements")

engine = create_engine("sqlite:///lab.db")  # та же база, поменяется только URL
Base.metadata.create_all(engine)            # создаёт таблицы по классам

with Session(engine) as session:
    # объекты вместо INSERT: связи расставляются сами
    run = Run(energy_mev=12.5)
    run.measurements.append(Measurement(channel="BPM01", value=0.412))
    session.add(run)
    session.commit()

    # запрос вместо SELECT ... JOIN: строится из Python-выражений
    stmt = select(Measurement).join(Run).where(Run.energy_mev > 10)
    for m in session.execute(stmt).scalars():
        print(m.channel, m.value, m.run.energy_mev)

Смена SQLite на PostgreSQL сводится к правке одной строки в create_engine; остальной код не изменяется.

ORM оправдан в приложении с десятком связанных таблиц и развивающейся схемой (накопленные миграции удобно вести инструментом Alembic), при командной разработке и множестве типовых операций «создать-прочитать-обновить-удалить». Чистый SQL предпочтителен для аналитических запросов с многоуровневыми агрегатами и оконными функциями (SQL здесь короче ORM-конструкций), для массовых загрузок и разовых скриптов. ORM не освобождает от знания SQL: он генерирует тот же SQL, и когда запрос выполняется медленно, разбираться приходится со сгенерированным текстом (для этого достаточно включить create_engine(..., echo=True) и просмотреть запросы, уходящие в базу).

Нереляционные базы данных

Под конкретные профили нагрузки существуют специализированные базы, объединяемые общим названием «NoSQL». Их устройство и цена каждого компромисса подробно рассмотрены у Клеппмана [16].

Проще всех устроено хранилище пар «ключ → значение», Redis. Оно располагается в оперативной памяти, и операции занимают микросекунды. Типичные роли: кеш (результат тяжёлого запроса или расчёта помещается под ключ с заданным временем жизни), очереди задач между процессами (на Redis работают брокеры для Celery), счётчики и pub/sub-уведомления. Запись на диск включается по желанию. Redis отвечает за скорость, а не за главное хранилище истины; типичные приёмы собраны у Карлсона [19].

Там, где у записей нет общей структуры, применяют документные базы наподобие MongoDB. Единицей хранения служит документ, вложенная структура наподобие JSON; документы собираются в коллекции, и жёсткой схемы нет, поэтому соседние документы могут иметь разные поля. Это удобно для разнородных метаданных, меняющих структуру от записи к записи. «Отсутствие схемы» означает, что схема находится не в базе, а в коде, и проверять её также приходится разработчику. Практические следствия рассмотрены в руководстве по MongoDB [18].

Телеметрию установки (давление в вакуумной камере, токи магнитов, температуры), приходящую с сотен датчиков каждую секунду годами, в ускорительной технике называют slow control и хранят в базах временных рядов. InfluxDB предназначена для потока «метка времени → значение» с тегами. Такие базы позволяют автоматически прореживать и удалять устаревшие данные (retention policies), быстро агрегировать по окнам времени («среднее за каждую минуту последних суток») и стыкуются с Grafana для дашбордов мониторинга. В мире PostgreSQL то же обеспечивает расширение TimescaleDB.

Наконец, для аналитики на миллиардах записей созданы колоночные базы, самая известная из которых называется ClickHouse. Она хранит данные не по строкам, а по столбцам. Значения одного столбца располагаются рядом, хорошо сжимаются, и запрос «среднее value по миллиарду строк» читает с диска только нужные столбцы, укладываясь в секунды на обычном сервере вместо часов; диалект SQL привычный. Платой является слабость в точечных обновлениях и удалениях: колоночные базы рассчитаны на дозапись и анализ, а не на правку отдельных строк.

Выбор хранилища: пример реальной системы

Система хранения данных ускорительного комплекса использует сразу три базы. Такой подход называется полиглотным хранением (polyglot persistence): под каждый профиль данных выбирается своё хранилище.

В PostgreSQL помещается всё, что должно быть строгим: пользователи и их роли, конфигурация ускорителя и его элементов, метаданные экспериментов (что за эксперимент, когда, кто ответственный), расписание работы установки и журнал событий. Структура этих данных известна заранее, меняется редко, а целостность критична: если запись эксперимента ссылается на несуществующего оператора, это ошибка, обнаруживать которую должна база, а не код. Здесь необходимы схема, внешние ключи и ACID-транзакции.

В MongoDB помещается всё разнородное: результаты измерений, данные пучка (траектории, размеры, интенсивность), временные ряды параметров, результаты моделирования и оптимизации. У этих данных нет единой схемы: каждый новый тип эксперимента приносит свой набор полей. Требование миграции базы под каждую новую методику приводит к тому, что данные складывают мимо базы, в файлы на рабочем столе.

Redis хранит недолго живущее: кеш частых запросов, текущие состояния устройств, сессии пользователей веб-интерфейса, очереди сообщений между компонентами. Значение тока магнита в текущий момент запрашивается сотни раз в секунду, и обращаться за ним в дисковую базу бессмысленно. Потеря этих данных при перезапуске неприятна, но не катастрофична: истина хранится в двух других базах.

Выбор сводится к одному признаку на каждую базу.

Признак данныхПодходящая база
Структура известна заранее, а нарушение связей недопустимоPostgreSQL
Структура меняется от записи к записиMongoDB
Данные нужны очень быстро, а их потеря не смертельнаRedis

Цена полиглотного хранения: как только данные распределяются по трём базам, исчезает транзакция, охватывающая их все, и одним атомарным действием уже нельзя записать эксперимент в PostgreSQL вместе с его результатами в MongoDB. Согласованность приходится обеспечивать вручную, распределёнными транзакциями или компенсирующими действиями, откатывающими то, что успело записаться. Эта сложность окупается, только когда профили данных действительно различны.

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

Одна задача в четырёх хранилищах

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

Код почти не меняется от варианта к варианту; меняется то, где лежат данные и во что обходится каждая операция. Сложность операций рассматривалась в главе «Основные структуры данных».

Сервис написан на микрофреймворке Flask: декоратор @app.route привязывает функцию к URL и методу, объект request даёт доступ к параметрам и телу запроса. Заголовочная часть общая для всех вариантов и далее не повторяется.

import json
from contextlib import closing

import psycopg2
import redis
from flask import Flask, request

app = Flask(__name__)

FILE_PATH = 'data.txt'
PAGE_SIZE = 20
ELEMENT_SIZE = 64
FILL_CHAR = b' '
TTL = 60
postgres_creds = {'dbname': 'lab', 'host': 'localhost'}
redis_creds = {'host': 'localhost', 'port': 6379}

Наивное решение. GET

Самый прямолинейный вариант — обычный текстовый файл, по строке на запись. Чтобы отдать одну страницу, приходится прочитать весь файл, а при изменении и удалении ещё и переписать его заново: \(O(n)\) на каждую операцию.

@app.route('/', methods=['GET'])
def paginated_get():
    page = int(request.args.get('page', '0'))
    first = page * PAGE_SIZE
    last = first + PAGE_SIZE
    result = []
    with open(FILE_PATH, 'r') as f:
        for i, line in enumerate(f.readlines()[first:last]):
            result.append({'id': first + i, 'data': line.strip()})
    return {"result": result}

Наивное решение. POST

@app.route('/', methods=['POST'])
def post():
    data = request.json['data']
    with open(FILE_PATH, 'a') as f:
        f.write(data + '\n')
    return {}, 201

Наивное решение. PUT

@app.route('/<int:data_id>', methods=['PUT'])
def put(data_id):
    data = request.json['data']
    with open(FILE_PATH, 'r') as f:
        new_data = f.readlines()
        new_data[data_id] = data + '\n'
    with open(FILE_PATH, 'w') as f:
        f.writelines(new_data)
    return {}, 204

Наивное решение. DELETE

@app.route('/<int:data_id>', methods=['DELETE'])
def delete(data_id):
    with open(FILE_PATH, 'r') as f:
        new_data = f.readlines()
    del new_data[data_id]
    with open(FILE_PATH, 'w') as f:
        f.writelines(new_data)
    return {}, 204

Фиксированные записи. GET

Если отвести каждой записи одинаковое число байт, файл превращается в массив, и нужная запись читается сразу, переходом к её смещению через seek(). Зато удаление дорожает: хвост файла за ней приходится сдвигать вручную.

@app.route('/', methods=['GET'])
def paginated_get():
    page = int(request.args.get('page', '0'))
    first = page * PAGE_SIZE
    result = []
    with open(FILE_PATH, 'rb') as f:
        f.seek(first * ELEMENT_SIZE)
        data = f.read(PAGE_SIZE * ELEMENT_SIZE)
        for i in range(PAGE_SIZE):
            result.append(
                {
                    "id": first + i,
                    "data": (
                        data[i * ELEMENT_SIZE: (i + 1) * ELEMENT_SIZE].
                        strip(FILL_CHAR).decode('utf-8')
                    )
                }
            )
    return {"result": result}

Фиксированные записи. POST

@app.route('/', methods=['POST'])
def post():
    data = str(request.json['data'])
    with open(FILE_PATH, 'a+b') as f:
        f.write(data.encode('utf-8').ljust(ELEMENT_SIZE, FILL_CHAR))
    return {}, 201

Фиксированные записи. PUT

@app.route('/<int:data_id>', methods=['PUT'])
def put(data_id):
    data = request.json['data']
    with open(FILE_PATH, 'r+b') as f:
        f.seek(data_id * ELEMENT_SIZE)
        f.write(data.encode('utf-8').ljust(ELEMENT_SIZE, FILL_CHAR))
    return {}, 204

Фиксированные записи. DELETE

@app.route('/<int:data_id>', methods=['DELETE'])
def delete(data_id):
    with open(FILE_PATH, 'r+b') as f:
        point = data_id * ELEMENT_SIZE
        f.seek(point)
        while True:
            f.seek(point + ELEMENT_SIZE)
            complex_data = f.read(ELEMENT_SIZE)
            f.seek(point)
            if len(complex_data):
                f.write(complex_data)
                point += ELEMENT_SIZE
            else:
                f.truncate()
                break
    return {}, 204

Реляционная база. GET

База данных скрывает эту механику за индексами: поиск по ключу, вставку и удаление она выполняет не хуже структур данных из главы «Основные структуры данных».

@app.route('/', methods=['GET'])
def paginated_get():
    page = int(request.args.get('page', '0'))
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'SELECT "id", "todo" FROM "todos" '
                'ORDER BY "id" OFFSET %s LIMIT %s;',
                (page * PAGE_SIZE, PAGE_SIZE)
            )
            return {
                "result": [{"id": row[0], "data": row[1]} for row in cursor]
            }

Реляционная база. POST

@app.route('/', methods=['POST'])
def post():
    data = str(request.json['data'])
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'INSERT INTO "todos" ("todo") VALUES (%s) RETURNING "id";',
                (data,)
            )
            conn.commit()
            return {"id": cursor.fetchone()[0]}, 201

Реляционная база. PUT

@app.route('/<int:data_id>', methods=['PUT'])
def put(data_id):
    data = request.json['data']
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'UPDATE "todos" SET "todo" = %s WHERE "id" = %s;',
                (data, data_id)
            )
            conn.commit()
            return {}, 204

Реляционная база. DELETE

@app.route('/<int:data_id>', methods=['DELETE'])
def delete(data_id):
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'DELETE FROM "todos" WHERE "id" = %s;',
                (data_id,)
            )
            conn.commit()
            return {}, 204

Кеш. GET

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

@app.route('/', methods=['GET'])
def paginated_get():
    page = int(request.args.get('page', '0'))

    redis_client = redis.Redis(**redis_creds)
    cached_page = redis_client.get(page)

    if cached_page:
        return cached_page

    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'SELECT "id", "todo" FROM "todos" '
                'ORDER BY "id" OFFSET %s LIMIT %s;',
                (page * PAGE_SIZE, PAGE_SIZE)
            )
            result = json.dumps({
                "result": [{"id": row[0], "data": row[1]} for row in cursor]
            })
            redis_client.set(page, result, ex=TTL)
            return result

Обратная сторона кеша: клиент, выполнивший POST или PUT, не увидит собственной правки, пока не истечёт TTL, и чем длиннее время жизни ключа, тем дольше сервис отдаёт устаревшее. Поэтому каждая изменяющая операция обязана сбрасывать затронутые ключи, и трудность заключается в том, чтобы определить, какие именно: правка обесценивает ту страницу, на которой находится запись, а вставка и удаление сдвигают нумерацию и обесценивают всё последующее.

Кеш. POST

Вставка попадает в конец списка, однако выяснять, где он заканчивается, дороже, чем сбросить все страницы.

@app.route('/', methods=['POST'])
def post():
    data = str(request.json['data'])
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'INSERT INTO "todos" ("todo") VALUES (%s) RETURNING "id";',
                (data,)
            )
            conn.commit()
            new_id = cursor.fetchone()[0]

    redis_client = redis.Redis(**redis_creds)
    cached_pages = redis_client.keys()  # в кеше лежат только страницы
    if cached_pages:
        redis_client.delete(*cached_pages)
    return {"id": new_id}, 201

Кеш. PUT

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

@app.route('/<int:data_id>', methods=['PUT'])
def put(data_id):
    data = request.json['data']
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'UPDATE "todos" SET "todo" = %s WHERE "id" = %s;',
                (data, data_id)
            )
            cursor.execute(
                'SELECT count(*) FROM "todos" WHERE "id" < %s;',
                (data_id,)
            )
            page = cursor.fetchone()[0] // PAGE_SIZE
            conn.commit()

    redis_client = redis.Redis(**redis_creds)
    redis_client.delete(page)
    return {}, 204

Кеш. DELETE

После удаления всё последующее сдвигается на одну позицию вперёд, поэтому устаревают и страница удалённой записи, и каждая следующая за ней.

@app.route('/<int:data_id>', methods=['DELETE'])
def delete(data_id):
    with closing(psycopg2.connect(**postgres_creds)) as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                'SELECT count(*) FROM "todos" WHERE "id" < %s;',
                (data_id,)
            )
            page = cursor.fetchone()[0] // PAGE_SIZE
            cursor.execute(
                'DELETE FROM "todos" WHERE "id" = %s;',
                (data_id,)
            )
            conn.commit()

    redis_client = redis.Redis(**redis_creds)
    stale = [key for key in redis_client.keys() if int(key) >= page]
    if stale:
        redis_client.delete(*stale)
    return {}, 204

Pandas и базы данных

Функция read_sql в pandas выполняет запрос и возвращает DataFrame, а to_sql записывает DataFrame в таблицу.

import pandas as pd
import sqlite3

con = sqlite3.connect("lab.db")

# фильтрация и агрегация выполняются в базе,
# в память попадает только готовая сводка
df = pd.read_sql(
    """
    SELECT r.energy_mev, AVG(m.value) AS mean_signal
    FROM measurements AS m
    JOIN runs AS r ON r.id = m.run_id
    WHERE m.channel = 'BPM01'
    GROUP BY r.id
    """,
    con,
)

# дальше — обычный pandas: график зависимости сигнала от энергии
df.plot.scatter(x="energy_mev", y="mean_signal")

# и обратно: результат анализа — в новую таблицу
df.to_sql("run_summary", con, if_exists="replace", index=False)

Тяжёлую фильтрацию и агрегацию следует передавать базе (SQL с WHERE и GROUP BY), а в pandas загружать свёрнутый результат: память и время расходуются на порядки экономнее, чем при чтении всех данных и фильтрации в DataFrame. Связка «SQL-запрос → DataFrame → график» даёт простейшее приложение для визуализации данных; построение графиков рассматривается в главах про обработку и визуализацию данных.

С чего начать в своей лаборатории

Рецепт перехода от тысячи CSV к одной базе включает следующие шаги:

  1. Завести один файл lab.db и описать схему. Минимально необходимы таблицы вида experiments, runs, measurements с первичными и внешними ключами и NOT NULL на важных полях. Схема служит документацией, проверяющей сама себя.

  2. Импортировать накопленные CSV одним скриптом:

    from pathlib import Path
    import pandas as pd
    import sqlite3
    
    con = sqlite3.connect("lab.db")
    
    for csv_path in Path("data").glob("**/*.csv"):
        df = pd.read_csv(csv_path)
        df["source_file"] = str(csv_path)  # не теряем происхождение данных
        df.to_sql("measurements_raw", con, if_exists="append", index=False)
    

    Далее «сырую» таблицу можно разложить по нормализованной схеме SQL-запросами.

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

  4. Включить PRAGMA journal_mode=WAL: в этом режиме SQLite позволяет читать базу (строить графики) параллельно с записью.

  5. Просматривать данные удобно в DB Browser for SQLite, а резервной копией служит обычная копия одного файла (Connection.backup() из Python выполняет её корректно даже во время работы).

Когда данными начнёт пользоваться вся группа с нескольких машин, схема переносится в PostgreSQL; SQL и почти весь код при этом остаются прежними.

Полезные ссылки

Задание. Собрать и развернуть всё вместе, приложение, контейнеры, сеть и базу: «Деплой стартапа».

Научные библиотеки Python

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

Порядок изложения следует зависимостям. NumPy предоставляет быстрый многомерный массив, на котором основано всё остальное, и вместе с pandas, предназначенным для работы с таблицами, обеспечивает хранение и первичную обработку данных. SciPy надстраивает над массивом численные методы: интегрирование, решение уравнений, оптимизацию, подгонку кривых, обработку сигналов, преобразование Фурье. Почти всё, что физику приходится вычислять, там уже реализовано и проверено. Далее рассматривается построение графиков, где Matplotlib остаётся основным инструментом, а HoloViews устроен иначе: в нём описывается, какие данные имеются, а не способ их отрисовки, и библиотека сама выбирает подходящий вид графика, делая его интерактивным.

Завершается раздел машинным обучением, для которого всё перечисленное служит фундаментом.

Устройство языка — объекты, коллекции, функции и классы — рассматривалось в разделе «Язык Python». Здесь язык считается известным.

Читателю, не знакомому с базовым синтаксисом, рекомендуется начать с электронной версии. В неё входят главы с основами языка, с NumPy и с Matplotlib, не вошедшие в печатное издание: их место заняли темы, которые нельзя прочитать в документации за десять минут. В печатном издании NumPy разобран в следующей главе вместе с pandas, а Matplotlib — в главе про визуализацию.

Почти весь код этой части набран в блокноте Jupyter — странице, разбитой на ячейки, где каждая ячейка выполняется отдельно и оставляет результат под собой, а имена, введённые в одной ячейке, сохраняются и доступны следующим. Строки, начинающиеся со знака процента, являются не кодом Python, а магическими командами IPython, оболочки, на которой работает блокнот. Одиночный % действует на строку, в которой стоит, так что %timeit sorted(data) измерит время этого вызова; двойной %%, поставленный первой строкой ячейки, распространяется на неё целиком, и %%timeit измерит всё написанное ниже. %load_ext подключает расширение IPython: с его помощью включается, например, построчный профилировщик, рассматриваемый в части «Ускорение расчётов». В обычном скрипте, запущенном через python file.py, ни одна из этих строк не работает, поэтому при переносе кода отсюда в файл их необходимо удалить.

NumPy и pandas

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

NumPy

Python поначалу разочаровывает: список из миллиона чисел занимает десятки мегабайт, а цикл по нему выполняется очень долго. Причины, рассмотренные в главе про объекты и память, сводятся к одному: каждое число в списке является отдельным объектом со своей обёрткой, размещённым где-то в куче.

Решением является NumPy. Идея заключается в том, чтобы хранить числа так, как их хранит C, то есть подряд, одного типа, без обёрток, а операции над ними передавать скомпилированному коду, работающему сразу над всем массивом. Выигрыш достигает сотен раз. На NumPy построен научный стек: SciPy, pandas, scikit-learn и библиотеки машинного обучения используют массивы NumPy.

Полная документация: numpy.org/doc.

NumPy решает две задачи:

  • хранить многомерные массивы (в том числе матрицы);
  • быстро считать математические функции сразу от всего массива.

Основой библиотеки является один объект, ndarray.

Отличия массива от списка:

  • длина массива, заданная в момент создания, остаётся неизменной, тогда как список растёт динамически;
  • все элементы массива одного типа;
  • операции пишутся сразу над массивом целиком, без цикла.

Отсюда следуют две сильные стороны NumPy: векторизация и broadcasting.

import numpy as np

Способы создания массивов

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

Конвертация из структур Python

np.array([1, 2, 3, 4, 5])
array([1, 2, 3, 4, 5])

При конвертации можно задавать тип данных с помощью аргумента dtype:

np.array([1, 2, 3, 4, 5], dtype=np.float32)
array([1., 2., 3., 4., 5.], dtype=float32)

Аналогичное преобразование:

np.float32([1, 2, 3, 4, 5])
array([1., 2., 3., 4., 5.], dtype=float32)

Генерация массивов

  • arange работает как аналог range из Python, но принимает и нецелочисленный шаг
  • linspace равномерно разбивает отрезок на n-1 интервал
  • logspace разбивает отрезок по логарифмической шкале
  • zeros создаёт массив заданной размерности, заполненный нулями
  • ones создаёт массив заданной размерности, заполненный единицами
  • empty создаёт массив заданной размерности, не инициализированный никаким значением, то есть заполненный мусором из памяти
np.arange(0, 5, 0.5)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5])
np.linspace(0, 5, 11)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. ])
np.logspace(0, 9, 10, base=2)
array([  1.,   2.,   4.,   8.,  16.,  32.,  64., 128., 256., 512.])
np.zeros((2, 2))
array([[0., 0.],
       [0., 0.]])
np.ones((2, 2))
array([[1., 1.],
       [1., 1.]])
np.empty((2, 2))
array([[1., 1.],
       [1., 1.]])
np.diag([1,2,3])
array([[1, 0, 0],
       [0, 2, 0],
       [0, 0, 3]])

Размеры массива хранятся в поле shape, а число измерений — в поле ndim.

array = np.ones((2, 3,))
print('Размерность массива - %s, количество размерностей - %d'%(array.shape, array.ndim))
array
Размерность массива - (2, 3), количество размерностей - 2





array([[1., 1., 1.],
       [1., 1., 1.]])
## Чему равны ndim и shape в следующих случаях
print(np.diag([1,2,3]).shape, np.diag([1,2,3]).ndim)
print(np.zeros((5, 5, 5)).shape, np.zeros((5, 5, 5)).ndim)
(3, 3) 2
(5, 5, 5) 3

Метод reshape изменяет форму массива, не изменяя самих данных

array = np.arange(0, 6, 0.5)
array = array.reshape((2, 6))
array
array([[0. , 0.5, 1. , 1.5, 2. , 2.5],
       [3. , 3.5, 4. , 4.5, 5. , 5.5]])

Многомерный массив разворачивается в вектор функцией ravel

array = np.ravel(array)
array
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. , 5.5])
# Какие будут массивы?
print(np.ravel(np.diag([1,2])))
print(np.reshape(np.diag([1,2]), [1, 4]))
[1 0 0 2]
[[1 0 0 2]]

Индексация

В NumPy применяется привычная индексация Python, включая отрицательные индексы и срезы, записываемые так же, как для списка

print(array[0])
print(array[-1])
print(array[1:-1])
print(array[1:-1:2])
print(array[::-1])
0.0
5.5
[0.5 1.  1.5 2.  2.5 3.  3.5 4.  4.5 5. ]
[0.5 1.5 2.5 3.5 4.5]
[5.5 5.  4.5 4.  3.5 3.  2.5 2.  1.5 1.  0.5 0. ]
print(array.shape)
(12,)
print(array[None,0:, None].ndim, array[None,0:, None].shape)
array[None,0:, None]
3 (1, 12, 1)





array([[[0. ],
        [0.5],
        [1. ],
        [1.5],
        [2. ],
        [2.5],
        [3. ],
        [3.5],
        [4. ],
        [4.5],
        [5. ],
        [5.5]]])

Замечание: индексы и срезы, перечисляемые для многомерного массива, разделять квадратными скобками не следует,

т.е. вместо matrix[i][j] нужно использовать matrix[i, j]

Массив, подставленный вместо индекса, может быть и списком номеров, и булевой маской:

array[[0, 2, 4, 6, 8, 10]]
array([0., 1., 2., 3., 4., 5.])
array[[True, False, True, False, True, False, True, False, True, False, True, False]]
array([0., 1., 2., 3., 4., 5.])
# Что будет выведено?
x = np.array([[1, 2, 3]])
y = np.array([1, 2, 3])

print (x.shape, y.shape)

print(np.array_equal(x, y))
print(np.array_equal(x, y[None, :]))
(1, 3) (3,)
False
True
x = np.arange(10)
x
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
x[(x % 2 == 0) & (x > 5)]
array([6, 8])
print(x)
y = x[x>5] 
y *= 2
print(y)
print(x)
[0 1 2 3 4 5 6 7 8 9]
[12 14 16 18]
[0 1 2 3 4 5 6 7 8 9]

Срез массива в NumPy является представлением тех же данных, а не их копией: изменение среза наподобие x[2:5] приводит к изменению исходного массива. Отбор по маске или по списку индексов всегда возвращает копию, и исходный массив остаётся нетронутым. Когда требуются собственные данные, применяется метод copy.

x.copy()
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Сохранение и чтение массивов в бинарном формате

with open('out.npy', 'wb') as f:
    np.save(f, x)
    
with open('out.npy', 'rb') as f:
    print(f.read())
    
with open('out.npy', 'rb') as f:
    y = np.load(f)
    print(y)    
b"\x93NUMPY\x01\x00v\x00{'descr': '<i8', 'fortran_order': False, 'shape': (10,), }                                                           \n\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\t\x00\x00\x00\x00\x00\x00\x00"
[0 1 2 3 4 5 6 7 8 9]

Чтение данных с помощью функции genfromtxt

Для примера потребуется файл iris_subset.txt. Он не приложен, и настоящий не нужен: числа в нём случайные (чашелистик длиной 1134 см встретится ниже в выводе). Важна только структура: текстовая таблица с заголовком, четыре числовых столбца и один строковый. Такой файл необходимо создать самостоятельно, поместив в первую строку имена столбцов, разделённые , :

sepal_length_in_cm, sepal_width_in_cm, petal_length_in_cm, petal_width_in_cm, class
1.0, 1.0, 10.0, 121.0, setosa
1.0, 314.0, 13.0, 121.0, versicolor

Если требуется работать с настоящими ирисами, их предоставляет scikit-learn одной строкой: from sklearn.datasets import load_iris.

iris = np.genfromtxt('iris_subset.txt', delimiter=', ', names=True, dtype=[('sepal_length_in_cm', 'f8'), 
                                                                          ('sepal_width_in_cm', 'f8'), 
                                                                          ('petal_length_in_cm', 'f8'), 
                                                                          ('petal_width_in_cm', 'f8'),
                                                                          ('class', 'U10')])
iris
array([(1.000e+00,   1. ,   10.,   121. , 'setosa'),
       (1.000e+00, 314. ,   13.,   121. , 'versicolor'),
       (1.134e+03,   1. ,  103.,  1421. , 'setosa'),
       (1.000e+00, 141. ,   10.,   121. , 'versicolor'),
       (1.440e+02,   1. , 4582., 13481. , 'versicolor'),
       (1.000e+00,  13.3,   10.,   121. , 'versicolor'),
       (1.141e+03,   1. , 1341.,  1231.1, 'setosa'),
       (7.320e+02, 131. ,  139.,    92.1, 'setosa')],
      dtype=[('sepal_length_in_cm', '<f8'), ('sepal_width_in_cm', '<f8'), ('petal_length_in_cm', '<f8'), ('petal_width_in_cm', '<f8'), ('class', '<U10')])

genfromtxt с names=True возвращает структурированный массив, хранящий имена столбцов вместе с данными. Строка из него запрашивается по номеру, а столбец — по названию.

print('Описание первого элемента: %s'%iris[0])
print('Значения столбца sepal_length_in_cm: %s'%iris['sepal_length_in_cm'])
Описание первого элемента: (1., 1., 10., 121., 'setosa')
Значения столбца sepal_length_in_cm: [1.000e+00 1.000e+00 1.134e+03 1.000e+00 1.440e+02 1.000e+00 1.141e+03
 7.320e+02]
sepal_length_setosa = iris['sepal_length_in_cm'][iris['class'] == 'setosa']
sepal_length_versicolor = iris['sepal_length_in_cm'][iris['class'] == 'versicolor']

print('Значения столбца sepal_length_in_cm\n\tclass setosa: %s\n\tclass versicolor: %s'%(sepal_length_setosa, 
                                                                                         sepal_length_versicolor))
Значения столбца sepal_length_in_cm
	class setosa: [1.000e+00 1.134e+03 1.141e+03 7.320e+02]
	class versicolor: [  1.   1. 144.   1.]

Строки в начале и в конце файла пропускаются аргументами skip_header и skip_footer, а столбцы отбираются через usecols.

iris_class = np.genfromtxt('iris_subset.txt', delimiter=', ', skip_header=1, usecols=4, dtype='U10')
iris_class
array(['setosa', 'versicolor', 'setosa', 'versicolor', 'versicolor',
       'versicolor', 'setosa', 'setosa'], dtype='<U10')
iris_features = np.genfromtxt('iris_subset.txt', delimiter=', ', skip_header=1, usecols=range(4))
iris_features
array([[1.0000e+00, 1.0000e+00, 1.0000e+01, 1.2100e+02],
       [1.0000e+00, 3.1400e+02, 1.3000e+01, 1.2100e+02],
       [1.1340e+03, 1.0000e+00, 1.0300e+02, 1.4210e+03],
       [1.0000e+00, 1.4100e+02, 1.0000e+01, 1.2100e+02],
       [1.4400e+02, 1.0000e+00, 4.5820e+03, 1.3481e+04],
       [1.0000e+00, 1.3300e+01, 1.0000e+01, 1.2100e+02],
       [1.1410e+03, 1.0000e+00, 1.3410e+03, 1.2311e+03],
       [7.3200e+02, 1.3100e+02, 1.3900e+02, 9.2100e+01]])
features_setosa = iris_features[iris_class == 'setosa']
features_versicolor = iris_features[iris_class == 'versicolor']

Операции в NumPy производятся над векторами одинаковой размерности целиком, без цикла.

Поэлементная разность двух векторов:

sepal_length_versicolor - sepal_length_setosa
array([    0., -1133.,  -997.,  -731.])

Аналогично для многомерных массивов.

features_versicolor - features_setosa
array([[ 0.00000e+00,  3.13000e+02,  3.00000e+00,  0.00000e+00],
       [-1.13300e+03,  1.40000e+02, -9.30000e+01, -1.30000e+03],
       [-9.97000e+02,  0.00000e+00,  3.24100e+03,  1.22499e+04],
       [-7.31000e+02, -1.17700e+02, -1.29000e+02,  2.89000e+01]])

Broadcasting

Broadcasting снимает требование одинаковой формы и разрешает арифметику над массивами разных, но согласованных между собой размерностей. Простейшим случаем является умножение вектора на число.

Imgur

2*np.arange(1, 4)
array([2, 4, 6])

Правило согласования размерностей:

In order to broadcast, the size of the trailing axes for both arrays in an operation must either be the same size or one of them must be one.

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

Если количество размерностей не совпадает, к массиву меньшей размерности слева дописываются фиктивные оси, не занимающие памяти, например:

a = np.ones((2, 3, 4))
b = np.ones(4)
c = a * b  # здесь a.shape = (2, 3, 4), а b.shape считается равным (1, 1, 4)

Прибавим к каждой строке матрицы один и тот же вектор:

Imgur

np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]]) + np.arange(3)
array([[ 0,  1,  2],
       [10, 11, 12],
       [20, 21, 22],
       [30, 31, 32]])

Со столбцами такой приём не работает: вектор из четырёх элементов не согласуется с матрицей по последней оси.

Imgurl

Сначала вектор, дополненный новой осью, приводится к виду:

np.arange(4)[:, np.newaxis]
array([[0],
       [1],
       [2],
       [3]])

Затем к нему прибавляется матрица:

np.arange(4)[:, np.newaxis]+np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]])
array([[ 0,  0,  0],
       [11, 11, 11],
       [22, 22, 22],
       [33, 33, 33]])

Кроме того, в NumPy имеются сводные операции над массивами: np.min, np.max, np.sum, np.mean и т.д.

print('Среднее значение всех значений класса versicolor: %s'%np.mean(features_versicolor))
print('Среднее значение каждого признака класса versicolor: %s'%np.mean(features_versicolor, axis=0))
Среднее значение всех значений класса versicolor: 1192.20625
Среднее значение каждого признака класса versicolor: [  36.75   117.325 1153.75  3461.   ]

Вычислим без цикла \(\frac{1}{n} \sum\limits_{i=1}^n |x_i-y_i|\) для каждой пары \((x, y)\), где \(x\) обозначает вектор признаков объекта из класса setosa, а \(y\) вектор признаков объекта из класса versicolor.

np.mean(np.abs(features_setosa - features_versicolor[:, np.newaxis]), axis=2)
array([[7.900000e+01, 7.090000e+02, 9.727750e+02, 2.672250e+02],
       [3.500000e+01, 6.665000e+02, 9.302750e+02, 2.247250e+02],
       [4.518750e+03, 4.382250e+03, 4.121975e+03, 4.637475e+03],
       [3.075000e+00, 6.345750e+02, 8.983500e+02, 2.516500e+02]])

Операции

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

x = np.arange(40).reshape(5, 2, 4)
print(x)
[[[ 0  1  2  3]
  [ 4  5  6  7]]

 [[ 8  9 10 11]
  [12 13 14 15]]

 [[16 17 18 19]
  [20 21 22 23]]

 [[24 25 26 27]
  [28 29 30 31]]

 [[32 33 34 35]
  [36 37 38 39]]]
print(x.mean())
print(np.mean(x))
19.5
19.5
x.mean(axis=0)
array([[16., 17., 18., 19.],
       [20., 21., 22., 23.]])
x.mean(axis=1)
array([[ 2.,  3.,  4.,  5.],
       [10., 11., 12., 13.],
       [18., 19., 20., 21.],
       [26., 27., 28., 29.],
       [34., 35., 36., 37.]])
x.mean(axis=2)
array([[ 1.5,  5.5],
       [ 9.5, 13.5],
       [17.5, 21.5],
       [25.5, 29.5],
       [33.5, 37.5]])
x.mean(axis=(0,2))
array([17.5, 21.5])
x.mean(axis=(0,1,2))
19.5

Конкатенация многомерных массивов

Объединение массивов выполняют функции np.concatenate, np.hstack, np.vstack, np.dstack, различающиеся только осью объединения.

x = np.arange(10).reshape(5, 2)
y = np.arange(100, 120).reshape(5, 4)
x
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])
y
array([[100, 101, 102, 103],
       [104, 105, 106, 107],
       [108, 109, 110, 111],
       [112, 113, 114, 115],
       [116, 117, 118, 119]])
np.hstack((x, y))
array([[  0,   1, 100, 101, 102, 103],
       [  2,   3, 104, 105, 106, 107],
       [  4,   5, 108, 109, 110, 111],
       [  6,   7, 112, 113, 114, 115],
       [  8,   9, 116, 117, 118, 119]])
x = np.ones([2, 3])
y = np.zeros([2, 2])
# Какой будет результат
print(np.hstack((x,y)).shape)
print(np.vstack((x,y)).shape)
(2, 5)



---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

Cell In[54], line 3
      1 # Какой будет результат
      2 print(np.hstack((x,y)).shape)
----> 3 print(np.vstack((x,y)).shape)


File /opt/anaconda3/lib/python3.12/site-packages/numpy/core/shape_base.py:289, in vstack(tup, dtype, casting)
    287 if not isinstance(arrs, list):
    288     arrs = [arrs]
--> 289 return _nx.concatenate(arrs, 0, dtype=dtype, casting=casting)


ValueError: all the input array dimensions except for the concatenation axis must match exactly, but along dimension 1, the array at index 0 has size 3 and the array at index 1 has size 2
p = np.arange(1).reshape([1, 1, 1, 1])
p
array([[[[0]]]])
print("vstack: ", np.vstack((p, p)).shape)
print("hstack: ", np.hstack((p, p)).shape)
print("dstack: ", np.dstack((p, p)).shape)
print("concatenate: ", np.concatenate((p, p), axis=3).shape)
vstack:  (2, 1, 1, 1)
hstack:  (1, 2, 1, 1)
dstack:  (1, 1, 2, 1)
concatenate:  (1, 1, 1, 2)

Типы

От типа массива зависят и занимаемая память, и диапазон представимых значений. Рассмотрим, что происходит с числом 70000 в uint16. С версии NumPy 2.0 конструктор массива на таком значении завершается ошибкой OverflowError, а без предупреждения оно усекается только при явном приведении через astype, превращаясь в 4464; такое переполнение, не сопровождаемое ошибкой, является классическим источником неверных результатов.

x = [1, 2, 70000]
np.array(x, dtype=np.float32)
array([1.e+00, 2.e+00, 7.e+04], dtype=float32)
np.array(x, dtype=np.uint16)
OverflowError: Python integer 70000 out of bounds for uint16

Ранее NumPy усекал значение, не помещавшееся в тип, и лишь выдавал предупреждение; с версии 2.0 это ошибка. Незаметное переполнение осталось только при явном приведении:

np.array(70000).astype(np.uint16)
4464

uint16 хранит остаток по модулю \( 2^{16} \), а \( 70000 - 65536 = 4464 \). Такая ошибка не приводит к исключению и ничего не выводит.

np.array(x, dtype=np.str_)
array(['1', '2', '70000'], dtype='<U5')

Функциональное программирование

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

def f(value):
    return np.sqrt(value)
print(np.apply_along_axis(f, 0, np.arange(10)))
[0.         1.         1.41421356 1.73205081 2.         2.23606798
 2.44948974 2.64575131 2.82842712 3.        ]
vf = np.vectorize(f)
%%timeit 
vf(np.arange(100000))
146 ms ± 2.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit 
np.apply_along_axis(f, 0, np.arange(100000)) 
1.89 ms ± 31.9 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%%timeit 
np.array([f(v) for v in np.arange(100000)])
129 ms ± 861 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Разница в семьдесят раз выглядит убедительно, однако выводы из неё были бы поспешными. np.vectorize и списковое включение вызывают f сто тысяч раз, и 130–150 миллисекунд составляют стоимость ста тысяч вызовов функции Python. В то же время apply_along_axis с axis=0 на одномерном массиве вызывает f один раз, передавая в неё весь массив целиком, так что измерен здесь один векторный np.sqrt, а не поэлементный обход.

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

Pandas

pandas.pydata.org/docs/

Pandas читает данные, приводит их в порядок, вычисляет по ним сводки и строит графики. Если NumPy предоставляет массив чисел, то pandas предоставляет таблицу с именованными столбцами и индексом, размечающим строки.

import pandas as pd
df = pd.read_csv("titanic.csv", sep='\t')

Набор данных о «Титанике» находится в открытом доступе (например, в OpenML под именем titanic). Файл использует табуляцию, а не запятую в качестве разделителя, поэтому sep='\t' обязателен. Файл, загруженный из другого источника, может оказаться обычным CSV.

df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
0 1 0 3 Braund, Mr. Owen Harris male 22.0 1 0 A/5 21171 7.2500 NaN S
1 2 1 1 Cumings, Mrs. John Bradley (Florence Briggs Th... female 38.0 1 0 PC 17599 71.2833 C85 C
2 3 1 3 Heikkinen, Miss. Laina female 26.0 0 0 STON/O2. 3101282 7.9250 NaN S
view = df[df['Sex'] == 'female']
list(((df['Sex'] == 'female') & (df['Age'] > 30)).index)
[0,
 1,
 2,
 3,
 4,
 5,
 6,
 7,
 8,
 9,
 10,
 11,
 12,
 13,
 14,
 15,
 16,
 17,
 18,
 19,
 20,
 21,
 22,
 23,
 24,
 25,
 26,
 27,
 28,
 29,
 30,
 31,
 32,
 33,
 34,
 35,
 36,
 37,
 38,
 39,
 40,
 41,
 42,
 43,
 44,
 45,
 46,
 47,
 48,
 49,
 50,
 51,
 52,
 53,
 54,
 55,
 56,
 57,
 58,
 59,
 60,
 61,
 62,
 63,
 64,
 65,
 66,
 67,
 68,
 69,
 70,
 71,
 72,
 73,
 74,
 75,
 76,
 77,
 78,
 79,
 80,
 81,
 82,
 83,
 84,
 85,
 86,
 87,
 88,
 89,
 90,
 91,
 92,
 93,
 94,
 95,
 96,
 97,
 98,
 99,
 100,
 101,
 102,
 103,
 104,
 105,
 106,
 107,
 108,
 109,
 110,
 111,
 112,
 113,
 114,
 115,
 116,
 117,
 118,
 119,
 120,
 121,
 122,
 123,
 124,
 125,
 126,
 127,
 128,
 129,
 130,
 131,
 132,
 133,
 134,
 135,
 136,
 137,
 138,
 139,
 140,
 141,
 142,
 143,
 144,
 145,
 146,
 147,
 148,
 149,
 150,
 151,
 152,
 153,
 154,
 155]
df[(df['Sex'] == 'female') & (df['Age'] > 30)].index
Index([1, 3, 11, 15, 18, 25, 40, 52, 61, 85, 98, 123, 132], dtype='int64')
df.drop(index=df[(df['Sex'] == 'female') & (df['Age'] > 30)].index, inplace=True)
df.loc[78]
PassengerId                               79
Survived                                   1
Pclass                                     2
Name           Caldwell, Master. Alden Gates
Sex                                     male
Age                                     0.83
SibSp                                      0
Parch                                      2
Ticket                                248738
Fare                                    29.0
Cabin                                    NaN
Embarked                                   S
Name: 78, dtype: object
df.iloc[0]
PassengerId                          1
Survived                             0
Pclass                               3
Name           Braund, Mr. Owen Harris
Sex                               male
Age                               22.0
SibSp                                1
Parch                                0
Ticket                       A/5 21171
Fare                              7.25
Cabin                              NaN
Embarked                             S
Name: 0, dtype: object
df.describe()
PassengerId Survived Pclass Age SibSp Parch Fare
count 143.000000 143.000000 143.000000 113.000000 143.000000 143.000000 143.000000
mean 80.902098 0.307692 2.461538 26.702035 0.601399 0.391608 27.526018
std 44.536473 0.463161 0.776134 14.483237 1.075593 0.813919 40.406013
min 1.000000 0.000000 1.000000 0.830000 0.000000 0.000000 6.750000
25% 43.500000 0.000000 2.000000 19.000000 0.000000 0.000000 7.925000
50% 81.000000 0.000000 3.000000 24.000000 0.000000 0.000000 13.000000
75% 118.500000 1.000000 3.000000 33.000000 1.000000 0.000000 29.597900
max 156.000000 1.000000 3.000000 71.000000 5.000000 5.000000 263.000000
df[["Sex", "Cabin"]].describe()
Sex Cabin
count 143 25
unique 2 23
top male C23 C25 C27
freq 100 2

Срезы в DataFrame

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

Индексация

После сортировки индекс, приписанный строке, сохраняется и перестаёт совпадать с её номером по порядку.

df.sort_values("Age", inplace=True)
df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S
119 120 0 3 Andersson, Miss. Ellis Anna Maria female 2.00 4 2 347082 31.275 NaN S
df.iloc[78]
PassengerId                              67
Survived                                  1
Pclass                                    2
Name           Nye, Mrs. (Elizabeth Ramell)
Sex                                  female
Age                                    29.0
SibSp                                     0
Parch                                     0
Ticket                           C.A. 29395
Fare                                   10.5
Cabin                                   F33
Embarked                                  S
Name: 66, dtype: object
df.loc[78]
PassengerId                               79
Survived                                   1
Pclass                                     2
Name           Caldwell, Master. Alden Gates
Sex                                     male
Age                                     0.83
SibSp                                      0
Parch                                      2
Ticket                                248738
Fare                                    29.0
Cabin                                    NaN
Embarked                                   S
Name: 78, dtype: object
df.loc[[78, 79, 100], ["Age", "Cabin"]]
Age Cabin
78 0.83 NaN
79 30.00 NaN
100 28.00 NaN

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

df_slice_copy = df.loc[[78, 79, 100], ["Age", "Cabin"]].copy()
df_slice_copy[:] = 3
df_slice_copy
Age Cabin
78 3.0 3
79 3.0 3
100 3.0 3

Если же изменять требуется саму основную таблицу, то используется loc.

df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S
119 120 0 3 Andersson, Miss. Ellis Anna Maria female 2.00 4 2 347082 31.275 NaN S
some_slice = df["Age"].isin([20, 25,30])
df.loc[some_slice, "Fare"] = df.loc[some_slice, "Fare"] * 10

Следующий способ применять не рекомендуется:

slice_df = df[some_slice]
slice_df["Fare"] = slice_df["Fare"] * 10

С pandas 3.0 включён механизм Copy-on-Write, и SettingWithCopyWarning удалён. Запись в slice_df теперь гарантированно попадает в копию, исходная таблица не изменяется, и предупреждения об этом не выдаётся. Правило прежнее: исходная таблица изменяется только через df.loc[маска, столбец] = ....

Столбцы отбираются названием или списком названий в [].

Замечание: если передаётся название одного столбца, то возвращается объект класса pandas.Series, а если список названий столбцов, то возвращается pandas.DataFrame; для получения numpy.array достаточно обратиться к полю values.

Series и DataFrame имеют много общих методов, работающих одинаково в обоих случаях.

df["Age"].head(5)
78     0.83
7      2.00
119    2.00
16     2.00
43     3.00
Name: Age, dtype: float64
df[["Age"]].head(5)
Age
78 0.83
7 2.00
119 2.00
16 2.00
43 3.00

pd.Series

Одномерный срез датафрейма является pd.Series, столбцом значений, снабжённым индексом.

Извлечь из pd.Series обычный np.array можно, однако обычно это не требуется: вместе с массивом теряется индекс.

df["Age"].head(5).values
array([0.83, 2.  , 2.  , 2.  , 3.  ])

Индекс извлекается отдельно:

df["Age"].head(5).index
Index([78, 7, 119, 16, 43], dtype='int64')

Создаётся Series так же, как np.array, только индекс задаётся явно.

pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
Red      1
Green    2
Blue     3
dtype: int64
pd.Series(1, index=["Red", "Green", "Blue"])
Red      1
Green    1
Blue     1
dtype: int64
pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
Red      1
Green    2
Blue     3
dtype: int64

Series разворачивается обратно в DataFrame:

s = pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
s.to_frame("Values")
Values
Red 1
Green 2
Blue 3
s.loc["Red"]
1
s.iloc[0]
1

Объединение таблиц

Две таблицы сводятся в одну методом join, сопоставляющим строки по индексу.

df1 = df[["Age", "Parch"]].copy()
df2 = df[["Ticket", "Fare"]].copy()
df1.join(df2).head(5)
Age Parch Ticket Fare
78 0.83 2 248738 29.0000
7 2.00 1 349909 21.0750
119 2.00 2 347082 31.2750
16 2.00 1 382652 29.1250
43 3.00 2 SC/Paris 2123 41.5792
df1 = df[["Age", "Parch", "PassengerId"]].copy()
df2 = df[["Ticket", "Fare", "PassengerId"]].copy()
pd.merge(df1, df2, on=["PassengerId"]).head(5)
Age Parch PassengerId Ticket Fare
0 0.83 2 79 248738 29.0000
1 2.00 1 8 349909 21.0750
2 2.00 2 120 347082 31.2750
3 2.00 1 17 382652 29.1250
4 3.00 2 44 SC/Paris 2123 41.5792
pd.merge(df1, df2, on=["PassengerId"], how="inner").head(5)
Age Parch PassengerId Ticket Fare
0 0.83 2 79 248738 29.0000
1 2.00 1 8 349909 21.0750
2 2.00 2 120 347082 31.2750
3 2.00 1 17 382652 29.1250
4 3.00 2 44 SC/Paris 2123 41.5792

Группировка

Средний возраст пассажира по классам каюты может быть вычислен напрямую тремя почти одинаковыми строками. Такой подход не рекомендуется: классов может оказаться тридцать. Для этой задачи предназначен groupby, разбивающий таблицу на группы.

print("Pclass 1: ", df[df["Pclass"] == 1]["Age"].mean())
print("Pclass 2: ", df[df["Pclass"] == 2]["Age"].mean())
print("Pclass 3: ", df[df["Pclass"] == 3]["Age"].mean())
Pclass 1:  36.86363636363637
Pclass 2:  26.68576923076923
Pclass 3:  23.26923076923077
df.groupby(["Pclass"])[["Age"]].mean()
Age
Pclass
1 36.863636
2 26.685769
3 23.269231

Сам по себе groupby ничего не вычисляет, а возвращает объект, хранящий разбиение и ожидающий сводной операции.

df.groupby(["Survived", "Pclass"])
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x73b08903f7d0>
df.groupby(["Survived", "Pclass"])["PassengerId"].count()
Survived  Pclass
0         1         18
          2         16
          3         65
1         1          7
          2         11
          3         26
Name: PassengerId, dtype: int64
df.groupby(["Survived", "Pclass"])[["PassengerId", "Cabin"]].count()
PassengerId Cabin
Survived Pclass
0 1 18 12
2 16 1
3 65 1
1 1 7 7
2 11 2
3 26 2
df.groupby(["Survived", "Pclass"])[["PassengerId", "Fare"]].describe()
PassengerId Fare
count mean std min 25% 50% 75% max count mean std min 25% 50% 75% max
Survived Pclass
0 1 18.0 82.555556 44.501450 7.0 40.75 88.5 117.00 156.0 18.0 80.035183 66.109719 27.7208 51.896875 61.2771 78.721875 263.0000
2 16.0 107.187500 44.842270 21.0 72.50 122.0 145.25 151.0 16.0 33.555725 32.412477 10.5000 12.881250 23.5000 31.740600 130.0000
3 65.0 80.892308 42.930585 1.0 49.00 87.0 114.00 155.0 65.0 19.123272 20.710190 6.7500 7.895800 8.0500 21.075000 98.2500
1 1 7.0 84.000000 49.568135 24.0 44.00 89.0 117.50 152.0 7.0 90.966057 85.998766 26.2833 35.500000 63.3583 106.560400 263.0000
2 11.0 57.181818 35.261362 10.0 33.00 57.0 73.00 134.0 11.0 21.627273 10.581905 10.5000 11.750000 26.0000 28.375000 41.5792
3 26.0 72.807692 46.318048 3.0 34.00 72.0 109.50 147.0 26.0 16.698400 24.254889 7.1417 7.756250 7.9250 14.244775 124.7500

Работа с временными метками

Показания приборов почти всегда поступают с меткой времени, проставленной системой сбора. Добавим в таблицу столбец с временем в формате Unix и рассмотрим, какие операции pandas предоставляет для него после приведения к своему типу.

tdf = df.copy()
tdf["ts"] = range(1560000000, 1560000000 + tdf.shape[0])
tdf.head(2)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked ts
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S 1560000000
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S 1560000001

Столбец, приведённый к datetime, получает операции, недоступные целому числу.

tdf["ts"] = pd.to_datetime(tdf["ts"], unit="s")
tdf.head(2)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked ts
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S 2019-06-08 13:20:00
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S 2019-06-08 13:20:01

Время, установленное в качестве индекса, делает доступным resample, пересчитывающий ряд на равномерную сетку заданного шага.

tdf.set_index("ts", inplace=True)
tdf.resample("15s").sum()[["PassengerId", "Survived", "Pclass", "Sex"]]
PassengerId Survived Pclass Sex
ts
2019-06-08 13:20:00 862 7 41 malemalefemalemalefemalemalefemalefemalemalefe...
2019-06-08 13:20:15 1302 4 40 femalefemalefemalefemalemalemalefemalefemalefe...
2019-06-08 13:20:30 1235 3 38 malemalefemalefemalemalemalemalemalemalemalema...
2019-06-08 13:20:45 1628 6 33 femalefemalemalemalemalemalemalemalefemalemale...
2019-06-08 13:21:00 1057 5 37 malemalemalemalefemalefemalemalefemalemalemale...
2019-06-08 13:21:15 1144 6 37 malemalefemalefemalemalefemalemalemalemalemale...
2019-06-08 13:21:30 1590 0 28 malemalemalemalemalemalemalemalemalemalemalema...
2019-06-08 13:21:45 845 4 33 malemalemalemalemalemalemalemalemalemalefemale...
2019-06-08 13:22:00 912 6 41 femalemalemalemalemalefemalemalemalemalemalema...
2019-06-08 13:22:15 994 3 24 malemalefemalemalemalefemalefemalemale

Скользящие окна

Скользящее окно (rolling) сглаживает зашумлённый ряд: для каждой точки вычисляется среднее (либо сумма, либо максимум) нескольких соседних. Первые значения, попавшие в неполное окно, остаются пустыми.

Imgurl

tdf.sort_index(inplace=True)
tdf[["Fare"]].rolling(window=5).mean().head(10)
Fare
ts
2019-06-08 13:20:00 NaN
2019-06-08 13:20:01 NaN
2019-06-08 13:20:02 NaN
2019-06-08 13:20:03 NaN
2019-06-08 13:20:04 30.41084
2019-06-08 13:20:05 30.19084
2019-06-08 13:20:06 29.31584
2019-06-08 13:20:07 28.61084
2019-06-08 13:20:08 30.72334
2019-06-08 13:20:09 26.62250

Скользящее окно сочетается с группировкой: окно внутри каждой группы вычисляется независимо.

rol = tdf[["Sex", "Fare"]].groupby(["Sex"]).rolling(window=5).mean()
rol.head(100)
Fare
Sex ts
female 2019-06-08 13:20:02 NaN
2019-06-08 13:20:04 NaN
2019-06-08 13:20:06 NaN
2019-06-08 13:20:07 NaN
2019-06-08 13:20:09 27.67584
... ... ...
male 2019-06-08 13:21:26 14.02416
2019-06-08 13:21:27 17.12416
2019-06-08 13:21:28 12.72000
2019-06-08 13:21:29 16.34084
2019-06-08 13:21:30 19.81000

100 rows × 1 columns

rol.loc['male'].head(10)
Fare
ts
2019-06-08 13:20:00 NaN
2019-06-08 13:20:01 NaN
2019-06-08 13:20:03 NaN
2019-06-08 13:20:05 NaN
2019-06-08 13:20:08 29.35750
2019-06-08 13:20:11 32.93750
2019-06-08 13:20:12 30.97084
2019-06-08 13:20:19 26.98918
2019-06-08 13:20:20 28.28418
2019-06-08 13:20:26 22.64668

Работа со строками

Строковые столбцы имеют аксессор .str, через который к каждому элементу применяются обычные строковые методы, выстраиваемые в цепочку. Ниже из полного имени пассажира извлекается личное имя: строка приводится к нижнему регистру, запятая заменяется, строка разрезается по точке и берётся вторая часть.

df["Name"].str.lower()\
          .str.replace(",", " ")\
          .str.split(".").str[1]\
          .head(10)
78                    alden gates
7                   gosta leonard
119              ellis anna maria
16                         eugene
43      simonne marie anne andree
63                         harald
10                 marguerite rut
58               constance mirium
50                     juha niilo
24                 torborg danira
Name: Name, dtype: object

Пропущенные значения

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

df["Cabin"].head(15)
78     NaN
7      NaN
119    NaN
16     NaN
43     NaN
63     NaN
10      G6
58     NaN
50     NaN
24     NaN
147    NaN
59     NaN
125    NaN
39     NaN
9      NaN
Name: Cabin, dtype: object
df["Cabin"].dropna().head(15)
10              G6
136            D47
27     C23 C25 C27
102            D26
151             C2
97         D10 D12
88     C23 C25 C27
118        B58 B60
139            B86
75           F G73
23              A6
66             F33
21             D56
148             F2
137           C123
Name: Cabin, dtype: object
df["Cabin"].fillna(3).head(5)
78     3
7      3
119    3
16     3
43     3
Name: Cabin, dtype: object
df["Cabin"].bfill().head(15)
78      G6
7       G6
119     G6
16      G6
43      G6
63      G6
10      G6
58     D47
50     D47
24     D47
147    D47
59     D47
125    D47
39     D47
9      D47
Name: Cabin, dtype: object
pd.isna(df["Cabin"]).head(10)
78      True
7       True
119     True
16      True
43      True
63      True
10     False
58      True
50      True
24      True
Name: Cabin, dtype: bool

Функция apply

Когда готовой векторной операции не нашлось, остаётся apply, применяющий заданную функцию к каждой строке (axis=1) или к каждому столбцу. Внутри работает обычный цикл Python со всеми издержками, рассмотренными в главе про производительность, поэтому перед написанием apply следует ещё раз поискать векторное решение.

def dummpy_example(row):
    return row['Sex'] * row['Pclass']

df['dummy_example'] = df.apply(dummpy_example, axis=1)
df.tail(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked dummy_example
128 129 1 3 Peter, Miss. Anna female NaN 1 1 2668 22.3583 F E69 C femalefemalefemale
140 141 0 3 Boulos, Mrs. Joseph (Sultana) female NaN 0 2 2678 15.2458 NaN C femalefemalefemale
154 155 0 3 Olsen, Mr. Ole Martin male NaN 0 0 Fa 265302 7.3125 NaN S malemalemale

Визуализация

Метод plot() строит ряд как есть, resample("10s").mean().plot() сначала усредняет его по десятисекундным интервалам и даёт сглаженную кривую, а hist() строит гистограмму распределения.

tdf["Fare"].plot()
<Axes: xlabel='ts'>

png

tdf["Fare"].resample("10s").mean().plot()
<Axes: xlabel='ts'>

png

tdf["Sex"].hist()
<Axes: >

png

SciPy

NumPy сам по себе почти ничего не вычисляет: он хранит числа, уложенные подряд, и выполняет над ними арифметические операции.

SciPy является библиотекой численных методов, построенной поверх NumPy. NumPy отвечает за структуру данных, то есть за быстрый многомерный массив и базовые операции над ним, а SciPy добавляет готовые алгоритмы: интегрирование, решение дифференциальных уравнений, оптимизацию и подгонку кривых, интерполяцию, линейную алгебру, обработку сигналов, спектральный анализ. Прежде чем реализовывать численный метод самостоятельно, целесообразно поискать его в SciPy: почти наверняка он уже реализован поверх проверенных десятилетиями FORTRAN- и C-библиотек (QUADPACK, LAPACK, MINPACK, ODEPACK). Код при этом остаётся коротким, а выполняется со скоростью компилируемого.

SciPy организована в подмодули по областям: scipy.integrate, scipy.optimize, scipy.linalg и так далее. Импортировать библиотеку целиком не принято, импортируется нужный подмодуль.

import numpy as np           # NumPy понадобится везде
from scipy import optimize   # импортируем конкретный подмодуль

Во всех примерах ниже предполагается, что numpy уже импортирован как np.

scipy.constants — физические константы

В scipy.constants собраны фундаментальные физические константы в системе СИ по данным CODATA, что избавляет от необходимости перепечатывать значения из справочника и исключает опечатки в восьмом знаке.

from scipy import constants

print(constants.c)          # скорость света в вакууме, м/с
print(constants.h)          # постоянная Планка, Дж·с
print(constants.hbar)       # приведённая постоянная Планка, Дж·с
print(constants.e)          # элементарный заряд, Кл
print(constants.k)          # постоянная Больцмана, Дж/К
print(constants.epsilon_0)  # электрическая постоянная, Ф/м
print(constants.m_e)        # масса электрона, кг

Помимо «именованных» констант имеется полный словарь physical_constants, возвращающий по названию кортеж (значение, единицы, погрешность). Поиск нужного ключа удобно выполнять функцией find.

print(constants.find('Bohr'))   # все константы, в названии которых есть 'Bohr'

value, unit, uncertainty = constants.physical_constants['proton mass']
print(value, unit, uncertainty)  # 1.672...e-27 kg и её погрешность

Там же находятся множители, переводящие одни единицы в другие: constants.eV хранит электронвольт в джоулях, constants.angstrom — ангстрем в метрах, рядом префиксы constants.milli, constants.femto и т. п. Формулы записываются в СИ, а входные данные задаются в привычных единицах.

E = 13.6 * constants.eV                  # энергия ионизации водорода в джоулях
lam = constants.h * constants.c / E      # длина волны соответствующего фотона, м
print(lam / constants.nano)              # ~91 нм — граница серии Лаймана

print(constants.convert_temperature(300, 'Kelvin', 'Celsius'))  # 26.85

scipy.integrate — интегрирование и ОДУ

Подмодуль scipy.integrate вычисляет определённые интегралы и интегрирует обыкновенные дифференциальные уравнения.

Определённые интегралы вычисляет функция quad (адаптивная квадратура из QUADPACK), принимающая функцию и пределы (в том числе бесконечные) и возвращающая пару из значения интеграла и оценки погрешности. В качестве примера рассмотрим интеграл, возникающий при выводе закона Стефана—Больцмана из формулы Планка.

from scipy import integrate

# Интеграл ∫ x³/(eˣ − 1) dx от 0 до ∞; аналитический ответ — π⁴/15.
# Числитель и знаменатель умножены на e⁻ˣ, чтобы eˣ не переполнялся
# при больших x — обычная предосторожность в численных расчётах
f = lambda x: x**3 * np.exp(-x) / (1 - np.exp(-x))

value, error = integrate.quad(f, 0, np.inf)
print(value, np.pi**4 / 15)          # 6.4939..., совпадает с точным значением
print(error)                         # оценка погрешности ~1e-9

Экспериментальные данные обычно представлены таблицей отсчётов; для них предусмотрены integrate.simpson(y, x=x) и integrate.trapezoid(y, x=x), работающие непосредственно по узлам сетки.

Основным инструментом для ОДУ является solve_ivp (initial value problem, задача Коши). Он интегрирует систему уравнений первого порядка dy/dt = f(t, y); уравнение более высокого порядка предварительно сводится к системе. Для затухающего гармонического осциллятора x'' + 2γx' + ω₀²x = 0 введём вектор состояния y = [x, v] и получим систему первого порядка.

from scipy.integrate import solve_ivp

omega0 = 2 * np.pi    # собственная частота, рад/с
gamma = 0.3           # коэффициент затухания, 1/с

def rhs(t, y, gamma, omega0):
    x, v = y                                 # y = [координата, скорость]
    return [v, -2 * gamma * v - omega0**2 * x]

sol = solve_ivp(rhs, t_span=(0, 10), y0=[1.0, 0.0],       # x(0)=1, v(0)=0
                args=(gamma, omega0),                     # параметры модели
                t_eval=np.linspace(0, 10, 500))           # где выдать решение

t = sol.t
x, v = sol.y          # sol.y имеет форму (2, 500): строки — компоненты решения
print(sol.success)    # True, если интегрирование дошло до конца

Параметры модели передаются в правую часть через аргумент args, а не читаются из глобальных переменных: solve_ivp вызывает rhs(t, y, gamma, omega0), и ту же функцию можно проинтегрировать с другими параметрами без изменения её кода. Способы передачи параметров в интеграторы рассмотрены в главе «Функции».

По умолчанию используется явный метод Рунге—Кутты RK45, автоматически подбирающий шаг; точность регулируется аргументами rtol и atol. Для жёстких систем, где временные масштабы сильно различаются (химическая кинетика, цепочки распадов), явные методы медленны; в этом случае следует перейти на method='Radau' или method='BDF'.

scipy.optimize — минимизация, корни, подгонка кривых

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

Минимизацией занимается функция minimize. Найдём равновесное межатомное расстояние в потенциале Леннард-Джонса V(r) = 4ε[(σ/r)¹² − (σ/r)⁶] (в безразмерных единицах ε = σ = 1).

from scipy import optimize

def lj(r):
    return 4 * (r**-12 - r**-6)     # потенциал Леннард-Джонса

res = optimize.minimize(lj, x0=1.5)  # x0 — начальное приближение
print(res.x)                         # положение минимума: [1.1224...]
print(2**(1/6))                      # аналитический ответ: r = 2^(1/6)
print(res.fun)                       # значение в минимуме: -1.0 (глубина ямы)

minimize работает и с функциями многих переменных, и тогда x0 становится массивом, по умолчанию применяется метод BFGS; для негладких функций имеет смысл попробовать method='Nelder-Mead'. Необходимо помнить, что найденный минимум является локальным и лежит вблизи x0.

Корни уравнения f(x) = 0 на отрезке, где функция меняет знак, надёжно находит brentq (метод Брента). Выведем постоянную закона смещения Вина. Положение максимума спектра Планка сводится к трансцендентному уравнению (x − 5)eˣ + 5 = 0:

from scipy import constants

f = lambda x: (x - 5) * np.exp(x) + 5

x_max = optimize.brentq(f, 1, 10)    # корень на отрезке [1, 10], f меняет знак
print(x_max)                          # 4.9651...

# Постоянная Вина b из λ_max·T = hc/(x_max·k)
b = constants.h * constants.c / (x_max * constants.k)
print(b)                              # 2.898e-3 м·К — как в справочнике

Тот же результат даёт более общий интерфейс optimize.root_scalar(f, bracket=(1, 10)), а для систем нелинейных уравнений предназначен optimize.root, принимающий вектор невязок.

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

rng = np.random.default_rng(42)

# «Эксперимент»: N(t) = N₀·exp(−t/τ) с N₀ = 10, τ = 1.5 плюс шум измерений
t = np.linspace(0, 5, 50)
data = 10 * np.exp(-t / 1.5) + rng.normal(0, 0.3, t.size)

def model(t, n0, tau):                # первый аргумент — независимая переменная,
    return n0 * np.exp(-t / tau)      # остальные — подгоняемые параметры

popt, pcov = optimize.curve_fit(model, t, data, p0=(5, 1))  # p0 — стартовая точка
n0, tau = popt
perr = np.sqrt(np.diag(pcov))         # стандартные погрешности параметров

print(n0, tau)                        # ≈ 10 и ≈ 1.5
print(perr)                           # погрешности подгонки

curve_fit возвращает оптимальные параметры popt и ковариационную матрицу pcov, а корни её диагональных элементов дают погрешности параметров для отчёта. Если для точек известны ошибки измерений, они передаются через sigma= (и absolute_sigma=True), тогда подгонка становится взвешенной. Для нелинейных моделей необходимо задавать начальное приближение p0, иначе метод может сойтись к другому минимуму или не сойтись вовсе.

Подгонка настоящего эксперимента: измерение эмиттанса

На ускорителе измеряется эмиттанс пучка, площадь, занимаемая им в фазовой плоскости «координата — угол». Эмиттанс определяет, до какого размера пучок может быть сфокусирован, и потому является основной мерой его качества.

Углы частиц напрямую не измеряются, измерить можно только поперечный размер пучка на люминофорном экране. Решение даёт метод квадрупольного сканирования: изменяя силу квадрупольной линзы перед экраном, снимают зависимость размера пучка от этой силы. В тонколинзовом приближении линза с нормализованным градиентом \(k\), а за ней промежуток длиной \(l\) до экрана дают матрицу перехода с элементами \(R_{11} = 1 - kl\) и \(R_{12} = l\), а квадрат размера на экране выражается через параметры Твисса \(\alpha_0, \beta_0, \gamma_0\) в месте линзы:

$$ \sigma^2(k) = \varepsilon\left(R_{11}^2\beta_0 - 2R_{11}R_{12}\alpha_0 + R_{12}^2\gamma_0\right), \qquad \gamma_0 = \frac{1+\alpha_0^2}{\beta_0}. $$

Это парабола по \(k\), и три её коэффициента однозначно определяют три искомые величины. Задача сводится к подгонке средствами curve_fit.

l = 1.2                       # расстояние «линза → экран», м

def sigma2(k, eps, beta0, alpha0):
    gamma0 = (1 + alpha0**2) / beta0
    R11, R12 = 1 - k * l, l
    return eps * (R11**2 * beta0 - 2 * R11 * R12 * alpha0 + R12**2 * gamma0)

k = np.linspace(-1.5, 1.5, 25)          # сканируем силу линзы
meas = ...                              # измеренные размеры пучка, м
sigma_meas = 20e-6                      # погрешность измерения размера, 20 мкм

Модель записана для \(\sigma^2\), поэтому кажется естественным возвести измерения в квадрат и подогнать параболу. Сравним такую подгонку с подгонкой, в которой измерениям возвращён их собственный вес.

# наивно: подгоняем квадраты
popt, pcov = optimize.curve_fit(sigma2, k, meas**2, p0=(1e-6, 5.0, -1.0))

# правильно: та же модель, но точки взвешены своими погрешностями.
# погрешность квадрата: d(sigma^2) = 2*sigma*d(sigma)
w = 2 * meas * sigma_meas
popt_w, pcov_w = optimize.curve_fit(lambda k, e, b, a: sigma2(k, e, b, a) / w,
                                    k, meas**2 / w, p0=(1e-6, 5.0, -1.0))

Результат на модельных данных с истинными значениями \(\varepsilon = 0.12\) мкм, \(\beta_0 = 6.94\) м, \(\alpha_0 = -1.69\):

по sigma^2 без весов   eps = 0.126 ± 0.045 мкм   beta = 6.58 ± 2.35 м   alpha = -1.61 ± 0.64
по sigma^2 с весами    eps = 0.119 ± 0.007 мкм   beta = 6.97 ± 0.44 м   alpha = -1.71 ± 0.12

Оба ответа верны в пределах своих погрешностей, однако погрешность различается в шесть раз: 36 % против 6 %. Возведение в квадрат нелинейно, и ошибки оно растягивает неодинаково. Точка с большим размером пучка получает в \(\sigma^2\)-пространстве огромный вес, а точки в перетяжке, где пучок мал, получают почти нулевой. Между тем информацию об эмиттансе несёт как раз перетяжка. Наивная подгонка отбрасывает самые ценные измерения.

Правило: преобразование данных, «выпрямляющее» модель, изменяет и веса точек. Тот же эффект даёт подгонка экспоненты через логарифмирование. log(y) превращает модель в прямую, однако делает равномерный шум неравномерным, и результат смещается. Если преобразование неизбежно, погрешности необходимо пересчитывать вместе с данными и передавать их в sigma=.

На линейном ускорителе инжектора ЦКП «СКИФ» эмиттанс измеряется так же, только вместо тонколинзового приближения расчёт размера ведётся полной моделью ускорителя, а подгонка выполняется методом COBYQA (Constrained Optimization BY Quadratic Approximations), не требующим производных целевой функции, что удобно, когда каждое её вычисление представляет собой запуск программы моделирования. На выходе первой ускоряющей структуры измерен эмиттанс \(1.54 \pm 0.08\) мкм при энергии \(36.4 \pm 1.8\) МэВ, на выходе ускорителя \(0.12 \pm 0.01\) мкм при \(197.9 \pm 9.9\) МэВ. Каждый параметр приведён с погрешностью. Подгонка без погрешностей в физике эксперимента считается незаконченной работой, а curve_fit возвращает их в pcov.

scipy.interpolate — интерполяция табличных данных

Экспериментальные данные представлены таблицей, а значение требуется в произвольной точке между узлами. scipy.interpolate строит по таблице непрерывную функцию, например калибровочную кривую (термопара: напряжение → температура).

from scipy import interpolate

# Калибровка термопары: напряжение (мВ) -> температура (°C)
voltage = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0])
temp = np.array([0.0, 25.3, 50.1, 76.4, 101.8, 128.0])

lin = interpolate.interp1d(voltage, temp)     # кусочно-линейная интерполяция
spl = interpolate.CubicSpline(voltage, temp)  # кубический сплайн

print(lin(2.5), spl(2.5))    # температура при 2.5 мВ по двум методам
print(spl(np.linspace(0, 5, 11)))   # интерполянт принимает и массивы

Кусочно-линейная интерполяция не добавляет к данным ничего лишнего; кубический сплайн даёт гладкую кривую с непрерывными первой и второй производными. По умолчанию за пределами таблицы interp1d возбуждает исключение. Экстраполяция опасна, и включать её следует осознанно (fill_value='extrapolate').

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

# Координата свободно падающего тела, измеренная в 11 моментах времени
t = np.linspace(0, 2, 11)
x = 0.5 * 9.81 * t**2

spl = interpolate.CubicSpline(t, x)
v = spl.derivative()          # v(t) = dx/dt — тоже готовая функция
print(v(1.0))                 # ≈ 9.81 — скорость через секунду падения

Интерполяция проводит кривую точно через все точки, включая испорченные шумом. Для зашумлённых данных применяется сглаживание (interpolate.make_smoothing_spline) или подгонка модели через curve_fit.

scipy.linalg — линейная алгебра

scipy.linalg покрывает и расширяет numpy.linalg: решение систем линейных уравнений, разложения матриц (LU, QR, SVD, Холецкого), собственные значения, матричные функции наподобие expm, считающей экспоненту от матрицы. Решим систему уравнений Кирхгофа для схемы с тремя контурами.

from scipy import linalg

# Метод контурных токов: R @ i = v
R = np.array([[ 11., -5.,  0.],
              [ -5., 18., -3.],
              [  0., -3.,  9.]])   # матрица сопротивлений, Ом
v = np.array([10., 0., 0.])        # ЭДС в контурах, В

i = linalg.solve(R, v)             # контурные токи, А
print(i)
print(R @ i)                       # проверка: получаем обратно v

linalg.solve(A, b) быстрее и численно устойчивее, чем linalg.inv(A) @ b; явное вычисление обратной матрицы почти никогда не требуется.

Второй задачей являются собственные значения, дающие в механике нормальные моды, а в квантовой механике уровни энергии. Рассмотрим два одинаковых груза массы m на пружинах жёсткости k, связанные пружиной kc; уравнения движения имеют вид ẍ = −(K/m)x, а квадраты частот нормальных мод совпадают с собственными значениями матрицы K/m.

m, k, kc = 1.0, 1.0, 0.5

K = np.array([[k + kc, -kc],
              [-kc, k + kc]])      # матрица жёсткости связанных осцилляторов

evals, evecs = linalg.eigh(K / m)  # eigh — для симметричных (эрмитовых) матриц
omega = np.sqrt(evals)             # частоты нормальных мод

print(omega)      # [1.0, 1.414...]: √(k/m) и √((k+2kc)/m)
print(evecs)      # столбцы — моды: синфазная (1,1) и противофазная (1,-1)

Для симметричных и эрмитовых матриц лучше использовать eigh, а не общий eig: он быстрее, гарантированно возвращает вещественные собственные значения в порядке возрастания и ортонормированные собственные векторы. Гамильтонианы в матричном представлении относятся к этому случаю.

scipy.signal — обработка сигналов

scipy.signal очищает и анализирует данные с АЦП, осциллографа или детектора. Три типовые операции: фильтрация, оценка спектра, поиск пиков.

Смоделируем измерение, в котором полезный сигнал 5 Гц закрыт широкополосным шумом, и подавим шум низкочастотным фильтром Баттерворта.

from scipy import signal

rng = np.random.default_rng(0)

fs = 1000                                    # частота дискретизации, Гц
t = np.arange(0, 2, 1 / fs)                  # 2 секунды записи
clean = np.sin(2 * np.pi * 5 * t)            # полезный сигнал 5 Гц
noisy = clean + 0.8 * rng.normal(size=t.size)

# НЧ-фильтр Баттерворта 4-го порядка, частота среза 20 Гц
b, a = signal.butter(4, 20, btype='low', fs=fs)
filtered = signal.filtfilt(b, a, noisy)      # прогон вперёд и назад:
                                             # нулевой фазовый сдвиг

filtfilt пропускает сигнал через фильтр дважды (вперёд и назад), поэтому не вносит фазовой задержки, и форма и положение пиков не искажаются.

Спектральную плотность мощности оценивают методом Уэлча, усредняющим спектры по перекрывающимся окнам, что снижает разброс оценки по сравнению с одиночным БПФ.

freqs, psd = signal.welch(noisy, fs=fs, nperseg=512)
print(freqs[np.argmax(psd)])    # ≈ 5.9 Гц — пик вблизи частоты сигнала
                                # (разрешение по частоте здесь fs/nperseg ≈ 2 Гц)

Пики (спектральные линии, импульсы детектора) находит find_peaks с настраиваемыми критериями (минимальная высота, расстояние между пиками, ширина).

peaks, props = signal.find_peaks(filtered, height=0.5, distance=100)
print(t[peaks])            # моменты максимумов синусоиды: шаг ≈ 0.2 с
print(props['peak_heights'])

scipy.fft — быстрое преобразование Фурье

Спектральный анализ напрямую выполняется через scipy.fft, заменивший старый scipy.fftpack. Для вещественных сигналов (а измеренные сигналы вещественны) предпочтительнее использовать rfft, возвращающий только физически осмысленную половину спектра, от нуля до частоты Найквиста fs/2.

Задача состоит в том, чтобы найти в зашумлённой записи скрытые периодические составляющие.

from scipy.fft import rfft, rfftfreq

rng = np.random.default_rng(1)

fs = 1000                                  # частота дискретизации, Гц
t = np.arange(0, 1, 1 / fs)                # 1 секунда записи, N = 1000 отсчётов
x = (np.sin(2 * np.pi * 50 * t)            # компонента 50 Гц, амплитуда 1
     + 0.5 * np.sin(2 * np.pi * 120 * t)   # компонента 120 Гц, амплитуда 0.5
     + rng.normal(0, 0.5, t.size))         # белый шум

spectrum = rfft(x)                         # комплексный спектр
freqs = rfftfreq(t.size, 1 / fs)           # сетка частот: 0 ... fs/2 = 500 Гц
amp = 2 / t.size * np.abs(spectrum)        # нормировка на амплитудный спектр

print(freqs[np.argmax(amp)])               # 50.0 — главный пик
print(amp[freqs == 50], amp[freqs == 120]) # ≈ 1.0 и ≈ 0.5 — амплитуды нашлись

Разрешение по частоте равно 1/T, где T — длительность записи (здесь 1 Гц). Всё, что выше частоты Найквиста fs/2, «заворачивается» вниз (алиасинг), поэтому сигнал перед оцифровкой должен быть отфильтрован. Множитель 2/N преобразует модуль БПФ в амплитуды гармоник. Обратное преобразование выполняет irfft, для комплексных сигналов служит пара fft/ifft, имеются и многомерные версии (fft2, fftn), например для дифракционных картин.

Прочие возможности SciPy

Остальные подмодули:

  • scipy.stats содержит распределения, статистические тесты, генерацию случайных величин;
  • scipy.sparse хранит разреженные матрицы (гамильтонианы больших систем, сеточные задачи);
  • scipy.special собрал специальные функции: Бесселя, Лежандра, эрмитовы полиномы, гамма-функцию;
  • scipy.ndimage занимается обработкой изображений (данные с ПЗС-камер);
  • scipy.spatial покрывает вычислительную геометрию: kd-деревья, триангуляции, ближайших соседей.

Если задача сформулирована в стандартных терминах (задача Коши, наименьшие квадраты, собственные значения), в SciPy почти наверняка найдётся готовая функция нужной точности и скорости.

Полезные ссылки

  • Документация SciPy — справочник по всем подмодулям, у каждой функции есть примеры;
  • Scientific Python Lectures (бывшие SciPy Lecture Notes) — подробный курс по научному Python: NumPy, SciPy, Matplotlib и продвинутые темы;
  • SciPy Cookbook — рецепты для типовых задач: от подгонки данных до решения уравнений в частных производных.

Визуализация на Python

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

Библиотек для построения графиков в Python много, однако физику обычно достаточно трёх. Matplotlib даёт статическое изображение, вставляемое затем в статью. Plotly добавляет интерактивность, ценную при разборе сырых данных: наведение курсора с точными числами, масштабирование, много измерений сразу на одном полотне. HoloViews предлагает описывать данные вместо того, чтобы строить график.

Все данные, показанные ниже, вычислены непосредственно в листингах, так что код главы выполняется подряд, от первой строки до последней.

Matplotlib

Matplotlib имеет два интерфейса: первый из них, pyplot, унаследованный от MATLAB, хранит скрытую «текущую фигуру», в которую и добавляют команды наподобие plt.plot. Второй представляет фигуру и оси явными объектами, названными в коде своими именами. Следует сразу использовать второй: в нём видно, к каким осям относится каждый вызов, и код, растущий вместе с задачей, не нарушается, когда фигур становится несколько.

Начнём с осциллограммы, записанной с цилиндра Фарадея, то есть с импульса тока пучка на фоне шума оцифровки.

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

t = np.linspace(0, 10, 2000)                        # время, мкс
current = 4.2 * np.exp(-((t - 3.5) / 0.45) ** 2)    # импульс тока пучка, мА
current += rng.normal(0, 0.10, t.size)              # шум оцифровки

fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(t, current, lw=0.8)
ax.set_xlabel('Время, мкс')
ax.set_ylabel('Ток пучка, мА')
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig('waveform.png', dpi=300)

subplots возвращает пару, где фигура fig представляет собой весь холст с полями и общим заголовком, а ax — прямоугольник с координатной сеткой, отведённый под сам график. Построение выполняется в осях, сохранение — для фигуры. Вызов tight_layout подбирает поля так, чтобы подписи, поставленные у осей, не срезались краем. savefig записывает файл, а plt.show() открывает окно, блокирующее скрипт до закрытия, и в программе требуется либо одно, либо другое.

Сырой сигнал редко показывают отдельно: рядом помещают сглаженную кривую, а найденный максимум подписывают стрелкой.

from scipy.signal import savgol_filter

smooth = savgol_filter(current, 101, 3)   # окно 101 отсчёт, полином 3-й степени
top = smooth.argmax()

fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(t, current, lw=0.6, alpha=0.4, label='сырой сигнал')
ax.plot(t, smooth, lw=1.6, color='crimson', label='после Савицкого—Голея')
ax.annotate(f'{smooth[top]:.2f} мА при {t[top]:.2f} мкс',
            xy=(t[top], smooth[top]), xytext=(t[top] + 1.0, smooth[top] - 0.5),
            arrowprops={'arrowstyle': '->'})
ax.set_xlabel('Время, мкс')
ax.set_ylabel('Ток пучка, мА')
ax.legend(loc='upper right')
fig.tight_layout()
fig.savefig('waveform-smooth.png', dpi=300)

Две кривые, наложенные в одних осях, различают толщиной, прозрачностью и цветом, а метод legend собирает подписи, переданные параметром label. Метод annotate размещает надпись со стрелкой, протянутой от точки xytext к точке xy на самом графике.

Следующий пример — скан по параметру. Изменяя силу квадрупольной линзы перед экраном, снимают размер пучка, а по параболе, восстановленной подгонкой, вычисляют эмиттанс; сам метод рассмотрен в главе про SciPy. Измерения, снабжённые погрешностями, изображает errorbar.

l = 1.2                                        # расстояние «линза — экран», м
eps, beta0, alpha0 = 0.12e-6, 6.94, -1.69      # эмиттанс, м·рад, и Твисс
gamma0 = (1 + alpha0 ** 2) / beta0

k = np.linspace(-1.5, 1.5, 25)                 # сила линзы, 1/м²
R11, R12 = 1 - k * l, l
sigma = np.sqrt(eps * (R11**2 * beta0 - 2*R11*R12*alpha0 + R12**2*gamma0))
meas = sigma + rng.normal(0, 20e-6, k.size)    # измерение с ошибкой 20 мкм

fig, ax = plt.subplots(figsize=(6, 3.4))
ax.errorbar(k, meas * 1e3, yerr=0.02, fmt='o', ms=4, capsize=3, label='экран')
ax.plot(k, sigma * 1e3, color='crimson', label='модель')
ax.set_xlabel(r'Сила линзы $k$, м$^{-2}$')
ax.set_ylabel(r'Размер пучка $\sigma$, мм')
ax.legend()
fig.tight_layout()
fig.savefig('quadscan.png', dpi=300)

Подписи осей поддерживают подмножество TeX, заключённое в знаки доллара, поэтому индексы и греческие буквы записываются привычным образом. Строку, передаваемую в подпись, необходимо помечать префиксом r, иначе обратный слеш будет обработан Python раньше matplotlib.

Двумерный массив отображает imshow, окрашивающий каждую ячейку по её значению; рассмотрим для него матрицу отклика замкнутой орбиты, вычисленную в главе про коррекцию.

n_bpm, n_cor, nu = 64, 48, 8.42
phi_b = np.sort(rng.uniform(0, 2 * np.pi * nu, n_bpm))   # фазы мониторов
phi_c = np.sort(rng.uniform(0, 2 * np.pi * nu, n_cor))   # фазы корректоров
beta_b = rng.uniform(4.0, 24.0, n_bpm)                   # бета-функция, м
beta_c = rng.uniform(4.0, 24.0, n_cor)

dphi = np.abs(phi_b[:, None] - phi_c[None, :])
R = (np.sqrt(beta_b[:, None] * beta_c[None, :]) / (2 * np.sin(np.pi * nu))
     * np.cos(dphi - np.pi * nu))
print(R.shape)

lim = np.abs(R).max()
fig, ax = plt.subplots(figsize=(5.5, 4.5))
im = ax.imshow(R, cmap='RdBu_r', vmin=-lim, vmax=lim, aspect='auto')
fig.colorbar(im, ax=ax, label=r'$R_{ij}$, мм/мрад')
ax.set_xlabel('Номер корректора $j$')
ax.set_ylabel('Номер монитора $i$')
fig.tight_layout()
fig.savefig('response.png', dpi=300)
(64, 48)

Палитру выбирают по смыслу величины: матрице отклика, где знак меняется от элемента к элементу, соответствует расходящаяся RdBu_r с белым нулём, и границы vmin и vmax, заданные вручную, обязаны быть симметричными. Иначе белый цвет сместится с нуля и изображение будет вводить в заблуждение. Для знакопостоянной величины лучше брать viridis, не меняющий светлоту скачками и потому различимый даже в чёрно-белой печати. Радужный jet, унаследованный от старых пакетов, создаёт ложные границы там, где данные меняются плавно.

Ячейки у imshow равны между собой, а физическая сетка часто неравномерна, и для неё применяют pcolormesh, принимающий сами координаты узлов. Физические границы изображения задаются параметром extent, и номера пикселей на осях сменяются метрами.

Настройки, повторяющиеся от графика к графику, выносятся в rcParams один раз на весь скрипт.

plt.rcParams.update({
    'figure.figsize': (6.5, 3.6),   # ширина колонки статьи
    'font.size': 11,
    'axes.grid': True,
    'grid.alpha': 0.3,
    'savefig.dpi': 300,
    'savefig.bbox': 'tight',
})

fig, (up, down) = plt.subplots(2, 1, sharex=True, figsize=(6.5, 5))
up.plot(t, current, lw=0.6)
up.set_ylabel('Ток, мА')
down.plot(t, smooth, color='crimson')
down.set_ylabel('Сглажено, мА')
down.set_xlabel('Время, мкс')
fig.align_ylabels()
fig.savefig('report.pdf')          # вектор, не рассыпающийся при печати

Параметр sharex=True связывает оси подграфиков, и подписи по времени печатаются только под нижним. Для печати следует сохранять векторный формат, .pdf или .svg, не размываемый при увеличении; растровый .png с плотностью 300 точек на дюйм предназначен для слайдов и веба. Все изображения, собранные в одну статью, полезно строить одним скриптом, поскольку правка стиля тогда обновляет сразу весь набор.

Plotly

При самостоятельном разборе данных требуется подвести курсор и прочитать точное число, выделить рамкой фрагмент и увеличить его, скрыть лишнюю кривую щелчком по легенде. Эти возможности предоставляет Plotly, строящий график в браузере поверх библиотеки plotly.js.

pip install plotly

Рассмотрим огибающую пучка, вычисленную вдоль тракта. Данные даёт уравнение Капчинского—Владимирского, выведенное в отдельной главе, а тракт состоит из пяти соленоидов, расставленных через полметра.

import numpy as np
from scipy.integrate import solve_ivp

gamma = 1 + 2.0 / 0.511                            # пучок 2 МэВ
beta = np.sqrt(1 - gamma ** -2)
perv = 2 * 100.0 / (17045 * (beta * gamma) ** 3)   # первеанс при токе 100 А
eps = 1e-6                                         # эмиттанс, м·рад

z_lens = np.arange(0.5, 3.0, 0.5)                  # пять соленоидов через 0.5 м
z = np.linspace(0, 3, 400)                         # сетка вдоль тракта, м

def envelope(k):
    """Радиус пучка a(z), м, при жёсткостях соленоидов k."""
    def rhs(s, y):
        ks = (k * np.exp(-((s - z_lens) / 0.06) ** 2)).sum()
        a, ap = y
        return [ap, -ks * a + perv / a + eps ** 2 / a ** 3]
    return solve_ivp(rhs, (0, 3), [5e-3, 0.0], t_eval=z, max_step=0.005).y[0]

a = envelope(np.full(5, 25.0)) * 1e3               # огибающая, мм
print(f'{a.min():.2f} {a.max():.2f}')
2.36 7.13

Первое изображение соберём вручную из объектов graph_objects, дающих полный контроль над каждой линией.

import plotly.graph_objects as go

fig = go.Figure()
fig.add_scatter(x=z, y=a, name='верх', line={'color': 'crimson'},
                hovertemplate='z = %{x:.2f} м<br>a = %{y:.2f} мм<extra></extra>')
fig.add_scatter(x=z, y=-a, name='низ', line={'color': 'crimson'},
                fill='tonexty', fillcolor='rgba(220,20,60,0.15)',
                hoverinfo='skip')
for zi in z_lens:
    fig.add_vline(x=zi, line_dash='dot', line_color='gray')
fig.update_layout(xaxis_title='z, м', yaxis_title='Радиус пучка, мм',
                  showlegend=False, height=350)
fig.write_html('envelope.html', include_plotlyjs='cdn')

Параметр hovertemplate задаёт содержимое всплывающей подсказки: %{x} и %{y} подставляют координаты точки, а <extra></extra> убирает служебную рамку с именем кривой. Метод write_html создаёт рядом самодостаточный файл, открываемый в любом браузере без Python. С ключом include_plotlyjs='cdn' он загружает plotly.js из сети, а со значением True встраивает библиотеку внутрь, и файл разрастается до нескольких мегабайт.

Скан по параметру удобно показывать картой, поэтому выполним расчёт для сорока одной жёсткости соленоидов и объединим полученные огибающие в один двумерный массив.

k_grid = np.linspace(15.0, 35.0, 41)
scan = np.array([envelope(np.full(5, k)) for k in k_grid]) * 1e3

heat = go.Figure(go.Heatmap(
    x=z, y=k_grid, z=scan, colorscale='Viridis',
    colorbar={'title': 'a, мм'},
    hovertemplate='z = %{x:.2f} м<br>k = %{y:.1f} 1/м²'
                  '<br>a = %{z:.2f} мм<extra></extra>'))
heat.update_layout(xaxis_title='z, м',
                   yaxis_title='Жёсткость соленоидов k, 1/м²', height=380)
heat.write_html('scan.html', include_plotlyjs='cdn')

При наведении курсора на точку карты читаются три числа: положение вдоль тракта, жёсткость линз и радиус пучка. В matplotlib для того же пришлось бы обращаться к массиву по индексам. Изображение, сохранённое в HTML, остаётся интерактивным и после закрытия Python: масштаб меняется колесом, а прямоугольник, выделенный курсором, растягивается на всё поле, и такими картами удобно искать рабочую точку установки.

Миллион точек plotly отрисовывает уже медленно, поскольку каждая линия, переданная в браузер, существует отдельным объектом JavaScript. Для больших массивов предусмотрен Scattergl, у которого отрисовка переложена на видеокарту.

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

import pandas as pd
import plotly.express as px

rng = np.random.default_rng(1)
trials = rng.uniform(18.0, 32.0, size=(300, 5))     # 300 случайных настроек
loss = np.array([np.sqrt(np.mean((envelope(k) - 4e-3) ** 2)) * 1e3
                 for k in trials])                  # отклонение от 4 мм, мм

runs = pd.DataFrame(trials, columns=[f'k{i}' for i in range(1, 6)])
runs['loss'] = loss

par = px.parallel_coordinates(runs, color='loss',
                              color_continuous_scale='Blues_r')
par.write_html('parallel.html', include_plotlyjs='cdn')
print(f'лучшая настройка: {loss.min():.2f} мм, худшая: {loss.max():.2f} мм')
лучшая настройка: 1.18 мм, худшая: 3.18 мм

Тысяча вариантов настройки оптики линака: каждая линия — один расчёт

Изображение выше построено тем же вызовом, только на настоящей задаче: тысяча прогонов оптимизации оптики линейного ускорителя инжектора ЦКП «СКИФ». Осей восемь, и крайняя левая несёт значение целевой функции, заключённое между 0.461 и 17.752, а остальные отведены под добавки к токам квадруполей, то есть каналы MG-LA1:QLD1-Iadd, MG-LA1:QLF1-Iadd, MG-LA2:QLD2-Iadd и так далее. Цветом закодирована та же целевая функция, и тёмные линии отмечают удачные прогоны.

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

HoloViews

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

HoloViews построен на принципе «аннотировать данные, а не строить график». Пользователь описывает семантику данных, то есть что является аргументом, что значением и в каких они единицах, а библиотека сама выбирает представление. Один и тот же объект, собранный однажды, отрисовывается разными бэкендами: интерактивным bokeh, matplotlib или plotly.

pip install holoviews bokeh

Основой является элемент, контейнер с данными, знающий свой тип визуализации. hv.Curve задаёт непрерывную зависимость, hv.Scatter показывает дискретные измерения, hv.Image отображает величину на регулярной сетке. Измерениям задают удобочитаемые подписи и единицы, попадающие затем на оси и во всплывающие подсказки.

import numpy as np
import holoviews as hv

hv.extension('bokeh')     # можно 'matplotlib' или 'plotly'

rng = np.random.default_rng(42)
t = np.linspace(0, 10, 2000)
current = 4.2 * np.exp(-((t - 3.5) / 0.45) ** 2) + rng.normal(0, 0.10, t.size)

dim_t = hv.Dimension('t', label='Время', unit='мкс')
dim_i = hv.Dimension('I', label='Ток пучка', unit='мА')

wave = hv.Curve((t, current), dim_t, dim_i)
wave

В Jupyter объект, возвращённый последней строкой ячейки, отображается автоматически, так что аналог %matplotlib inline не требуется. С бэкендом bokeh панорамирование, масштабирование и подсказки доступны сразу, и подозрительный выброс, замеченный на графике, рассматривается вблизи без перестроения изображения.

hv.Points внешне похож на Scatter, однако семантика у него иная: обе координаты выступают независимыми измерениями, а точки просто располагаются на плоскости, как частицы на фазовой плоскости. Готовые элементы комбинируются операторами, из которых + размещает графики рядом, а * накладывает их в одних осях.

x = rng.normal(0, 1.0, 2000)        # координата частиц, мм
xp = rng.normal(0, 0.3, 2000)       # угол, мрад
phase = hv.Points((x, xp), ['x', "x'"])

grid = np.linspace(-3, 3, 200)
xx, yy = np.meshgrid(grid, grid)
spot = hv.Image(np.exp(-2 * (xx ** 2 + yy ** 2) / 1.2 ** 2),   # пятно на экране
                bounds=(-3, -3, 3, 3), kdims=['x', 'y'], vdims='I')

sparse = hv.Scatter((t[::200], current[::200]), dim_t, dim_i)
overlay = wave * sparse                       # в одних осях
layout = (wave + phase + spot).cols(3)        # рядом, в три колонки

Совмещение работает благодаря тому, что каждый элемент самодостаточен: HoloViews определяет, что измерения у wave и sparse совпали, и сам выравнивает оси, подписи и легенду. Число колонок в layout задаёт метод .cols(), вызываемый на готовой компоновке.

В matplotlib скан по параметру превращается в цикл, объединяющий десяток подграфиков в одну фигуру, а HoloMap превращает тот же параметр в слайдер.

from scipy.integrate import solve_ivp

# модель тракта (z_lens, perv, eps, z) взята из раздела про Plotly
dim_z = hv.Dimension('z', label='Положение', unit='м')
dim_a = hv.Dimension('a', label='Радиус пучка', unit='мм')

def curve(k):
    """Огибающая пучка при жёсткости всех соленоидов k."""
    def rhs(s, y):
        ks = (k * np.exp(-((s - z_lens) / 0.06) ** 2)).sum()
        a, ap = y
        return [ap, -ks * a + perv / a + eps ** 2 / a ** 3]
    a = solve_ivp(rhs, (0, 3), [5e-3, 0.0], t_eval=z, max_step=0.005).y[0]
    return hv.Curve((z, a * 1e3), dim_z, dim_a)

hmap = hv.HoloMap({k: curve(k) for k in [20, 25, 30, 35]}, kdims='k')
hv.save(hmap, 'envelope-scan.html')     # слайдер останется живым и без Python

dmap = hv.DynamicMap(curve, kdims='k').redim.range(k=(15.0, 35.0))

HoloMap вычисляет все кадры заранее и хранит их в памяти, зато сохраняется в самодостаточный HTML, который можно передать коллеге. DynamicMap вычисляет кадр по движению слайдера, что необходимо, когда параметр непрерывен или каждый расчёт дорог, однако без запущенного ядра Python объект не отображается, и в статический файл он не сохраняется.

Оформление отделено от семантики и задаётся методом .opts() поверх готового объекта.

overlay.opts(
    hv.opts.Curve(width=650, height=280, color='black'),
    hv.opts.Scatter(size=7, color='crimson'),
)

# logz — логарифмическая шкала цвета, нужная пучкам и спектрам
# с большим динамическим диапазоном
spot.opts(width=380, height=340, cmap='inferno', colorbar=True, logz=True)

Часть опций зависит от бэкенда: ширина width и высота height заданы в пикселях у bokeh, а у matplotlib вместо них используются fig_inches и aspect. Полный список опций элемента выводит hv.help(hv.Curve).

Вокруг HoloViews сформировалась экосистема HoloViz. Пакет hvPlot после одного импорта добавляет к DataFrame аксессор .hvplot, заменяющий встроенный df.plot() из pandas; возвращает он обычные объекты HoloViews со всеми слайдерами и оверлеями. Библиотека Panel связывает графики, виджеты и текст в единый интерфейс, превращаемый командой panel serve app.py в веб-приложение. Мониторинг установки становится страницей в браузере без единой строки JavaScript.

Полезные ссылки

Машинное обучение в физических исследованиях

В предыдущих главах рассмотрены обработка массивов данных с помощью NumPy и Pandas и визуализация результатов. Следующим шагом является автоматическое извлечение из данных закономерностей с помощью моделей, классифицирующих события, предсказывающих значения физических величин и находящих структуру там, где визуальный анализ уже не позволяет её обнаружить.

Назначение раздела

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

Методы машинного обучения позволяют:

  • Находить закономерности в больших наборах собранных данных
  • Классифицировать физические объекты и явления
  • Предсказывать поведение систем на основе известных данных
  • Оптимизировать экспериментальные установки и процессы

Содержание раздела

  1. Основные понятия, где вводится терминология и рассматриваются типы задач машинного обучения
  2. Классические алгоритмы, от метода ближайших соседей до градиентного бустинга и кластеризации
  3. Нейронные сети, то есть модель нейрона, обратное распространение ошибки, оптимизаторы и регуляризация
  4. Инструменты, прежде всего библиотека scikit-learn, с помощью которой всё перечисленное может быть опробовано на практике

За каждым методом стоит своя математика и своя интуиция; рецепты без объяснения в настоящем разделе не приводятся.

Важное замечание

Раздел не требует глубокого знания машинного обучения. Предполагается, что читатель:

  • Знаком с математическим анализом и линейной алгеброй в объёме первого курса
  • Владеет программированием хотя бы на базовом уровне
  • Готов экспериментировать и искать решения

Расширенная версия

В настоящем разделе собрана основа. Расширенная версия этого материала, дополненная практическими заданиями, в которых алгоритмы реализуются самостоятельно, вышла отдельной книгой «Базовые методы ИИ в физических исследованиях». Готовые рецепты обучения всех основных моделей, от линейной регрессии до нейронных сетей, собраны в сборнике рецептов по машинному обучению.


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

Основные понятия машинного обучения

Терминология, сложившаяся в машинном обучении стихийно, затрудняет понимание: одни и те же понятия называются по-разному, а за привычными словами наподобие «модель» или «обучение», заимствованными из обихода, стоит не совсем то, что принято в физике.

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

Основные термины

  • Искусственным интеллектом (AI) называют систему, способную принимать решения на основе восприятия окружающего мира.
  • Машинное обучение (ML) составляет подраздел AI и обозначает систему, принимающую решения на основе накопленного опыта (данных) и текущего состояния мира.
  • Глубокое обучение (DL) составляет подраздел ML, основанный на использовании глубоких нейронных сетей (Neural Networks, NN).
  • Data Science — дисциплина, объединяющая сбор, обработку, анализ и извлечение знаний из данных.
  • Big Data обозначает обработку и анализ данных, масштаб которых не позволяет работать с ними в стандартных инструментах (например, в Excel).

Термины, связанные с данными

  • Датасетом, или выборкой (Dataset), называют набор данных, поданный алгоритму на вход.
  • Признак, или фича (Feature, X), обозначает характеристику или измеряемый параметр объекта.
  • Целевая переменная, она же метка или класс (Label, Target, y), задаёт значение, которое требуется предсказать по признакам объекта.
  • Законом природы (в контексте ML) называют скрытую взаимосвязь между признаками и целевой переменной, восстанавливаемую моделью. Формально это отображение из пространства признаков X в пространство меток y.

Типы задач машинного обучения

Задачи ML делятся по тому, имеется ли у объектов разметка (метка y), проставленная человеком, и какова она.

1. Обучение с учителем (Supervised Learning)

Имеется размеченная обучающая выборка, где каждому объекту сопоставлена правильная метка y. Целью является восстановление закона природы X -> y.

Пример. Отбор событий на детекторе (сигнал / фон), где физик вручную разметил часть накопленных данных.

2. Обучение без учителя (Unsupervised Learning)

Разметка y отсутствует или не используется. Алгоритм ищет структуры, закономерности и связи в самих данных.

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

Основные типы задач

Внутри этих двух парадигм выделяют несколько типовых постановок.

Классификация (Classification)

  • Цель: отнести объект к одному из заранее заданных классов.
  • Особенность: множество меток y конечно и часто невелико.
  • Подвиды: бинарная (2 класса) и многоклассовая (>2 классов).
  • Пример: определение болезни по симптомам (болен/здоров), распознавание цифр, написанных от руки.

Регрессия (Regression)

  • Цель: предсказать непрерывную числовую величину.
  • Особенность: Метка y принимает вещественные значения.
  • Пример: восстановление энергии частицы по зарегистрированному отклику калориметра, прогноз температуры на завтра.

Кластеризация (Clustering)

  • Цель: разбить данные на группы (кластеры) так, чтобы объекты внутри одной группы были похожи, а объекты из разных групп отличались.
  • Особенность: заранее проставленные метки отсутствуют (обучение без учителя).
  • Пример: поиск групп однотипных событий в данных, накопленных установкой, группировка спектров по форме линий.

Снижение размерности (Dimensionality Reduction)

  • Цель: уменьшить количество признаков, перейдя в пространство меньшей размерности и сохранив при этом важные структуры данных (близкие объекты должны остаться близкими).
  • Применение: визуализация данных (например, 3D -> 2D), борьба с «проклятием размерности», сжатие данных.

Ранжирование (Ranking)

  • Цель: упорядочить объекты (например, документы или товары) по их релевантности запросу или предпочтениям пользователя.
  • Пример: выдача результатов поиска, рекомендательные системы.

Генерация (Generation)

  • Цель: создавать новые объекты (изображения, текст, музыку), похожие на объекты обучающей выборки, но не совпадающие ни с одним из них.
  • Пример: генерация реалистичных лиц, написание текстов в стиле определённого автора.

Типы признаков (Features)

От типа признака зависит как выбор модели, так и способ предобработки, применяемой к данным.

  • Бинарные: выбор из двух вариантов (да/нет, кот/не кот).
  • Номинальные (категориальные): конечное множество без порядка (цвета, марки машин).
  • Порядковые (ординальные): конечное упорядоченное множество (оценки: плохо/удовлетворительно/хорошо/отлично).
  • Числовые (вещественные): непрерывные величины (рост, цена, расстояние).

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

Устройство модели

Моделью машинного обучения называют параметрическую функцию (или «чёрный ящик»), отображающую пространство признаков X в пространство ответов y, то есть Model: X -> y.

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

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


В следующей главе осуществляется переход от общих понятий к конкретным алгоритмам, начиная с самого наглядного, метода ближайших соседей.

Классические алгоритмы машинного обучения

В настоящей главе рассматриваются классические алгоритмы машинного обучения: метод ближайших соседей, линейная и логистическая регрессии, деревья решений и построенные на их основе ансамбли (случайный лес, градиентный бустинг), а также методы понижения размерности и кластеризации. Разделы, опирающиеся друг на друга, целесообразно читать по порядку. Изложение носит конспективный характер: строгие выкладки приведены у Хасти [26] и Бишопа [27], рецепты, доведённые до работающего кода, — у Жерона [29].

Метод k-ближайших соседей (KNN)

Постановка задачи

Рассмотрим задачу классификации животных по двум признакам.

  • Длина усов
  • Длина хвоста

Для каждого объекта обучающей выборки известна метка, кот или пёс; необходимо построить модель, определяющую класс животного по новым измерениям.

На плоскости точки каждого класса группируются в своей области пространства признаков.

Гипотеза о компактности

В основе метода KNN лежит гипотеза о компактности.

Объекты одного класса расположены «близко» друг к другу в пространстве признаков, а объекты разных классов «далеко».

Гипотеза сводит классификацию к поиску близких объектов, уже размеченных в обучающей выборке.

Алгоритм KNN

Определение:
K-ближайших соседей (K Nearest Neighbors, KNN) — один из простейших алгоритмов классификации.

Алгоритм предсказания:

  1. Для нового объекта вычислить расстояние до всех объектов обучающей выборки
  2. Выбрать \(K\) объектов с наименьшим расстоянием
  3. Присвоить объекту класс, чаще всего встречающийся среди \(K\) соседей (голосование большинства)

Гиперпараметр:
\(K\) — количество соседей, участвующих в голосовании.

  • При малом \(K\) модель чувствительна к шуму и выбросам
  • При большом \(K\) граница решений становится более гладкой, но может потерять локальные особенности

Метрики расстояния

Понятие «близости» определяется выбранной метрикой.

Манхэттенское расстояние

$$d(\mathbf{x}, \mathbf{\hat{x}}) = \sum_{i=1}^{N} |x_i - \hat{x}_i|$$

Евклидово расстояние

$$d(\mathbf{x}, \mathbf{\hat{x}}) = \sqrt{\sum_{i=1}^{N} (x_i - \hat{x}_i)^2}$$

Косинусное расстояние

$$d(\mathbf{x}, \mathbf{\hat{x}}) = 1 - \frac{\sum_{i=1}^{N} x_i \hat{x}i}{\sqrt{\sum{i=1}^{N} x_i^2} \cdot \sqrt{\sum_{i=1}^{N} \hat{x}_i^2}}$$

Преимущество косинусного расстояния: оно измеряет угол между векторами, а не абсолютную разницу значений, и потому применимо там, где важна ориентация вектора признаков, а его длина не имеет значения.

Проблемы и решения

1. Зависимость от масштаба признаков

Проблема:
Если признаки имеют разные масштабы (например, 29 признаков ∈ [0, 1], а один ∈ [0, 1000]), то в вычисленном расстоянии будет доминировать признак с большим масштабом.

Решением является нормализация признаков.

  • Минимакс-нормализация приводит все значения к диапазону [0, 1]
  • Стандартизация приводит их к нулевому математическому ожиданию и единичной дисперсии (\(\mu = 0, \sigma = 1\))

2. Вычислительная сложность

Проблема:
При большом объёме обучающей выборки (\(N\) объектов) поиск ближайших соседей требует \(O(N)\) операций сравнения на каждый объект.

Решением являются структуры данных, ускоряющие поиск.

  • kD-tree (k-dimensional tree) строит дерево разбиения пространства, на каждом уровне разделяющее данные по одному признаку
  • Ball tree — иерархическая структура из гиперсфер
  • HNSW (Hierarchical Navigable Small World) — графовая структура для приближённого поиска ближайших соседей
  • FRiS-Stolp отбирает эталоны, заменяющие собой целые группы объектов

3. Улучшение голосования

Вместо простого подсчёта соседей каждого класса можно применить взвешенное голосование: вес соседа обратно пропорционален расстоянию до него.

$$\text{вес}_i = \frac{1}{d(\mathbf{x}, \mathbf{x}_i)} \quad \text{или} \quad \text{вес}_i = e^{-d(\mathbf{x}, \mathbf{x}_i)}$$

Свойства модели KNN

АспектОписание
ОбучениеОтсутствует в классическом смысле: модель запоминает всю выборку
ПредсказаниеВычислительно затратно: расстояния считаются до всех запомненных объектов
ПараметрыОтсутствуют: модель не хранит обученных чисел
Гиперпараметры\(K\) (количество соседей), тип метрики расстояния, стратегия взвешивания
ИнтерпретируемостьВысокая: решение принимается по конкретным соседним объектам

Метод FRiS-Stolp для отбора эталонов

Для сокращения вычислительной сложности можно оставить подмножество наиболее информативных объектов — эталонов (столпов).

Критерий качества эталона:
Эталон считается хорошим при выполнении двух условий:

  • Объекты его класса лежат как можно ближе к нему
  • Объекты чужих классов лежат как можно дальше

Функция FRiS: $$\text{FRiS}(z, a_i, b_i) = \frac{r_2 - r_1}{r_2 + r_1}$$

где \(r_1\) — расстояние до ближайшего объекта своего класса, \(r_2\) — до ближайшего объекта чужого класса.


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


Линейная регрессия

Постановка задачи

Линейная регрессия решает задачу предсказания непрерывной целевой переменной \(y\) по одной или нескольким входным переменным (признакам) \(x\), измеренным для того же объекта.

В простейшем одномерном случае модель имеет вид

$$\hat{y} = w_1 x + w_0$$

где \(w_1\) означает вес признака (наклон прямой), а \(w_0\) — смещение (bias, свободный член).

Модель предполагает, что истинная зависимость имеет вид

$$y = w_1 x + w_0 + \varepsilon$$

где \(\varepsilon\) — случайная ошибка (шум).

Цель обучения: найти такие параметры \(w_0, w_1, \dots, w_k\), чтобы предсказания модели \(\hat{y}\) оказались максимально близки к значениям \(y\) обучающей выборки.

Функции ошибки

  • SSE (Sum of Squared Errors), сумма квадратов ошибок, $$\text{SSE} = \sum_{i=1}^{N} (y_i - \hat{y}_i)^2 = |\text{error}|_2^2$$

  • MSE (Mean Squared Error), среднеквадратичная ошибка, $$\text{MSE} = \frac{1}{N} \sum_{i=1}^{N} (y_i - \hat{y}_i)^2$$

  • MAE (Mean Absolute Error), средняя абсолютная ошибка, $$\text{MAE} = \frac{1}{N} \sum_{i=1}^{N} |y_i - \hat{y}_i|$$

В линейной регрессии чаще используется MSE, дифференцируемая всюду и приводящая к аналитическому решению.

Матричная форма

Для многомерного случая (\(k\) признаков) модель записывается как

$$y_i = w_1 x_{1,i} + w_2 x_{2,i} + \dots + w_k x_{k,i} + w_0$$

Смещение \(w_0\) включается в вектор весов путём добавления фиктивного признака \(x_0 = 1\) ко всем объектам.

В матричной форме модель имеет следующий вид.

$$\mathbf{y} = \mathbf{X} \mathbf{w}$$

где

  • \(\mathbf{y} = \begin{bmatrix} y_1 \ \vdots \ y_N \end{bmatrix}\) — вектор целевых значений,
  • \(\mathbf{X} = \begin{bmatrix} x_{1,1} & \cdots & x_{1,k} & 1 \ \vdots & \ddots & \vdots & \vdots \ x_{N,1} & \cdots & x_{N,k} & 1 \end{bmatrix}\) — матрица объектов-признаков (с добавленным столбцом единиц),
  • \(\mathbf{w} = \begin{bmatrix} w_1 \ \vdots \ w_k \ w_0 \end{bmatrix}\) — вектор весов.

Оптимизационная задача:

$$\hat{\mathbf{w}} = \arg\min_{\mathbf{w}} |\mathbf{X}\mathbf{w} - \mathbf{y}|_2^2$$

Аналитическое решение методом наименьших квадратов (МНК)

Квадратичная функция потерь записывается следующим образом.

$$Q(\mathbf{w}) = |\mathbf{y} - \mathbf{X}\mathbf{w}|_2^2 = (\mathbf{y} - \mathbf{X}\mathbf{w})^T (\mathbf{y} - \mathbf{X}\mathbf{w})$$

Для нахождения минимума приравняем градиент к нулю.

$$\nabla_{\mathbf{w}} Q(\mathbf{w}) = -2\mathbf{X}^T\mathbf{y} + 2\mathbf{X}^T\mathbf{X}\mathbf{w} = 0$$

Отсюда получаем аналитическое решение.

$$\hat{\mathbf{w}} = (\mathbf{X}^T \mathbf{X})^{-1} \mathbf{X}^T \mathbf{y}$$

Проблемы аналитического решения

На практике этой формулой почти не пользуются по двум причинам.

  1. Матрица \(\mathbf{X}^T \mathbf{X}\) может быть необратима.

    • При линейной зависимости признаков (коллинеарность),
    • Когда число признаков \(k\) превышает число объектов \(N\) (бесконечное множество решений).
  2. Вычислительная сложность. Обращение матрицы имеет сложность \(O(k^3)\); при большом числе признаков это недопустимо долго.

Градиентный спуск

Когда аналитическое решение неприменимо или неэффективно, применяется итеративный метод оптимизации — градиентный спуск.

Алгоритм:

  1. Инициализировать веса случайными значениями \(\mathbf{w} \gets \text{random}()\)
  2. Повторять до сходимости $$\mathbf{w} \gets \mathbf{w} - \alpha \nabla_{\mathbf{w}} Q(\mathbf{w})$$

где

  • \(\alpha\) — скорость обучения (learning rate),
  • \(\nabla_{\mathbf{w}} Q(\mathbf{w})\) — градиент функции потерь.

Для функции потерь MSE градиент вычисляется как

$$\nabla_{\mathbf{w}} Q(\mathbf{w}) = \frac{2}{N} \mathbf{X}^T (\mathbf{X}\mathbf{w} - \mathbf{y})$$

Варианты градиентного спуска

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

  • Полный градиентный спуск (Batch GD): градиент вычисляется по всей выборке.
  • Стохастический градиентный спуск (SGD). Градиент вычисляется по одному случайному объекту.
  • Мини-батч градиентный спуск. Градиент вычисляется по небольшой случайной подвыборке (батчу).

Проблемы и улучшения

  • Локальные минимумы. Для выпуклых функций (как MSE) проблемы нет: любой локальный минимум является глобальным.
  • Выбор скорости обучения \(\alpha\).
    • Слишком большая \(\alpha\) приводит к расходимости алгоритма,
    • Слишком маленькая \(\alpha\) делает обучение слишком медленным.
    • Решением является адаптивное уменьшение \(\alpha\) по мере обучения.
  • Momentum (инерция). $$\mathbf{v} \gets \beta \mathbf{v} + \alpha \nabla_{\mathbf{w}} Q(\mathbf{w}), \quad \mathbf{w} \gets \mathbf{w} - \mathbf{v}$$ где \(\beta \in (0, 1)\) — коэффициент инерции. Скорость, накопленная на предыдущих шагах, ускоряет сходимость и позволяет «проскакивать» пологие участки.

Проблемы линейной регрессии и их решения

1. Отсутствие линейной зависимости

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

Пример. Предсказание стоимости склада по длине и ширине. Линейная модель $$\text{цена} = w_1 \cdot \text{длина} + w_2 \cdot \text{ширина} + w_0$$ не учитывает, что значение имеет площадь (\(\text{длина} \times \text{ширина}\)). Решением является новый признак «площадь», произведение сторон.

2. Коллинеарность признаков

Если признаки линейно зависимы (\(x_2 = 2x_1\)), веса становятся неустойчивыми и неинтерпретируемыми.

$$y = 6x_1 + 8x_2 + 5 = 0x_1 + 11x_2 + 5 = 22x_1 + 0x_2 + 5 = \dots$$

Решение. Удаление признаков, дублирующих друг друга (анализ корреляции, методы отбора признаков).

3. Выбросы

Выбросы сильно влияют на MSE из-за квадратичного штрафа.

Решения.

  • Фильтрация выбросов на этапе предобработки,
  • Использование более устойчивых функций потерь (например, MAE или Huber loss).

4. Шумные признаки и переобучение

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

Решением является регуляризация.

Регуляризация

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

L2-регуляризация (Ridge)

$$Q_{\text{reg}}(\mathbf{w}) = \frac{1}{N} |\mathbf{X}\mathbf{w} - \mathbf{y}|_2^2 + \lambda |\mathbf{w}|_2^2$$

  • «Выравнивает» веса, уменьшая их по модулю,
  • Даёт более стабильное решение на линейно связанных признаках,
  • Аналитическое решение имеет вид \(\hat{\mathbf{w}} = (\mathbf{X}^T \mathbf{X} + N\lambda \mathbf{I})^{-1} \mathbf{X}^T \mathbf{y}\). Множитель \(N\) появляется из-за \(1/N\) при первом слагаемом; если записывать потери без нормировки на объём выборки, останется просто \(\lambda \mathbf{I}\).

L1-регуляризация (Lasso)

$$Q_{\text{reg}}(\mathbf{w}) = \frac{1}{N} |\mathbf{X}\mathbf{w} - \mathbf{y}|_2^2 + \lambda |\mathbf{w}|_1$$

  • Также уменьшает веса,
  • Обнуляет веса нерелевантных признаков, выполняя автоматический отбор признаков.

Параметр регуляризации \(\lambda\)

  • при \(\lambda = 0\) получается обычная линейная регрессия,
  • при \(\lambda \to \infty\) все веса стремятся к нулю,
  • Оптимальное значение \(\lambda\) подбирается на отложенной валидационной выборке.

Параметры и гиперпараметры

ТипПримеры
Параметры моделиВеса \(\mathbf{w}\), найденные в процессе обучения
ГиперпараметрыВеличины, заданные до обучения: скорость \(\alpha\), тип и коэффициент регуляризации \(\lambda\), начальное приближение, использование momentum, размер батча

Оценка качества модели

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

  1. Метрики ошибки. MSE, MAE на тестовой выборке.
  2. Базовое сравнение. Сравнение с «наивной» моделью \(\hat{y} = \text{mean}(\mathbf{y})\).
  3. Разделение выборки. Разбиение на обучающую и тестовую части (train/test split).
  4. Кросс-валидация. Более надёжная оценка, усреднённая по нескольким разбиениям.

В следующих разделах рассматриваются логистическая регрессия (классификация), метрики качества для задач классификации и деревья решений.


Логистическая регрессия и деревья решений

В настоящем разделе рассматриваются два метода классификации, логистическая регрессия и деревья решений, и метрики качества классификатора. Доля правильных ответов (accuracy) может вводить в заблуждение, и полагаться на неё в одиночку нельзя.

Линейные классификаторы и логистическая регрессия

От линейной функции к вероятности

Линейный классификатор строит решающую границу в виде гиперплоскости: для объекта с признаками \(x\) модель вычисляет линейную комбинацию

$$ f(x) = w_1 x_1 + w_2 x_2 + ... + w_0 = w^T x $$

В простейшем случае класс определяется знаком функции. $$ \hat{y}_i = \text{sign}(f(x_i)) = \begin{cases} 1, & f(x_i) \geq 0 \ -1, & f(x_i) < 0 \end{cases} $$

Для многих задач требуется вероятность принадлежности к классу, а \(f(x)\) лежит в диапазоне \((-\infty, +\infty)\), тогда как вероятность \(p\) должна лежать в диапазоне \([0, 1]\).

Линейный отклик преобразуется в вероятность следующим образом.

  1. Рассмотрим вероятность положительного класса \(p_+ = P(y=1|x)\).
  2. Преобразуем вероятность в шанс (Odds Ratio). $$ OR = \frac{p_+}{1 - p_+} \in [0, +\infty) $$
  3. Прологарифмируем шанс, чтобы получить диапазон \((-\infty, +\infty)\). $$ \log(OR) = \log\left(\frac{p_+}{1 - p_+}\right) = f(x) $$

Выразим вероятность \(p_+\) из этого уравнения. $$ \frac{p_+}{1 - p_+} = e^{f(x)} \implies p_+ = e^{f(x)} - p_+ e^{f(x)} \implies p_+(1 + e^{f(x)}) = e^{f(x)} $$

Итоговая формула для вероятности (сигмоида). $$ p_+ = \frac{e^{f(x)}}{1 + e^{f(x)}} = \frac{1}{1 + e^{-f(x)}} = \sigma(f(x)) $$

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

Понятие отступа (Margin)

Для анализа качества классификации вводится отступ (margin) \(i\)-го объекта.

$$ M_i = y_i f(x_i) = y_i w^T x_i $$

где \(y_i \in {-1, +1}\) — истинная метка класса.

Интерпретация отступа:

  • при \(M_i > 0\) объект классифицирован верно (\(y_i = \hat{y}_i\))
  • при \(M_i \leq 0\) объект классифицирован неверно (\(y_i \neq \hat{y}_i\))

Примеры:

\(y_i\)\(f(x_i)\)\(M_i = y_i f(x_i)\)Результат
+1+6+6Верно, уверенно
+1-6-6Ошибка
-1-6+6Верно, уверенно
-1+6-6Ошибка

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

Задача оптимизации через Log Loss

Для обучения модели необходимо подобрать веса \(w\), максимизирующие правдоподобие данных, в предположении, что объекты независимы и одинаково распределены (i.i.d.).

Вероятность верного предсказания на объекте \(i\) с меткой \(y_i \in {-1, 1}\). $$ P(y_i | x_i, w) = \sigma(y_i w^T x_i) = \sigma(M_i) $$

Функция правдоподобия для всей выборки имеет вид $$ P(Y | X, w) = \prod_{i=1}^{N} \sigma(y_i w^T x_i) \to \max $$

Для удобства оптимизации перейдём к логарифму правдоподобия. $$ \sum_{i=1}^{N} \log \sigma(y_i w^T x_i) \to \max $$

Подставив определение сигмоиды \(\sigma(z) = \frac{1}{1 + e^{-z}}\), получим $$ \sum_{i=1}^{N} \log \frac{1}{1 + e^{-y_i w^T x_i}} = - \sum_{i=1}^{N} \log (1 + e^{-y_i w^T x_i}) \to \max $$

Задача максимизации логарифма правдоподобия равносильна минимизации функции потерь Log Loss. $$ \mathcal{L}(w) = \sum_{i=1}^{N} \log (1 + e^{-y_i w^T x_i}) = \sum_{i=1}^{N} \log (1 + e^{-M_i}) \to \min $$

Связь с отступом: Функция log loss штрафует малые и отрицательные отступы: при \(M_i \to +\infty\) потери стремятся к нулю, а при \(M_i \to -\infty\) растут линейно.

Log loss минимизируется тем же градиентным спуском, что и MSE в линейной регрессии, а защита от переобучения обеспечивается той же регуляризацией, L1 или L2.


Метрики качества классификации

Выбор метрики важен, особенно при сильном дисбалансе классов.

Accuracy и её ограничения

Accuracy (доля правильных ответов). $$ \text{Accuracy} = \frac{\text{Число верных предсказаний}}{\text{Общее число объектов}} $$

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

  • Пример с диагностикой редкой болезни.
    • 100 000 здоровых, 10 больных.
    • Если классификатор всегда предсказывает «здоров», Accuracy = \(100000 / 100010 \approx 99.99%\).
    • Модель бесполезна: она не находит ни одного больного.

Матрица ошибок (Confusion Matrix)

Для детального анализа используется матрица ошибок, сводящая предсказания и реальность в четыре числа.

Предсказано: 1 (Больной)Предсказано: 0 (Здоровый)
Реально: 1 (Больной)TP (True Positive)FN (False Negative)
Реально: 0 (Здоровый)FP (False Positive)TN (True Negative)
  • TP — число больных, отнесённых к больным.
  • TN — число здоровых, отнесённых к здоровым.
  • FP — число здоровых, отнесённых к больным (ошибка I рода).
  • FN — число больных, отнесённых к здоровым (ошибка II рода).

$$ \text{Accuracy} = \frac{TP + TN}{TP + TN + FP + FN} $$

Precision и Recall

Для задач с дисбалансом классов чаще используются Precision и Recall, вычисленные по положительному классу.

  1. Precision (точность) показывает, какая доля объектов, отнесённых к положительным, действительно положительна. $$ \text{Precision} = \frac{TP}{TP + FP} $$
  2. Recall (полнота) показывает, какую долю объектов, действительно относящихся к положительному классу, модель обнаружила. $$ \text{Recall} = \frac{TP}{TP + FN} $$

Пример 1. Модель нашла 1 больного из 10 и не ошиблась на здоровых.

  • Precision = \(1 / (0 + 1) = 1\) (все найденные действительно больные).
  • Recall = \(1 / (1 + 9) = 0.1\) (найдена лишь десятая часть больных).

Пример 2. Поменяем целевой класс и будем искать здоровых (100 000 здоровых, 10 больных).

  • Precision = \(100000 / (100000 + 9) \approx 0.99991\)
  • Recall = \(100000 / (100000 + 0) = 1\)

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

F-мера (F-score)

Для баланса между Precision и Recall используется гармоническое среднее, \(F_\beta\)-мера.

$$ F_\beta = (\beta^2 + 1) \frac{\text{Precision} \times \text{Recall}}{\beta^2 \text{Precision} + \text{Recall}} $$

Наиболее популярна F1-мера (\(\beta = 1\)). $$ F_1 = 2 \frac{\text{Precision} \times \text{Recall}}{\text{Precision} + \text{Recall}} $$

ROC-кривая и AUC

Многие классификаторы (включая логистическую регрессию) выдают вероятность \(p_+ \in [0, 1]\), и порог, отделяющий один класс от другого, можно варьировать.

  • TPR (True Positive Rate) совпадает с Recall. $$ \text{TPR} = \frac{TP}{TP + FN} $$
  • FPR (False Positive Rate) — доля здоровых, ошибочно отнесённых к больным. $$ \text{FPR} = \frac{FP}{FP + TN} $$

ROC-кривая (Receiver Operating Characteristic) показывает зависимость TPR от FPR при всех порогах.

AUC (Area Under Curve) — площадь под ROC-кривой.

  • AUC = 1 соответствует идеальному классификатору.
  • AUC = 0.5 соответствует случайному угадыванию.
  • Чем больше AUC, тем лучше классификатор ранжирует объекты (отделяет положительный класс от отрицательного).

Деревья решений (Decision Trees)

Дерево решений — непараметрический метод, строящий цепочку правил, применяемых к объекту последовательно.

Принцип построения

Предположим, что имеется один признак, по которому объекты сортируются и выбирается порог \(t\), разделяющий выборку на две части, \(L\) (left) и \(R\) (right).

Формально для признака \(x_i\) и порога \(t_j\) это записывается следующим образом. $$ Q \xrightarrow{x_i < t_j} \begin{cases} L \ R \end{cases} $$

Разделение должно уменьшить разнородность (гетерогенность) в дочерних узлах; его качество оценивается функцией $$ G(x_i, t_j) = \frac{|L|}{|Q|} H(L) + \frac{|R|}{|Q|} H(R) $$ где \(H(R)\) — функция неопределённости (impurity) в узле.

Критерии неопределённости

Пусть \(p_0\) и \(p_1\) — доли объектов классов 0 и 1 в узле.

  1. Misclassification (доля ошибок). $$ H(R) = 1 - \max(p_0, p_1) $$
  2. Entropy (энтропия). $$ H(R) = -p_0 \log_2 p_0 - p_1 \log_2 p_1 = - \sum_k p_k \log_2 p_k $$
  3. Gini (индекс Джини). $$ H(R) = 1 - p_0^2 - p_1^2 = 1 - \sum_k p_k^2 $$

Критерии остановки (регуляризация)

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

  • Ограничить максимальную глубину дерева.
  • Ограничить минимальное количество объектов в узле, разрешённое для дальнейшего деления.
  • Ограничить минимальное количество объектов в листе.
  • Pruning (обрезка). Построить большое дерево, а затем удалить ветви, не дающие прироста качества на валидации.

Плюсы и минусы деревьев решений

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

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

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


Итоги по классификации

Линейная модель переходит от метки к вероятности; её качество оценивается метриками Precision, Recall, F1 и ROC-AUC. Ключевым понятием является отступ (margin), связывающий линейный отклик модели с качеством классификации и лежащий в основе log loss.

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


Деревья. Случайный лес. Градиентный бустинг

Введение

Одиночное дерево нестабильно и склонно к переобучению, тогда как ансамбли деревьев на протяжении многих лет удерживают первое место на табличных данных. В настоящем разделе рассматриваются регрессионные деревья, разложение ошибки на смещение и разброс (bias-variance) и два способа объединения моделей: бэггинг (Bagging) со случайным лесом (Random Forest) и бустинг (Boosting), включая градиентный.

1. Деревья решений

Регрессия

Для регрессии критерий разбиения тот же, меняется только мера неоднородности в узле.

  • \(G_{x_i, t_j} = L_Q H_L + R_Q H_R\)
  • MSE (среднеквадратичная ошибка) даёт \(H_R = \frac{1}{N} \sum_{i}^{N} (y_m - y_i)^2\), где \(y_m\) — среднее значение.
  • MAE (средняя абсолютная ошибка) даёт \(H_R = \frac{1}{N} \sum_{i}^{N} |y_m - y_i|\), где \(y_m\) — медиана.

Оптимизация

  • Сложность подготовки составляет \(O(N \cdot \log N \cdot d)\), где \(d\) — количество признаков, \(N\) — количество объектов: все признаки необходимо отсортировать перед вычислением ошибки на каждом разбиении.
  • Сложность ошибки составляет \(O(N)\).
  • Сложность нахождения оптимального разбиения составляет \(O(N^2 d)\).

Пересчёт ошибок можно ускорить: переход от суммы квадратов отклонений для \(N\) объектов к сумме для \(N-1\) объектов выполняется за \(O(1)\) по формулам преобразования сумм. Применяются и эвристики, например случайный набор признаков в каждой вершине.

2. Смещение и Разброс (Bias-Variance)

Разложение ошибки

Рассмотрим модель зависимости истинных значений от функции. $$y = f(x) + \varepsilon$$ Здесь

  • \(y\) — истинные значения;
  • \(f(x)\) — закон природы;
  • \(\varepsilon\) — ошибка измерения (\(\varepsilon \in N(0, \sigma^2)\), математическое ожидание \(M\varepsilon = 0\)).

Таким образом, \(y \in N(f(x), \sigma^2)\), математическое ожидание \(My = f(x)\).

Рассмотрим семейство функций-регрессоров \(b = b(x)\), приближающих \(f(x)\). В конкретной точке множество регрессоров даст множество значений, а у \(b(x)\) имеются математическое ожидание и дисперсия.

Разложение среднеквадратичной ошибки (MSE) имеет следующий вид. $$MSE = M(y - b)^2 = M(y^2) + M(b^2) - 2M(by)$$

Используя свойства дисперсии \(Var(x) = Dx = M(x - Mx)^2 = Mx^2 - M[x]^2\), получаем следующее.

  1. \(My^2 = Dy + M[y]^2 = \sigma^2 + f^2\)
  2. \(Mb^2 = Db + M[b]^2\)
  3. \(Mby = M(f + \varepsilon)b = Mfb + M\varepsilon b = fMb + M\varepsilon Mb = fMb\)

Подставляя в формулу MSE, получаем $$MSE = \sigma^2 + f^2 + Db + M[b]^2 - 2fM[b] = (f - M[b])^2 + Db + \sigma^2$$

Здесь

  • \((f - M[b]) = \text{bias}\) (смещение);
  • \(Db = \text{variance}\) (разброс);
  • \(\sigma^2\) — неустранимая ошибка.

Итоговое разложение имеет следующий вид. $$MSE = \text{bias}^2 + \text{variance} + \sigma^2$$

Влияние сложности модели

Два слагаемых разложения действуют в противоположных направлениях: сложная модель подстраивается под конкретную выборку и накапливает variance, простая не достигает закона природы и накапливает bias. Между этими крайностями приходится выбирать; неустранимую \(\sigma^2\) не компенсирует ни одна модель, поскольку это шум измерения в самих данных.

3. Ансамбли моделей

Мудрость толпы

На ярмарке разыгрывалась лотерея, в которой требовалось на глаз угадать вес быка. Участвовало около 800 человек, бык весил 1198 фунтов, точно не угадал никто, а среднее арифметическое всех догадок составило 1197 фунтов.

Каждый участник ошибался в свою сторону, опираясь на собственный «независимый» опыт, и при усреднении ошибки компенсировали друг друга. Так же ведёт себя случайная погрешность, усреднённая по серии измерений; на этом основана идея ансамблей.

Бэггинг (Bagging)

Предсказания нескольких классификаторов или регрессоров усредняются, а чтобы обеспечить моделям разный «опыт», их обучают на разных данных. Подвыборки формируются методом Bootstrap: элементы общей выборки выбираются случайно, и один и тот же элемент может попасть в подвыборку несколько раз.

Пусть \(b_1(x), ..., b_n(x)\) — модели регрессии, обученные на разных подвыборках, \(f(x)\) — функция истинных значений. Квадратичная ошибка \(i\)-й модели равна \(\varepsilon_i^2(x) = (b_i(x) - f(x))^2\). Если усреднить ошибку всех моделей, получим \(\frac{1}{n} M_x [\sum \varepsilon_i^2(x)]\).

Предположим, что ошибки несмещены (\(M_x \varepsilon_i(x) = 0\)) и некоррелированы (\(M_x \varepsilon_i(x)\varepsilon_j(x) = 0\) при \(i \neq j\)). Построим новую модель. $$a(x) = \frac{1}{n} \sum_{i}^{n} b_i(x)$$

Тогда среднеквадратичная ошибка новой модели равна $$M_x \left( \frac{1}{n} \sum_{i}^{n} b_i(x) - f(x) \right)^2 = M_x \left( \frac{1}{n} \sum_{i}^{n} \varepsilon_i(x) \right)^2 = \frac{1}{n^2} M_x \sum_{i}^{n} \varepsilon_i^2(x)$$

Вывод. Бэггинг снижает variance в ошибке (то есть ослабляет переобучение) примерно в \(n\) раз, если считать ошибки некоррелированными. Модели «в среднем» ошибаются одинаково, но в разные стороны, а выбросы попадают лишь в часть подвыборок.

Случайный лес (Random Forest)

Метод развивает идею бэггинга применительно к деревьям.

  1. Деревья обучаются на разных подвыборках.
  2. Для повышения некоррелированности деревьев при построении узла наилучшее разбиение ищется не по всем признакам, а лишь по случайно выбранной их части.

Гиперпараметры леса: число деревьев плюс гиперпараметры одного дерева. Ответы деревьев объединяются усреднением в регрессии и голосованием большинства в классификации.

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

Платой являются прозрачность и ресурсы: лес из сотен деревьев не поддаётся визуальному прочтению, требует большого объёма памяти, плохо обрабатывает разреженные признаки и, как и одиночное дерево, не экстраполирует за пределы обучающих данных.

4. Бустинг

AdaBoost и решающие пни

В бустинге объекты, на которых ансамбль ошибся, получают больший вес на следующем шаге, и каждая новая модель в первую очередь исправляет ошибки предшественников. Базовые модели намеренно выбираются слабыми, часто это «пни» (decision stumps), деревья глубиной 1.

Градиентный бустинг на примере

Задача. Предсказать цену товара по возрасту (в днях). Предположим, что имеется только один признак.

Шаг 1. Начальное приближение — среднее по всей выборке. Цены равны 700, 800, 600, 400, 300. Среднее даёт \((700+800+600+400+300) / 5 = 560\).

Вычислим остатки — разность цены и приближения.

Возраст (дней)ЦенаПриближениеОстаток
25700560140
50800560240
7560056040
80400560-160
100300560-260

Шаг 2. Построим регрессионное дерево, предсказывающее остатки. Разбиение выполняется по признаку Возраст >= 80.

  • Ветка 1 (Age < 80) даёт остатки 140, 240, 40 со средним 140.
  • Ветка 2 (Age >= 80) даёт остатки -160, -260 со средним -210.

Шаг 3. Прибавим предсказание дерева к приближению (можно с коэффициентом, в примере без него).

ВозрастЦенаПриближение №1Остаток №1Выход дерева №1Приближение №2Остаток №2
257005601401407000
50800560240140700100
7560056040140700-100
80400560-160-21035050
100300560-260-210350-50

Далее процедура повторяется: строится следующее дерево, корректируется приближение, вычисляются новые остатки.

Формализация градиентного бустинга

На \(t\)-м шаге модель представляет собой сумму функций, накопленных к этому моменту. $$f(x) = \sum_{i=0}^{t-1} f_i(x)$$

Параметры нового шага находятся через минимизацию функции потерь \(L\). $$\rho_t, \theta_t = \arg\min_{\rho, \theta} M_{x,y} [L(y, f(x) + \rho h(x, \theta))]$$ где \(f_t(x) = \rho_t h(x, \theta_t)\).

Остаток на \(i\)-м элементе, он же градиент, равен $$r_{i,t} = - \left[ \frac{\nabla L(y_i, f(x_i))}{\nabla f(x_i)} \right]$$ Этот остаток является целью дерева, строящегося на следующем шаге.

  1. Параметры дерева находятся как \(\theta_t = \arg\min_{\theta} \sum_{i}^{n} (r_{i,t} - h(x_i, \theta))^2\)
  2. Параметры «линейной регрессии», то есть шага, находятся как \(\rho_t = \arg\min_{\rho} \sum_{i}^{n} L(y_i, f(x_i) + \rho h(x_i, \theta_t))\)

Случай MSE. Для среднеквадратичной ошибки остаток равен разнице между истинным значением и предсказанием. $$r_{i,t} = - \frac{\nabla L}{\nabla f} = 2(y_i - f(x_i)) \rightarrow \text{разница между истинным значением и предсказанием}$$

Сравнение градиентного бустинга и бэггинга

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

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

Поэтому деревья в бустинге намеренно делают неглубокими, три-четыре уровня, не больше. Сила ансамбля заключается в количестве деревьев, а не в сложности каждого.

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


Понижение размерности. Кластеризация

В настоящем разделе рассматриваются две задачи обучения без учителя. Первая — сжать признаковое пространство, сохранив структуру данных; для этого применяются метод главных компонент (PCA) и t-SNE. Вторая — разделить на группы объекты, не размеченные заранее; здесь используются k-средних, иерархическая кластеризация и DBSCAN.

1. Понижение размерности

Основная идея

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

Метод главных компонент (PCA)

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

Основная концепция:

  1. Предполагается, что облако данных описывается эллипсом, вытянутым вдоль главных направлений.
  2. Меняется базис пространства.
  3. Отрезается «новая ось» с минимальной дисперсией.
  4. Чтобы вычислить «потерянную информацию», дисперсия отрезанной оси делится на сумму дисперсий по всем осям.

Вычисления. Ковариация признаков вычисляется по формуле $$cov(X_i, X_j) = M[(X_i - M[X_i])(X_j - M[X_j])] = M[X_i X_j] - M[X_i]M[X_j]$$

Выборку можно сместить так, чтобы математическое ожидание \(M[X_i] = 0\). В матричном виде ковариация записывается следующим образом. $$cov(X) = \frac{1}{N} X^T X$$

Свойства.

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

Ограничения PCA.

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

t-SNE (t-Distributed Stochastic Neighbor Embedding)

Механическая аналогия.

  1. Данные из пространства размерности \(N\) помещаются в пространство меньшей размерности \(M\) (отображение).
  2. Объекты «прибиваются гвоздями» к своим местам.
  3. Объекты в пространстве \(M\) соединяются пружинами, сила которых зависит от того, насколько расстояние в пространстве \(M\) отличается от расстояния в пространстве \(N\).
  4. Система «отпускается» (гвозди убираются).

Динамика.

  • Если точки в пространстве \(M\) дальше, чем в пространстве \(N\), то они притягиваются.
  • Если точки в пространстве \(M\) ближе, чем в пространстве \(N\), то они отталкиваются.

Математическое описание. Пусть \(|x_i - x_j|\) — расстояние в исходном пространстве, \(|y_i - y_j|\) — расстояние в пространстве отображения.

Условное сходство через Гауссово распределение записывается следующим образом. $$p_{j|i} = \frac{e^{-|x_i - x_j|^2 / 2\sigma_i^2}}{\sum_{k \neq i} e^{-|x_i - x_k|^2 / 2\sigma_i^2}}$$ где \(\sigma_i^2\) — дисперсия распределения Гаусса вокруг точки \(x_i\), у каждой точки своя, подобранная под плотность окрестности.

Симметричное сходство даёт $$p_{ji} = \frac{p_{j|i} + p_{i|j}}{2N}$$

В пространстве меньшей размерности используется не Гауссово распределение, а распределение Стьюдента с одной степенью свободы (t-distribution), утяжелённое на хвостах. $$q_{ij} = \frac{t(|y_i - y_j|)}{\sum_{k \neq l} t(|y_k - y_l|)}, \text{ где } t(x) = \frac{1}{1 + x^2}$$

Нормировка выполняется по всем парам сразу, а не по соседям одной точки, поскольку только так \(Q\) остаётся симметричным совместным распределением, как и \(P\).

Оптимизация. Матрица сходства в отображении приближается к матрице исходного пространства путём минимизации расстояния Кульбака-Лейблера. $$KL(P||Q) = \sum_{i,j} p_{ij} \log \frac{p_{ij}}{q_{ij}} \to \min$$

Минимизация выполняется градиентным спуском, а сам градиент даёт равнодействующую всех сил, приложенных к точке. $$\frac{\partial KL(P||Q)}{\partial y_i} = 4 \sum_j (p_{ij} - q_{ij}),(y_i - y_j),\bigl(1 + |y_i - y_j|^2\bigr)^{-1}$$ Все вычисления выполняются в пространстве отображения: разность \(y_i - y_j\) задаёт направление силы, множитель \(t(|y_i - y_j|)\) — её величину. Исходные координаты \(x\) в градиент не входят, поскольку они уже учтены в \(p_{ij}\).

Обоснование выбора t-distribution. При понижении размерности максимальные расстояния «меняются», и если использовать распределение Гаусса, то точки на плоскости расположатся очень скученно (crowding problem).

Особенности t-SNE.

  • Работает со сложными структурами.
  • Новые данные спроецировать невозможно: требуется либо полное переобучение, либо дополнительные приёмы (например, k-nn).
  • Вычисления длительны. Можно использовать не все точки, а только ближайших соседей.
    • Если брать мало соседей, t-SNE больше учитывает локальные паттерны.
    • Если брать много соседей, t-SNE больше учитывает глобальные паттерны.

2. Кластеризация

k-средних (k-means)

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

Алгоритм.

  1. Задать количество кластеров.
  2. Случайным образом разместить точки (центроиды) в пространстве.
  3. Для каждой точки определить, к какому центроиду она ближе.
  4. Переместить каждый центроид в «центр масс» точек, приписанных ему на предыдущем шаге.
  5. Повторять пункты 3-4 фиксированное число раз, или пока перемещение центроидов не станет достаточно малым (алгоритм сойдётся).

Особенности и ограничения:

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

Агломеративная (иерархическая) кластеризация

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

Алгоритм «снизу-вверх» устроен следующим образом.

  1. Сначала каждая точка образует центр своего кластера.
  2. Вычисляются попарные расстояния между центрами кластеров.
  3. Пара ближайших кластеров объединяется в новый, и центр пересчитывается.
  4. Пункты 2-3 повторяются, пока все точки не будут объединены в один кластер.

DBSCAN

DBSCAN является единственным из трёх методов, помечающим точки, не относящиеся ни к одному кластеру. Кластеры он ищет как области повышенной плотности, поэтому находит кластеры произвольной формы, а их число определяет самостоятельно. Явно выделенные выбросы являются готовым инструментом поиска аномалий в данных с установки.

У метода два гиперпараметра, радиус окрестности \(\varepsilon\) и минимальное число точек \(m\) в этом радиусе, причём сама точка тоже учитывается (в scikit-learn это eps и min_samples). Соседями точки считаются все точки, которые лежат от неё не дальше \(\varepsilon\).

Алгоритм.

  1. Выбирается случайная точка.
  2. Если у неё меньше \(m\) точек в окрестности радиуса \(\varepsilon\), то она помечается как потенциальный выброс (noise point), и выбирается другая точка.
  3. Если точек в окрестности радиуса \(\varepsilon\) не меньше \(m\), выполняется следующее.
    • Заводится новый кластер, и точка (core point) помещается в него.
    • Если сосед оказался потенциальным выбросом или у него мало соседей, то это край кластера (border point). Он заносится в кластер, и осуществляется переход к другому соседу.
    • Если у соседа достаточно собственных соседей, то он добавляется в кластер (core point), а его соседи заносятся в очередь обхода.
  4. Пункты 1-3 повторяются.
  5. Точки, так и не попавшие ни в один кластер, остаются выбросами, и это не «ещё один кластер». В scikit-learn они получают метку \(-1\), и общего между ними только то, что плотности вокруг каждой не хватило.

Выбор между тремя методами осуществляется следующим образом: если число кластеров известно и ожидаются компактные группы — k-средних; если число неизвестно, а выборка небольшая — агломеративная; если кластеры имеют сложную форму или необходимо отделить выбросы — DBSCAN.

Итоги по понижению размерности и кластеризации

Обе задачи решаются без учителя и необходимы, когда о данных ещё ничего не известно. PCA и t-SNE позволяют визуально оценить многомерную выборку; k-средних, агломеративная кластеризация и DBSCAN разделяют её на группы. Размеченного правильного ответа здесь нет, и сверять результат приходится с физическим смыслом.

Нейронным сетям посвящена следующая глава.

Нейронные сети

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

В настоящей главе рассматриваются математическая модель нейрона и функции активации, построение из нейронов многослойной сети и её обучение методом обратного распространения ошибки (backpropagation), а далее — оптимизаторы (SGD, Adam, RMSProp), нормализация данных и весов и регуляризация, позволяющая бороться с переобучением. Материал, изложенный здесь в одной главе, подробно рассмотрен у Гудфеллоу [28], а доведён до работающего кода — у Жерона [29].

1. Нейрон и функции активации

Математический нейрон

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

Рассмотрим пример, в котором по трём признакам необходимо предсказать, сдаст ли студент экзамен.

  • \(X_1\) показывает, задавал ли студент вопросы на лекциях (1/0).
  • \(X_2\) показывает, попал ли он к лектору на экзамене (1/0).
  • \(X_3\) показывает, сдал ли он лабораторные работы (1/0).

Логика задачи следующая.

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

Модель нейрона вычисляет взвешенную сумму входов. $$S = W_1X_1 + W_2X_2 + W_3X_3$$

Выход нейрона \(Y\) определяется пороговой функцией активации. $$f(x) = \begin{cases} 1, & x \ge 0.5 \ 0, & x < 0.5 \end{cases}$$

Для такой простой логики подходят веса \(W_1 = 0.5, W_2 = -0.5, W_3 = 0.5\). Тогда для входа \((1, 1, 1)\) получаем \(0.5 \cdot 1 - 0.5 \cdot 1 + 0.5 \cdot 1 = 0.5 \ge 0.5 \rightarrow\) сдал (1).

Проблема одного слоя

Один слой проводит в пространстве признаков единственную разделяющую плоскость. Поэтому задачу, в которой ответ зависит от комбинации признаков, а не от каждого по отдельности (например, экзамен сдают те, кто либо задавал вопросы, либо сдал лабораторные работы, но не то и другое одновременно — это XOR, и никакая прямая \(W_1X_1 + W_3X_3 = c\) такие классы не разделит), персептрон не решает.

Скрытые слои

Скрытые слои располагаются между входом и выходом. Слой \(Z\) строит новое признаковое пространство, в котором прежде неразделимая задача решается одной плоскостью, проведённой уже в нём. $$Z_1 = f(W_{11,1}X_1 + W_{12,1}X_2 + \dots)$$ $$Y_1 = f(W_{21,1}Z_1 + W_{22,1}Z_2 + \dots)$$

Функции активации

Для обучения сети методом градиентного спуска функции активации должны быть дифференцируемы.

  • Пороговая функция. Она ближе всего к биологической модели, но для обучения не подходит: в точке порога она не дифференцируема, а всюду вне её производная равна нулю, и градиент отсутствует.
  • Сигмоида: Дифференцируемая функция. $$\sigma(x) = \frac{1}{1 + e^{-x}}$$ Производная сигмоиды имеет следующий вид. $$\frac{d\sigma(x)}{dx} = \sigma(x)(1 - \sigma(x))$$

2. Обучение нейронной сети

Обучение одного нейрона

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

Метод обратного распространения ошибки (Backpropagation)

Для пересчёта весов в многослойной сети необходимо знать ошибку, приписанную каждому нейрону; для выходного слоя \(Y_1\) она вычисляется непосредственно, а для скрытых слоёв \(Z\) требуется метод обратного распространения.

Цепное правило (Chain Rule): $$\frac{dz}{dx} = \frac{dz}{dy} \cdot \frac{dy}{dx}$$

Пример вычисления градиента: Пусть задана функция \(f(x, y, z) = (x + y)z\).

  1. \(q = x + y\), откуда \(\frac{dq}{dx} = 1, \frac{dq}{dy} = 1\).
  2. \(f = zq\), откуда \(\frac{df}{dq} = z, \frac{df}{dz} = q\).
  3. Итоговые градиенты дают \(\frac{df}{dx} = \frac{df}{dq} \cdot \frac{dq}{dx} = z \cdot 1\).

Обновление весов: Ошибкой называется разность между полученным выходом (actual) и ожидаемым (expected). $$err = actual - expected$$ Для сигмоиды градиент веса включает производную функции активации. $$weights_delta = err \cdot \sigma(x) \cdot (1 - \sigma(x))$$ Обновление веса с learning rate (\(lr\)) записывается следующим образом. $$weight_{new} = weight_{old} - output \cdot weights_delta \cdot lr$$

Ошибка для скрытых слоёв распределяется пропорционально весам, связывающим их с последующим слоем. $$err_{hidden} = weight \cdot weights_delta$$

Проблемы сигмоиды

Сигмоида дифференцируема и удобна, однако у неё имеются три недостатка, из-за которых в глубоких сетях её практически вытеснил ReLU.

  • На хвостах производная близка к нулю, и в глубокой сети градиент затухает, не доходя до нижних слоёв.
  • Выход всегда положителен, поэтому градиенты весов одного нейрона, смещённые в одну сторону, приводят к зигзагообразному ходу обучения.
  • Вычисление экспоненты является дорогостоящим.

3. Оптимизаторы

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

SGD (Stochastic Gradient Descent)

Базовый метод записывается следующим образом. $$x_{t+1} = x_t - \alpha f'(x_t)$$ где \(\alpha\) — learning rate.

SGD with Momentum

Накапливает инерцию движения, сглаживая колебания. $$v_{t+1} = \rho v_t + \alpha f'(x_t)$$ $$x_{t+1} = x_t - v_{t+1}$$

Nesterov Momentum

Версия momentum, вычисляющая градиент в точке, в которую инерция сдвинет параметры, и потому реже проскакивающая минимум. $$v_{t+1} = \rho v_t - \alpha f'(x_t + \rho v_t)$$ $$x_{t+1} = x_t + v_{t+1}$$

Adagrad (Adaptive Gradient)

Адаптирует learning rate для каждого параметра индивидуально, вследствие чего веса, сдвинутые сильно, изменяются меньше. $$cache_{t+1} = cache_t + f'(x_t)^2$$ $$x_{t+1} = x_t - \frac{\alpha f'(x_t)}{\sqrt{cache_{t+1}} + \epsilon}$$ Проблема: \(cache \to \infty\), что может остановить обучение.

RMSProp

Сглаживает изменения кеша, забывая старые значения (экспоненциальное скользящее среднее). $$cache_{t+1} = \beta cache_t + (1 - \beta) f'(x_t)^2, \quad \beta \in [0, 1]$$ $$x_{t+1} = x_t - \frac{\alpha f'(x_t)}{\sqrt{cache_{t+1}} + \epsilon}$$

Adam

Комбинация RMSProp и Momentum записывается следующим образом. $$v_{t+1} = \gamma v_t + (1 - \gamma) f'(x_t)$$ $$cache_{t+1} = \beta cache_t + (1 - \beta) f'(x_t)^2$$ Оба накопителя стартуют с нуля, поэтому на первых шагах они занижены, и перед их использованием вводится поправка на смещение. $$\hat{v}{t+1} = \frac{v{t+1}}{1 - \gamma^{t+1}}, \qquad \widehat{cache}{t+1} = \frac{cache{t+1}}{1 - \beta^{t+1}}$$ Обновление выполняется уже по исправленным величинам; без этой поправки Adam сводится к RMSProp, дополненному инерцией. $$x_{t+1} = x_t - \frac{\alpha \hat{v}{t+1}}{\sqrt{\widehat{cache}{t+1}} + \epsilon}$$ В качестве отправной точки обычно выбирают Adam или Nesterov momentum: с ними сеть, собранная из типовых слоёв, в большинстве случаев обучается без ручного подбора.

4. Нормализация и инициализация

Нормализация данных

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

  • Технические ограничения точности дробных чисел.
  • Без нормализации страдают L1 и L2 регуляризация.
  • Ускоряется обучение. В нейронных сетях это критично, так как каждый слой отображает данные в новое пространство признаков, и при изменении весов смещается распределение признаков, получаемых на выходе слоя.

Инициализация весов

Два очевидных способа заполнить веса перед обучением не работают.

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

Для сохранения дисперсии выведем соотношение, связывающее веса линейного слоя. $$D(s) = D\left(\sum_{i}^{N} w_i x_i\right) = \sum_{i}^{N} D(w_i x_i) = n D(w) D(x)$$ Чтобы \(D(s) = D(x)\), необходимо выполнение условия $$D(w) = \frac{1}{n}$$ Если взять случайные значения и умножить их на \(\frac{1}{\sqrt{n}}\) (или использовать распределение с дисперсией \(1/n\)), получается инициализация ЛеКуна, сохраняющая дисперсию только в прямом проходе, поскольку она учитывает лишь число входов. Инициализация Ксавье (Glorot и Bengio) выравнивает прямой и обратный проходы одновременно и использует \(D(w) = 2/(n_{in} + n_{out})\). Для ReLU половина выходов обнуляется, поэтому дисперсия весов берётся вдвое больше; такой вариант называется инициализацией Хе (He), и именно он применяется для слоёв с ReLU. По умолчанию фреймворки используют другое: Keras у Dense и Conv2D — glorot_uniform (Ксавье), PyTorch у nn.Linear и nn.Conv2d — kaiming_uniform_(a=√5). Следовательно, инициализацию под ReLU обычно приходится задавать явно.

Batch Normalization

Нормирует активации, вычисленные внутри одного батча; в исходной работе слой располагали перед функцией активации, чтобы распределение входов, приходящих в каждый слой, не смещалось по мере обучения (internal covariate shift). Объяснение, предложенное авторами, впоследствии было оспорено, однако слой работает.

Формула нормализации имеет следующий вид. $$\hat{x}_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \varepsilon}}$$ где \(\mu\) и \(\sigma^2\) — матожидание и дисперсия в батче.

Далее применяется масштабирующее преобразование с обучаемыми параметрами \(\gamma, \beta\). $$y_i = \gamma \hat{x}_i + \beta$$

На инференсе, когда сеть только вычисляет предсказание, батча может не быть, поэтому вместо статистик батча используются скользящие средние, накопленные при обучении. $$\mu_{t+1} = \alpha \times \mu_{batch} + (1 - \alpha) \mu_t$$ $$\sigma^2_{t+1} = \alpha \times \sigma^2_{batch} + (1 - \alpha) \sigma^2_t$$

Batch normalization ускоряет сходимость сети и позволяет использовать больший learning rate.

5. Регуляризация

L1 и L2 регуляризация

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

  • L2: \(|W|_2^2 = \sum w^2\)
  • L1: \(|W|_1 = \sum |w|\)
  • Elastic Net: Комбинация L1 и L2.

Dropout

Случайным образом «выключает» часть нейронов в процессе обучения, причём на каждой итерации выключаются разные нейроны.

  • Интерпретация: Уменьшение количества признаков.
  • На инференсе. Все нейроны включены, и масштаб сигнала должен совпадать с обучением. В исходной работе для этого выходы домножались на долю оставленных нейронов уже при выводе; в современных реализациях поступают наоборот — при обучении делят на эту долю (inverted dropout), поэтому model.eval() в PyTorch и training=False в Keras просто отключают слой без дополнительных пересчётов.

Residual Connection (Остаточные связи)

В очень глубоких сетях градиент может не доходить до нижних слоёв (становиться очень малым), поэтому его «проталкивают» туда сложением признаков, взятых в обход нескольких слоёв (skip connection).

Аугментация

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

6. Заключение

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

Расширенная версия раздела, включая задачи компьютерного зрения (Computer Vision), — книга «Базовые методы ИИ в физических исследованиях». В следующей главе эти модели обучаются средствами scikit-learn: интерфейс у них общий, а готовые рецепты для сетей прямого распространения собраны в отдельном сборнике.

Инструменты: scikit-learn

scikit-learn.org

Стандартом де-факто для классического машинного обучения в Python является библиотека scikit-learn (sklearn), построенная поверх NumPy, SciPy и Pandas и охватывающая весь конвейер обработки данных.

датасет → разбиение → предобработка → обучение → метрики → подбор гиперпараметров → сохранение модели

NumPy и Pandas рассмотрены в главе NumPy и pandas, построение графиков — в главе Визуализация на Python. В настоящей главе конвейер рассматривается по шагам на одном сквозном примере — классификации сортов вина по химическому составу.

Установка библиотеки осуществляется следующей командой.

pip install scikit-learn

Единый API и методы fit / predict / transform

Основной причиной популярности scikit-learn является единообразный интерфейс. Все объекты библиотеки делятся на два типа.

  • Модели (estimators) обучаются предсказывать целевую величину; к ним относятся KNeighborsClassifier, LinearRegression, RandomForestClassifier, MLPClassifier...
  • Преобразователи (transformers) подготавливают входные данные; к ним относятся StandardScaler, OneHotEncoder, PCA...

Объекты обоих типов имеют одинаковый набор методов.

  • fit(X, y) обучает объект: модель подбирает параметры, а преобразователь запоминает статистики, вычисленные по выборке (например, среднее и дисперсию каждого признака);
  • predict(X) предсказывает метки для новых объектов, которых модель не видела;
  • predict_proba(X) предсказывает вероятности, приписываемые классам (у классификаторов);
  • transform(X) преобразует входные данные (у преобразователей);
  • fit_transform(X) обучает и преобразует одним вызовом.

Замена одного алгоритма другим сводится к изменению одной строки кода: kNN, случайный лес и нейронная сеть обучаются и вызываются одинаково. Все алгоритмы, рассмотренные в предыдущих главах, реализованы в scikit-learn.

Используемые библиотеки

Импортируем всё, что потребуется в этой главе.

import numpy as np
import pandas as pd

from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV, StratifiedKFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
    accuracy_score,
    precision_score,
    recall_score,
    f1_score,
    confusion_matrix,
    classification_report,
    roc_auc_score,
)

Датасеты

В sklearn.datasets выделяются три семейства функций.

  • load_* возвращает небольшие учебные датасеты, входящие в состав библиотеки, такие как load_iris, load_wine, load_breast_cancer, load_digits;
  • fetch_* возвращает большие датасеты, загружаемые из интернета при первом обращении, такие как fetch_california_housing, fetch_openml;
  • make_* представляет собой генераторы синтетических данных для экспериментов, такие как make_classification, make_regression, make_blobs.

Рассмотрим датасет Wine, содержащий 178 образцов вина трёх сортов (три класса), каждый из которых описан 13 числовыми признаками химического состава: содержание алкоголя, яблочной кислоты, магния, фенолов, интенсивность цвета и так далее. Задача заключается в определении сорта винограда по химическому анализу.

# as_frame=True возвращает данные в виде pandas.DataFrame
wine = load_wine(as_frame=True)

data = wine.frame          # признаки + целевая переменная в одной таблице
X = wine.data              # матрица объекты-признаки, shape (178, 13)
y = wine.target            # метки классов: 0, 1, 2

print(data.shape)          # размерность
print(wine.target_names)   # названия сортов
data.head()                # первые пять строк: данные всегда просматриваются вручную

Разбиение выборки через train_test_split

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

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,       # 20% объектов — в тест
    random_state=42,     # фиксируем случайность для воспроизводимости
    stratify=y,          # сохраняем соотношение классов в обеих частях
)

Параметр stratify=y важен при дисбалансе классов, поскольку гарантирует совпадение долей классов в обучающей и тестовой выборках. Отложенная тестовая выборка не используется до финальной оценки: она представляет данные, которых модель не видела.

Предобработка

Масштабирование признаков

Как показано в главе про классические алгоритмы, методы, опирающиеся на расстояния (kNN, k-means) и на градиентный спуск (линейные модели, нейронные сети), чувствительны к масштабу признаков. В датасете Wine магний измеряется десятками, а фенолы единицами, поэтому признаки, подаваемые модели, необходимо стандартизировать.

scaler = StandardScaler()

# обучаем scaler ТОЛЬКО на обучающей выборке...
X_train_scaled = scaler.fit_transform(X_train)

# ...а тест лишь преобразуем уже выученными статистиками
X_test_scaled = scaler.transform(X_test)

StandardScaler приводит каждый признак к нулевому среднему и единичной дисперсии; среди альтернатив — MinMaxScaler (нормализация, приводящая признак к диапазону [0, 1]) и RobustScaler (устойчивый к выбросам).

fit вызывается только на обучающей выборке. Если обучить scaler на всей выборке, статистики тестовой части попадут в обучение, что называется утечкой данных (data leakage) и приводит к завышению оценок качества.

Кодирование категориальных признаков

В Wine все признаки числовые, но в реальных данных часто встречаются категориальные (тип детектора, режим установки), а модели работают с числами, поэтому категории необходимо закодировать.

from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

# OneHotEncoder: каждая категория -> отдельный бинарный признак
# подходит для номинальных признаков без порядка
encoder = OneHotEncoder(handle_unknown="ignore")

# OrdinalEncoder: категории -> целые числа 0, 1, 2, ...
# подходит для порядковых признаков (плохо/хорошо/отлично)
ordinal = OrdinalEncoder()

Для применения разных преобразований к разным столбцам (масштабирования числовых и кодирования категориальных) используется ColumnTransformer из sklearn.compose.

Обучение модели

Обучим два классификатора из главы про классические алгоритмы — метод ближайших соседей и случайный лес.

# kNN: голосование K ближайших соседей, требует масштабирования
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train_scaled, y_train)

# случайный лес: ансамбль деревьев, к масштабу нечувствителен
forest = RandomForestClassifier(n_estimators=200, random_state=42)
forest.fit(X_train, y_train)

# предсказания на тестовой выборке
y_pred_knn = knn.predict(X_test_scaled)
y_pred_forest = forest.predict(X_test)

Обучение сводится к одному вызову fit, а гиперпараметры модели (число соседей n_neighbors, число деревьев n_estimators) задаются в конструкторе.

Pipeline

Пара scaler + модель встречается настолько часто, что для неё предусмотрен отдельный класс Pipeline, объединяющий цепочку преобразований и финальную модель в один объект с тем же API.

model = Pipeline(steps=[
    ("scaler", StandardScaler()),          # шаг 1: стандартизация
    ("knn", KNeighborsClassifier(n_neighbors=5)),  # шаг 2: классификатор
])

# fit сам обучит scaler на train и передаст преобразованные данные в kNN
model.fit(X_train, y_train)

# predict сам преобразует тест выученным scaler'ом и предскажет
y_pred = model.predict(X_test)

Pipeline решает сразу три задачи.

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

Метрики качества

Классификация

Теория метрик рассмотрена в разделе про логистическую регрессию; здесь вызываются готовые функции.

print(f"Accuracy:  {accuracy_score(y_test, y_pred):.3f}")

# у нас три класса, поэтому указываем способ усреднения:
# average="macro" — среднее по классам без учёта их размера
print(f"Precision: {precision_score(y_test, y_pred, average='macro'):.3f}")
print(f"Recall:    {recall_score(y_test, y_pred, average='macro'):.3f}")
print(f"F1:        {f1_score(y_test, y_pred, average='macro'):.3f}")

# матрица ошибок: строки — истинные классы, столбцы — предсказанные
print(confusion_matrix(y_test, y_pred))

# сводный отчёт по всем метрикам для каждого класса
print(classification_report(y_test, y_pred, target_names=wine.target_names))

Напомним смысл метрик.

  • Accuracy представляет собой долю правильных ответов и вводит в заблуждение при дисбалансе классов;
  • Precision показывает, какая доля объектов, отнесённых к положительным, действительно положительна;
  • Recall показывает, какую долю действительно положительных объектов модель обнаружила;
  • F1 представляет собой гармоническое среднее Precision и Recall;
  • ROC-AUC — площадь под ROC-кривой, то есть качество ранжирования объектов по вероятностям.

ROC-AUC вычисляется по предсказанным вероятностям, а не по меткам; для многоклассовой задачи указывается стратегия «один против остальных».

y_proba = model.predict_proba(X_test)   # вероятности классов, shape (n, 3)
print(f"ROC-AUC: {roc_auc_score(y_test, y_proba, multi_class='ovr'):.3f}")

Регрессия

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

import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

# отдельные имена, чтобы не затереть сквозной пример с вином
X_reg, y_reg = load_diabetes(return_X_y=True)
X_reg_tr, X_reg_te, y_reg_tr, y_reg_te = train_test_split(
    X_reg, y_reg, test_size=0.2, random_state=42)

regressor = Ridge(alpha=1.0).fit(X_reg_tr, y_reg_tr)
y_pred = regressor.predict(X_reg_te)

mae = mean_absolute_error(y_reg_te, y_pred)   # средняя абсолютная ошибка
mse = mean_squared_error(y_reg_te, y_pred)    # среднеквадратичная ошибка
rmse = np.sqrt(mse)                       # корень из MSE, в единицах величины
r2 = r2_score(y_reg_te, y_pred)               # коэффициент детерминации R^2

print(f"MAE:  {mae:.1f}")
print(f"RMSE: {rmse:.1f}")
print(f"R^2:  {r2:.3f}")
MAE:  46.1
RMSE: 55.5
R^2:  0.419
  • MAE более устойчива к выбросам и измеряется в тех же единицах, что и целевая величина;
  • MSE сильнее штрафует крупные ошибки (квадратичный штраф);
  • R² показывает, какую долю дисперсии данных объясняет модель, причём 1 соответствует идеальной модели, 0 — предсказанию средним, а значения меньше 0 — результату хуже среднего.

Кросс-валидация

Одно разбиение train/test даёт одну случайную оценку качества, поэтому более надёжной является кросс-валидация. Выборка делится на \(k\) частей (фолдов), модель \(k\) раз обучается на \(k-1\) частях и проверяется на оставшейся, а полученные оценки усредняются.

# стратифицированные фолды сохраняют соотношение классов
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# передаём Pipeline целиком — предобработка честно обучается внутри каждого фолда
scores = cross_val_score(model, X_train, y_train, cv=cv, scoring="f1_macro")

print(f"F1 по фолдам: {scores}")
print(f"Среднее: {scores.mean():.3f} +- {scores.std():.3f}")
F1 по фолдам: [0.89355742 1.         0.96658312 0.92951496 0.96658312]
Среднее: 0.951 +- 0.036

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

Подбор гиперпараметров через GridSearchCV

Гиперпараметры (число соседей \(K\), глубина деревьев, коэффициент регуляризации) не выучиваются моделью, а подбираются отдельно. GridSearchCV перебирает все комбинации из заданной сетки, оценивая каждую кросс-валидацией.

param_grid = {
    "knn__n_neighbors": [1, 3, 5, 7, 9, 15],   # имя шага в Pipeline + "__" + имя параметра
    "knn__weights": ["uniform", "distance"],   # обычное или взвешенное голосование
}

search = GridSearchCV(
    model,                  # наш Pipeline: scaler + kNN
    param_grid,
    cv=cv,                  # схема кросс-валидации
    scoring="f1_macro",     # метрика для сравнения комбинаций
    n_jobs=-1,              # задействовать все ядра процессора
)

search.fit(X_train, y_train)

print(f"Лучшие параметры: {search.best_params_}")
print(f"Лучший CV F1: {search.best_score_:.3f}")

# лучшая модель уже переобучена на всём train — финальная проверка на тесте
best_model = search.best_estimator_
y_pred = best_model.predict(X_test)
print(f"F1 на тесте: {f1_score(y_test, y_pred, average='macro'):.3f}")
Лучшие параметры: {'knn__n_neighbors': 15, 'knn__weights': 'uniform'}
Лучший CV F1: 0.959
F1 на тесте: 1.000

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

Сохранение модели через joblib

Обученную модель не требуется переобучать при каждом запуске: её сохраняют на диск. Для sklearn-моделей рекомендуется joblib, работающий эффективнее pickle на объектах, содержащих большие NumPy-массивы.

import joblib

# сохраняем весь Pipeline: и scaler, и модель, одним файлом
joblib.dump(best_model, "wine_knn.joblib")

# ... позже, в другом скрипте или сервисе
loaded = joblib.load("wine_knn.joblib")
prediction = loaded.predict(X_test)   # сырые признаки — предобработка внутри

Модели необходимо загружать только из доверенных источников: joblib-файл, как и pickle, при загрузке может выполнить произвольный код. Кроме того, необходимо фиксировать версию scikit-learn среди зависимостей проекта, как описано в главе «От скрипта к приложению», поскольку модель, сохранённая одной версией библиотеки, не обязательно загрузится другой.

Дальнейшее изучение

Таким образом, конвейер — датасет, разбиение, стандартизация, обучение через единый API, Pipeline, метрики и кросс-валидация, подбор гиперпараметров, сохранение модели — является одним и тем же для любой задачи; меняются только данные и модель.

Готовые ноутбуки-рецепты по всем основным моделям, включая линейную и логистическую регрессию, деревья и случайный лес, наивный Байес, kNN, SVM, k-means, DBSCAN, PCA и нейронные сети прямого распространения, собраны в отдельном сборнике рецептов по машинному обучению. Каждый рецепт построен единообразно: по цепочке от библиотеки к датасету, предобработке, обучению, метрикам и графикам.

С чего начинать оптимизацию

Python обладает всеми достоинствами, кроме скорости. Интерпретация, динамическая типизация, объектная модель — всё, что делает язык удобным, обходится дорого. Наивный цикл на Python выполняется в десятки и сотни раз медленнее того же кода на C. Для скрипта, запускаемого раз в день, это несущественно, а для обработки терабайта данных с детектора или моделирования миллиона частиц разница составляет часы против недель.

Медленный код на Python почти всегда можно ускорить, не переписывая его целиком на C++. В настоящем разделе последовательно рассматриваются следующие темы:

  • глава «Причины низкой скорости Python» объясняет, откуда берётся разница с C, и вводит инструменты измерения времени: cProfile, line_profiler, py-spy, timeit;
  • глава «Оптимизация средствами самого Python» собирает приёмы, не требующие ничего, кроме самого языка: выбор структуры данных, встроенные функции, экономия памяти;
  • глава «Скорость выполнения программ» последовательно ускоряет один и тот же расчёт: сначала с помощью Numba, затем Cython и в завершение NumPy;
  • глава «Многопоточность и GIL» объясняет, почему потоки в Python не ускоряют вычисления, что представляет собой глобальная блокировка интерпретатора и в каких случаях помогают порождённые процессы;
  • глава «Асинхронность» рассматривает цикл событий и asyncio, опираясь на корутины из главы «Итераторы, генераторы и корутины», и показывает, как ожидать тысячи одновременно запущенных операций ввода-вывода;
  • глава «CUDA и вычисления на GPU» показывает, когда и как перенести расчёт на видеокарту с помощью CuPy и Numba.

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

Причины низкой скорости Python

Гибкость Python препятствует многим оптимизациям, поскольку всякая оптимизация опирается на предположения и заранее оговорённые ограничения. Чем меньше компилятору позволено считать заранее известным, тем меньше у него возможностей. Рассмотрим три основные причины.

1. Динамическая типизация

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

2. Изменяемость всего и вся

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

import builtins

print(len("abc"))
len = lambda obj: "mock!"
print(len("abc"))
len = builtins.len
3
mock!
def my_func(a, b):
    return a + b

print(my_func(1, 2))

def new_func(a, b):
    return a * b

my_func.__code__ = new_func.__code__
print(my_func(1, 2))
3
2
import sys
import ctypes

def change_local_variable():
    # берём объект предыдущего кадра стека у вызывающей стороны
    frame = sys._getframe(1)
    frame.f_locals['my_var'] = "hello"
    # Force update
    ctypes.pythonapi.PyFrame_LocalsToFast(ctypes.py_object(frame),
                                          ctypes.c_int(0))

def do_smth():
    my_var = 1
    change_local_variable()
    print(my_var)

    
do_smth()
hello

Пример рассчитан на Python до 3.12 включительно: начиная с 3.13 действует PEP 667. f_locals стал прокси-объектом и записывает непосредственно в переменные кадра, поэтому строки с ctypes не нужны, а функция PyFrame_LocalsToFast в C API отсутствует, и обращение к ней приводит к AttributeError. Запись в чужой кадр по-прежнему возможна, причём более коротким кодом.

Интерпретатор обязан выполнять написанное буквально. Он не может вынести проверку i == 0 из цикла, поскольку не знает, не изменятся ли a, i или сам range в процессе выполнения, и оптимизацию, разрешённую любому компилятору C, приходится выполнять вручную.

def do1():
    a = [-1] * 1000
    for i in range(len(a)):
        if i == 0:
            a[i] = 1
        else:
            a[i] = i
            
def do2():
    a = [-1] * 1000
    a[0] = 1
    for i in range(1, len(a)):
        a[i] = i
%timeit -n100 do1()
%timeit -n100 do2()
42.2 μs ± 970 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)
30.6 μs ± 1.14 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

3. CPython

Третьей причиной является сама эталонная реализация языка, написанная давно.

  1. CPython начинали писать задолго до многоядерных процессоров.
  2. Производительность никогда не была его главной целью.
  3. Совместимость с C API ограничивает изменения внутреннего устройства.

Начиная с версии 3.11 CPython заметно ускорился, см. обзор нововведений и раздел Faster CPython. Работа продолжается в проекте faster-cpython, а отдельное направление, Multithreaded Python without the GIL, нацелено на глобальную блокировку GIL, которая рассматривается в отдельной главе.

Момент оптимизации

Premature optimization is the root of all evil

Фразу Кнута обычно понимают следующим образом: сначала пишется работающий код, а быстрым он делается позднее, когда функциональность готова. Следствие: производительность остаётся той, какая получилась случайно, пока кто-либо не обнаружит легко исправимое место, ускоряющее программу без переделки половины кода.

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

Обратной крайностью является «большой комок грязи», архитектура, не продуманная вовсе.

If you think good architecture is expensive, try bad architecture.

Подробнее об этом написано в эссе Фута и Йодера и в статье Википедии.

Мантра оптимизаций

  1. Не делать
  2. Делать это позже
  3. Делать это оптимально

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

Порядок оптимизации

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

Д. Кнут, Structured Programming with go to Statements, ACM Computing Surveys, 1974

Основная задача заключается в поиске места, к которому имеет смысл прикладывать усилия. Этому служат два правила.

Правило 1. Профилирование кода

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

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

Правило 2. Сохранение корректности

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

Профилирование

Базовый набор почти полностью входит в стандартную поставку: cProfile собирает профиль, pstats разбирает и сортирует собранное, а SnakeViz представляет результат в виде диаграммы в браузере.

Ниже приведены ещё два инструмента.

  1. py-spy снимает профиль с уже запущенной программы, не изменяя её код, что необходимо, когда расчёт продолжается третьи сутки и перезапуск недопустим.
  2. line_profiler профилирует построчно и показывает время, приходящееся на каждую строку.

Измерение времени

Когда требуется измерить время одной функции, а не снимать полный профиль, применяется модуль timeit из стандартной библиотеки.

import timeit

setup = '''
s='abcdefghijklmnopqrstuvwxyz'

def reverse_0(s: str) -> str:
    reversed_output = ''
    s_length = len(s)
    for i in range(s_length-1, 0-1, -1):
        reversed_output = reversed_output + s[i]
    return reversed_output

def reverse_5(s: str) -> str:
    return s[::-1]
'''
timeit.timeit('reverse_0(s)', setup, number=10000)
0.020173080999484228
timeit.timeit('reverse_5(s)', setup, number=10000)
0.001456363000215788

Функция timeit измеряет время по time.perf_counter, на время измерения отключает сборщик мусора и возвращает суммарное время N запусков, а не усреднённое по ним.

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

В IPython для той же цели предусмотрена магическая команда %timeit, выводящая, в отличие от функции, среднее время и стандартное отклонение, вычисленное по семи прогонам.

def reverse_0(s: str) -> str:
    reversed_output = ''
    s_length = len(s)
    for i in range(s_length-1, 0-1, -1):
        reversed_output = reversed_output + s[i]
    return reversed_output

%timeit -n100 reverse_0('abcdefghijklmnopqrstuvwxyz')
2.09 μs ± 130 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)

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

Оптимизация средствами самого Python

Глава опирается на главу «Причины низкой скорости Python»: прежде чем ускорять, необходимо измерить, а прежде чем измерять, необходимо понимать, откуда берётся медлительность.

В настоящей главе рассматриваются приёмы, не требующие ничего, кроме самого языка; компиляторы и векторизация рассматриваются в следующей главе. Выигрыш обычно скромнее, зато цена нулевая: код остаётся обычным Python, доступным для чтения любому коллеге.

Объекты оптимизации

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

1. Общая архитектура

Устройство системы в целом: какие данные она обрабатывает, каким способом, в каком объёме и где их хранит.

2. Алгоритмы и структуры данных

Выбор алгоритма и структуры данных под конкретную обработку.

3. Реализация (код)

Способ записи выбранного алгоритма на языке.

4. Оптимизации во время компиляции

Преобразования, которые компилятор или JIT способен выполнить над уже написанным кодом.

5. Оптимизации во время исполнения

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

Ниже рассматриваются уровни 3–5, хотя у первых двух потенциал ускорения наибольший, как и цена ошибки: переделывать выбранную архитектуру посреди работы дорого.

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

За оптимизацию всегда приходится платить.

  1. Она отнимает время без гарантии, что потраченные часы принесут результат.
  2. Система в целом становится сложнее, а код, написанный ради нескольких процентов, — менее понятным.
  3. Легко выиграть в скорости и крупно проиграть в памяти.

Рекомендации по написанию кода на Python

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

Совет 1. Встроенные функции

Подсчитаем количество элементов в заранее созданном списке.

one_million_elements = [i for i in range(1000000)]

def calc_total(elements):
    total = 0
    for item in elements:
        total += 1
    
%timeit calc_total(one_million_elements)
31.6 ms ± 404 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit len(one_million_elements)
43.6 ns ± 1.03 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

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

Совет 2. Правильная фильтрация

Отберём из списка нечётные элементы, в соответствии с предыдущей рекомендацией использовав встроенный filter.

def my_filter1(elements):
    result = []
    for item in elements:
        if item % 2:
            result.append(item)
    return result
            
def my_filter2(elements):
    return list(filter(lambda x: x % 2, elements))
%timeit my_filter1(one_million_elements)
45.6 ms ± 344 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit my_filter2(one_million_elements)
76.8 ms ± 780 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Замедление объясняется накладными расходами: filter создаёт итератор, к каждому элементу применяется Python-функция lambda, а полученный итератор ещё необходимо преобразовать в список.

Запишем то же самое выражением, создающим требуемый список сразу.

def my_filter3(elements):
    return [item for item in elements if item % 2]

%timeit my_filter3(one_million_elements)
40.3 ms ± 1.01 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
one_million_elements_str = [str(i) for i in range(1000000)]

def str_filter1(elements):
    return [item for item in elements if item.isdigit()]

def str_filter2(elements):
    return list(filter(str.isdigit, elements))
%timeit str_filter1(one_million_elements_str)
55.3 ms ± 244 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit str_filter2(one_million_elements_str)
49.8 ms ± 166 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

builtins и генераторы не ускоряют код сами по себе: достаточно было заменить lambda на метод str.isdigit, написанный на C, и filter оказался быстрее. Каждый конкретный случай проверяется замером.

Совет 3. Правильная проверка вхождений

Разница между in по списку и по множеству разобрана в главе «Сложность операций с коллекциями». Здесь рассматриваются цена построения множества и расход памяти.

Запишем код, проверяющий наличие элемента.

def check_in1(elements, number):
    for item in elements:
        if item == number:
            return True
    return False

%timeit check_in1(one_million_elements, 500000)
9.02 ms ± 34.1 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit 500000 in one_million_elements
5.65 ms ± 21.4 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

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

%timeit 42 in one_million_elements
492 ns ± 2.24 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Для такой задачи в Python предусмотрено множество set, проверка вхождения в которое осуществляется по хешу, то есть за \(O(1)\) вместо \(O(n)\).

one_million_elements_set = set(one_million_elements)
%timeit 500000 in one_million_elements_set
37.3 ns ± 0.345 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
%timeit 42 in one_million_elements_set
23.5 ns ± 0.223 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

За это приходится платить временем на построение множества.

%timeit set(one_million_elements)
46.7 ms ± 358 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

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

Совет 4. Сортировка

import random

data = [random.random() for _ in range(1_000_000)]
%timeit sorted(data)
120.7 ms ± 3.1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit (lambda a: a.sort())(data[:])
108.5 ms ± 2.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Данные взяты случайные, и это существенно: на уже отсортированном списке Timsort вырождается в один линейный проход, оба замера снижаются примерно до 13 мс, и разница между ними исчезает. Копирование выполняют оба варианта: sorted создаёт копию внутри себя, а во втором варианте её создаёт срез, стоимость которого составляет около 2 мс. На случайных данных sort опережает на десяток процентов, следовательно, если исходный порядок не требуется, предпочтительнее метод, работающий на месте.

Совет 5. Условия if

Условие в if можно записать по-разному, и выбор записи влияет на время, затрачиваемое в цикле.

count = 100000

def check_false1(flag):
    for i in range(count):
        if flag == False:
            pass
    
def check_false2(flag):
    for i in range(count):
        if flag is False:
            pass

def check_false3(flag):
    for i in range(count):
        if not flag:
            pass
%timeit check_false1(True)
3.7 ms ± 31.6 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_false2(True)
2.6 ms ± 9.39 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_false3(True)
2.14 ms ± 13.9 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Сравним три варианта проверки на пустоту и определим, какой из них быстрее.

  1. if len(elements) == 0:
  2. if elements == []:
  3. if not elements:
def check_empty1(elements):
    for i in range(count):
        if len(elements) == 0:
            pass
    
def check_empty2(elements):
    for i in range(count):
        if elements == []:
            pass

def check_empty2_new(elements):
    for i in range(count):
        if elements == list():
            pass
        
def check_empty3(elements):
    for i in range(count):
        if not elements:
            pass
%timeit check_empty1(one_million_elements)
5.98 ms ± 38.9 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_empty2(one_million_elements)
5.54 ms ± 53.1 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_empty2_new(one_million_elements)
8.73 ms ± 33.4 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_empty3(one_million_elements)
2.97 ms ± 43 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Самым быстрым оказался и самый идиоматичный вариант if not elements, рекомендуемый любым руководством по стилю. Такое совпадение встречается нечасто.

Совет 6. Спрашивать разрешения или обрабатывать последствия

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

class Foo:
    attr1 = 'hello'
    
foo = Foo()
def check_attr1(obj):
    for i in range(count):
        if hasattr(obj, 'attr1'):
            obj.attr1
            
def check_attr2(obj):
    for i in range(count):
        try:
            obj.attr1
        except AttributeError:
            pass
%timeit check_attr1(foo)
8.42 ms ± 70.3 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_attr2(foo)
4.63 ms ± 29.5 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Разница становится ещё больше, если атрибутов, требующих проверки, несколько.

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

class Bar:
    pass

bar = Bar()
%timeit check_attr1(bar)
5.91 ms ± 74.3 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit check_attr2(bar)
59.5 ms ± 897 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Исключение, возбуждённое один раз, обходится дёшево, а миллион раз подряд — дорого. Выбор между hasattr и try/except определяется тем, какая ситуация встречается чаще.

Совет 7. Особенности определения словаря и списка

Словарь и список можно объявить двумя способами, дающими одинаковый результат.

def create_list1():
    for i in range(count):
        a = []

def create_list2():
    for i in range(count):
        a = list()
        
def create_dict1():
    for i in range(count):
        a = {}

def create_dict2():
    for i in range(count):
        a = dict()

Способы через [] и {} быстрее list() и dict() соответственно.

%timeit create_list1()
4.12 ms ± 127 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit create_list2()
7.16 ms ± 164 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit create_dict1()
4.04 ms ± 93.6 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit create_dict2()
7.82 ms ± 115 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Разница обусловлена обращением к имени: интерпретатору необходимо выяснить, на что указывает list, тогда как литерал компилируется в одну инструкцию. Это подтверждается байт-кодом, разобранным модулем dis.

import dis

dis.dis("[]")
  0           0 RESUME                   0

  1           2 BUILD_LIST               0
              4 RETURN_VALUE
import dis

dis.dis("list()")
  0           0 RESUME                   0

  1           2 PUSH_NULL
              4 LOAD_NAME                0 (list)
              6 CALL                     0
             14 RETURN_VALUE

Совет 8. Вызов функции

Если вызова функции можно избежать, его следует избежать: на каждый вызов создаётся кадр стека, и затраты времени на него заметны.

def square(num):
    return num ** 2
%timeit [square(num) for num in range(10000)]
1.05 ms ± 6.03 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%timeit [num ** 2 for num in range(10000)]
694 μs ± 6.45 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Совет 9. Отказ от активной работы с глобальными переменными

count = 100000

some_global = 0
def work_with_global():
    global some_global
    for i in range(count):
        some_global += 1
        
def work_with_local():
    some_local = 0
    for i in range(count):
        some_local += 1
%timeit work_with_global()
6.98 ms ± 56.6 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit work_with_local()
4.16 ms ± 41.8 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
some_global = 0
def work_with_global_optimized():
    global some_global
    some_local = some_global
    for i in range(count):
        some_local += 1
    some_global = some_local
%timeit work_with_global_optimized()
4.14 ms ± 75.4 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Совет 10. Специализированные библиотеки для математики

Численные расчёты нецелесообразно записывать циклами на Python, поскольку для этого существуют библиотеки на C и Фортране, дающие разницу в десятки раз.

def list_slow():
    a = range(10000)
    return [i ** 2 for i in a]

%timeit list_slow()
658 μs ± 4.81 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
import numpy as np

def list_fast():
    a = np.arange(10000)
    return a ** 2

%timeit list_fast()
10.4 μs ± 32.3 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Опасная зона

Приёмы, приведённые ниже, ухудшают читаемость кода ради нескольких процентов; применять их имеет смысл только в том случае, если профилировщик показал, что эти проценты необходимы.

Совет 11. Множественное присваивание

def create_variables1():
    for i in range(10000):
        a = 0
        b = 1
        c = 2
        d = 3
        e = 4
        f = 5
        g = 6
        h = 7
        i = 8
        j = 9
        
def create_variables2():
    for i in range(10000):
        a, b, c, d, e, f, g, h, i, j = 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
%timeit create_variables1()
616 μs ± 5.26 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%timeit create_variables2()
503 μs ± 8.69 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

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

Совет 12. Поиск функций и атрибутов

Поиск атрибута в Python не является бесплатным: за ним стоит __getattribute__, а если тот не нашёл атрибута, то и __getattr__. Естественным решением представляется найти атрибут один раз и сохранить его в локальную переменную, не разыскивая заново на каждой итерации.

def squares1(elements):
    result = []
    for item in elements:
        result.append(item)

def squares2(elements):
    result = []
    append = result.append
    for item in elements:
        append(item)
%timeit squares1(one_million_elements)
24.6 ms ± 255 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit squares2(one_million_elements)
29 ms ± 367 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Рекомендация, годами переходящая из одной подборки об оптимизации в другую, проигрывает: начиная с версии 3.11 CPython специализирует вызов метода в байт-коде, и обычный result.append(item) оказывается быстрее заранее сохранённой ссылки.

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

Прочее

Три проекта за пределами CPython решают ту же задачу иначе.

  1. nimpy позволяет вызывать функции на языке Nim из Python.
  2. Pythran предлагает ещё один подход к компиляции Python-кода.
  3. Pyston представляет собой альтернативный интерпретатор, снабжённый JIT-компилятором.

Оптимизация памяти

Расчёт, не помещающийся в оперативную память, не спасёт никакая векторизация: он не запустится.

Измерение памяти

Измерение памяти в Python затруднено, и первый способ, подсказанный документацией, вводит в заблуждение.

import sys

print(sys.getsizeof([i for i in range(1000000)]))
print(sys.getsizeof([i for i in range(100000)]))
8448728
800984

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

class SomeClass:
    def __init__(self, i):
        self.i = i
        self.j = i * 2
        
sys.getsizeof([SomeClass(i) for i in range(1000000)])
8448728

Список объектов SomeClass занимает столько же, сколько список целых чисел: sys.getsizeof измеряет размер самого списка, то есть массива указателей, а не объектов, на которые эти указатели ведут, и надёжно работает только для простых типов и встроенных структур, размещённых в непрерывном участке памяти.

Остаётся воспользоваться профилировщиком памяти.

%load_ext memory_profiler
%memit
peak memory: 625.96 MiB, increment: 0.00 MiB

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

%memit [n for n in range(10000000)]
peak memory: 1007.02 MiB, increment: 377.12 MiB
%memit [n for n in range(1000000)]
peak memory: 632.71 MiB, increment: 0.07 MiB

Утечки памяти в Python

Подсчёт ссылок, циклические ссылки и поколенческий сборщик разбирались в главе «Объекты и память»; там же рассмотрена ловушка с изменяемым аргументом по умолчанию. Здесь речь идёт о том, что утекает в долго живущей программе.

В смысле C++ утечек в Python почти нет: за освобождением следит сборщик мусора, и потерять память можно разве что нарушив счётчик ссылок в расширении, написанном на C. Подробнее об этом рассказано в разборе устройства сборщика.

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

def mutable_argument(arr=[]):
    arr.append(42)
    return arr
def unused_variable_in_long_process(arg1, arg2, arg3, unused_variable):
    pass
class ClassCaching:
    cache = {}                      # общий на весь класс, а не на экземпляр

    def calc(self, arg):
        result = self.cache.get(arg)
        if result is not None:
            return result
        result = do_calc(arg)
        self.cache[arg] = result    # растёт вечно: удалять отсюда некому
        return result

В старых версиях Python (2.7 и все версии до 3.4) сборщик не умел разбирать циклические ссылки между объектами с __del__, и образованные ими циклы существовали до конца работы программы.

Array

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

import array

%memit array.array('q', range(10000000))
peak memory: 702.93 MiB, increment: 70.22 MiB

Полный список кодов типов приведён в документации.

np.array

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

np.arange(10000000).nbytes / 2**20
76.29

Здесь %memit не подходит: он измеряет прирост потребления процессом, а интерпретатор с аллокатором удерживают уже освобождённые страницы про запас и размещают в них вновь созданный массив. В таком случае %memit покажет increment: 0.00 MiB для восьмидесяти мегабайт данных, и это не экономия, а несостоявшееся измерение. У NumPy размер известен точно и без замеров: nbytes возвращает len * itemsize.

tuple vs list

Кортеж несколько компактнее списка: ему не требуется запас под рост. Разница невелика, зато видно, во что обходится списковое включение, создающее список с запасом места под будущие append.

sys.getsizeof([i for i in one_million_elements])
8448728
sys.getsizeof(tuple(one_million_elements))
8000040
sys.getsizeof(list(one_million_elements))
8000056

Slots

Атрибут __slots__ отменяет у экземпляров словарь __dict__ и размещает объявленные поля в фиксированных ячейках, за счёт чего экономится память.

class SomeClass:
    def __init__(self, i):
        self.a = i
        self.b = 2 * i
        self.c = 3 * i
        self.d = 4 * i
        self.e = 5 * i
%memit [SomeClass(i) for i in range(1000000)]
peak memory: 880.38 MiB, increment: 247.62 MiB
class SomeClassSlots:
    __slots__ = ('a', 'b', 'c', 'd', 'e',)
    def __init__(self, i):
        self.a = i
        self.b = 2 * i
        self.c = 3 * i
        self.d = 4 * i
        self.e = 5 * i
                
%memit [SomeClassSlots(i) for i in range(1000000)]
peak memory: 853.01 MiB, increment: 217.66 MiB

Обычно __slots__ ускоряет и обращение к атрибуту, но не всегда.

d1 = SomeClass(0)
d2 = SomeClassSlots(0)

def attr_work(obj):
    count = 0
    for i in range(10000):
        count += obj.a + obj.b + obj.c + obj.d + obj.e
%timeit attr_work(d1)
824 μs ± 20.2 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%timeit attr_work(d2)
845 μs ± 7.76 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Здесь разница оказалась в пределах шума: современный CPython кеширует поиск атрибута и в обычном __dict__.

С наследованием __slots__ неудобен: указывать его приходится в каждом классе иерархии, иначе __dict__ возвращается и экономия исчезает.

bitarray

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

import bitarray.util as bu

bu.zeros(10000000).nbytes / 2**20
1.19
%memit [False for i in range(10000000)]
peak memory: 701.93 MiB, increment: 67.81 MiB

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

range и вычисление вместо хранения

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

a = range(1, 100000, 3)
print(a[10])
print(len(a))
31
33333

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

Другой полезный инструментарий

Для исследования памяти полезны ещё два инструмента, работающих с живой кучей.

  1. objgraph строит граф ссылок и помогает установить, какой объект удерживает другой объект живым.
  2. guppy3 собирает подробную статистику по куче.

Резюме

  • Оптимизировать имеет смысл только измеренное: интуиция о том, где программа проводит время, систематически ошибается, поэтому сначала применяется профилировщик, затем вносятся правки.
  • Оптимизация всегда имеет цену, отнимая время разработки, читаемость кода, а иногда и корректность, поэтому, прежде чем ускорять, необходимо убедиться, что медленная работа действительно составляет проблему.
  • Самый дешёвый выигрыш обычно даёт не микрооптимизация, а смена структуры данных или алгоритма, когда замена списка на множество в проверке вхождения меняет \(O(n)\) на \(O(1)\).
  • Для памяти существуют свои средства, такие как __slots__, array и генераторы вместо списков, не ускоряющие код, зато позволяющие обработать данные, которые иначе не поместились бы.
  • Сначала правильно, потом быстро. Ускоренный неверный расчёт остаётся неверным.

Скорость выполнения программ

В настоящей главе рассматривается последовательное ускорение одной задачи, при котором результат измеряется на каждом шаге.

В качестве задачи выбрано умножение матриц — операция, встречающаяся в любом физическом расчёте, на которой видны все типичные способы оптимизации.

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

Класс Matrix

Матрица описана как список списков с парой конструкторов:

import random

class Matrix(list):
    @classmethod
    def zeros(cls, shape):
        n_rows, n_cols = shape
        return cls([[0] * n_cols for i in range(n_rows)])

    @classmethod
    def random(cls, shape):
        M, (n_rows, n_cols) = cls(), shape
        for i in range(n_rows):
            M.append([random.randint(-255, 255)
                      for j in range(n_cols)])
        return M

    def transpose(self):
        n_rows, n_cols = self.shape
        return self.__class__(zip(*self))

    @property
    def shape(self):
        return ((0, 0) if not self else
                (len(self), len(self[0])))
def matrix_product(X, Y):
    """Вычисляет матричное произведение X и Y.

    >>> X = Matrix([[1], [2], [3]])
    >>> Y = Matrix([[4, 5, 6]])
    >>> matrix_product(X, Y)
    [[4, 5, 6], [8, 10, 12], [12, 15, 18]]
    >>> matrix_product(Y, X)
    [[32]]
    """
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    # верим, что с размерностями всё хорошо
    Z = Matrix.zeros((n_xrows, n_ycols))
    for i in range(n_xrows):
        for j in range(n_xcols):
            for k in range(n_ycols):
                Z[i][k] += X[i][j] * Y[j][k]
    return Z
%doctest_mode
Exception reporting mode: Plain
Doctest mode is: ON
>>> X = Matrix([[1], [2], [3]])
>>> Y = Matrix([[4, 5, 6]])
>>> matrix_product(X, Y)
[[4, 5, 6], [8, 10, 12], [12, 15, 18]]
>>> matrix_product(Y, X)

[[32]]
[[32]]
%doctest_mode
Exception reporting mode: Context
Doctest mode is: OFF

Измерение времени выполнения

Реализация работает корректно; её скорость измеряется магической командой %%timeit, описанной в главе «Причины низкой скорости Python».

%%timeit shape = 64, 64; X = Matrix.random(shape); Y = Matrix.random(shape)
matrix_product(X, Y)
86.6 ms ± 1.52 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Умножение двух матриц 64×64 занимает 87 миллисекунд, почти десятую долю секунды. Найдём причину.

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

def bench(shape=(64, 64), n_iter=16):
    X = Matrix.random(shape)
    Y = Matrix.random(shape)
    for iter in range(n_iter):
        matrix_product(X, Y)    

Рассмотрим происходящее подробнее с помощью line_profiler, описанного в той же главе.

#!pip install line_profiler
%load_ext line_profiler
%lprun -f matrix_product bench()

Операция list.__getitem__ не является бесплатной, поэтому поменяем местами вложенные циклы for, чтобы код выполнял меньше обращений по индексу.

def matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = Matrix.zeros((n_xrows, n_ycols))
    for i in range(n_xrows):
        Xi = X[i]
        for k in range(n_ycols):
            acc = 0
            for j in range(n_xcols):
                acc += Xi[j] * Y[j][k]
            Z[i][k] = acc
    return Z
%lprun -f matrix_product bench()

Выполнение ускорилось на две секунды, однако более 30 % времени по-прежнему уходит на итерацию по индексам во внутреннем цикле. Устраним и этот недостаток.

def matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = Matrix.zeros((n_xrows, n_ycols))
    for i in range(n_xrows):
        Xi, Zi = X[i], Z[i]
        for k in range(n_ycols):
            Zi[k] = sum(Xi[j] * Y[j][k] for j in range(n_xcols))
    return Z
%lprun -f matrix_product bench()

Уберём лишние обращения по индексу и из самого внутреннего цикла.

def matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = Matrix.zeros((n_xrows, n_ycols))
    Yt = Y.transpose()  # <--
    for i, (Xi, Zi) in enumerate(zip(X, Z)):
        for k, Ytk in enumerate(Yt):
            Zi[k] = sum(Xi[j] * Ytk[j] for j in range(n_xcols))
    return Z

Numba

Со встроенными списками Python компилятор Numba работать не способен: для генерации машинного кода ему необходим массив известного типа. Перепишем matrix_product через ndarray, хранящий числа одного типа подряд.

import numba
import numpy as np


@numba.jit
def jit_matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = np.zeros((n_xrows, n_ycols), dtype=X.dtype)
    for i in range(n_xrows):
        for k in range(n_ycols):
            for j in range(n_xcols):
                Z[i, k] += X[i, j] * Y[j, k]
    return Z

Рассмотрим полученный результат.

shape = 64, 64
X = np.random.randint(-255, 255, shape)
Y = np.random.randint(-255, 255, shape)

jit_matrix_product(X, Y)          # прогрев: здесь идёт компиляция
%timeit -n100 jit_matrix_product(X, Y)
107 µs ± 1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Numba компилирует функцию при первом вызове под конкретные типы аргументов, и на этой машине компиляция занимает около 360 мс, что в три тысячи раз дольше самого счёта. Без прогрева она попадает в измерение, и %timeit выдаёт 495 мкс со среднеквадратичным отклонением 900 мкс, то есть разброс превышает среднее. По такому разбросу и распознаётся измерение, искажённое компиляцией. IPython в таком случае предупреждает, что самый медленный прогон оказался в двадцать раз дольше самого быстрого. После прогрева разброс снижается до одной микросекунды.

Настоящая задача: пространственный заряд

Умножение матриц является учебным примером; рассмотрим, что Numba даёт на реальной задаче. В коде REDPIC, которому посвящена отдельная глава, пучок представлен ансамблем макрочастиц, а наиболее затратной функцией расчёта является суммирование кулоновских сил между частицами. Это эффект пространственного заряда, из-за которого сильноточный пучок расталкивает сам себя:

$$ \vec{F}i = \sum{j \ne i} \frac{\vec{r}_i - \vec{r}_j}{|\vec{r}_i - \vec{r}_j|^3}. $$

Каждая частица взаимодействует с каждой, поэтому сложность составляет \(O(N^2)\), где \(N\) — число частиц. Цикл, записанный напрямую, допускает и JIT-компиляцию, и распараллеливание, поскольку слагаемые для разных \(i\) вычисляются независимо.

import numpy as np
from numba import njit, prange

def space_charge(x, y, z, Fx, Fy, Fz):
    for i in range(len(x)):
        for j in range(len(x)):
            if i != j:
                r3 = ((x[j]-x[i])**2 + (y[j]-y[i])**2 + (z[j]-z[i])**2)**1.5
                Fx[i] += (x[i]-x[j]) / r3
                Fy[i] += (y[i]-y[j]) / r3
                Fz[i] += (z[i]-z[j]) / r3

Для параллельной версии достаточно заменить внешний range на prange: таким образом Numba узнаёт, какой из вложенных циклов можно распределить по ядрам:

def space_charge_par(x, y, z, Fx, Fy, Fz):
    for i in prange(len(x)):       # <-- единственное отличие
        for j in range(len(x)):
            if i != j:
                r3 = ((x[j]-x[i])**2 + (y[j]-y[i])**2 + (z[j]-z[i])**2)**1.5
                Fx[i] += (x[i]-x[j]) / r3
                Fy[i] += (y[i]-y[j]) / r3
                Fz[i] += (z[i]-z[j]) / r3

jit_version = njit(space_charge)                      # JIT
par_version = njit(parallel=True)(space_charge_par)   # JIT + ядра CPU

Запись njit(func) вместо @njit над определением удобна, когда одну функцию необходимо измерить в нескольких режимах: исходный код один, обёрток несколько.

Результаты измерения (Apple M4, 10 ядер; первый вызов каждой скомпилированной версии выполнен заранее, чтобы в измерение не попало время компиляции):

      N | чистый Python |     @njit | @njit parallel | ускорение
   256 |       53.0 мс |   0.40 мс |        0.20 мс |   131x /   271x
   512 |      219.0 мс |   1.67 мс |        0.48 мс |   131x /   454x
  1024 |      865.7 мс |   6.88 мс |        1.68 мс |   126x /   514x
  2048 |     3503.4 мс |  28.19 мс |        6.69 мс |   124x /   524x
  4096 |    14076.2 мс | 114.68 мс |       24.80 мс |   123x /   567x

Из таблицы следуют три вывода.

Квадратичность видна в числах. При каждом удвоении \(N\) время растёт вчетверо, что и означает \(O(N^2)\). JIT-компиляция сложность не меняет: она сокращает время более чем в сто раз, но кривая остаётся квадратичной. Ускорение констант и улучшение асимптотики являются разными вещами.

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

Параллелизм добавляет ещё в 4–5 раз, но не в 10, хотя ядер десять. Часть из них производительные, часть — энергоэффективные; к этому добавляются накладные расходы на распределение работы, поглощающие на малых \(N\) почти весь выигрыш: при \(N = 256\) параллельная версия опережает последовательную лишь вдвое. Линейного масштабирования по числу ядер на практике почти не встречается.

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

Cython

Второй путь к машинному коду — Cython, представляющий собой Python с аннотациями типов: код транслируется в C и компилируется. Ручной работы больше, однако и контроля больше.

%load_ext cython
%%capture
%%cython -a
import random

class Matrix(list):
    @classmethod
    def zeros(cls, shape):
        n_rows, n_cols = shape
        return cls([[0] * n_cols for i in range(n_rows)])

    @classmethod
    def random(cls, shape):
        M, (n_rows, n_cols) = cls(), shape
        for i in range(n_rows):
            M.append([random.randint(-255, 255)
                      for j in range(n_cols)])
        return M

    def transpose(self):
        n_rows, n_cols = self.shape
        return self.__class__(zip(*self))

    @property
    def shape(self):
        return ((0, 0) if not self else
                (int(len(self)), int(len(self[0]))))

    
def cy_matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = Matrix.zeros((n_xrows, n_ycols))
    Yt = Y.transpose()
    for i, Xi in enumerate(X):
        for k, Ytk in enumerate(Yt):
            Z[i][k] = sum(Xi[j] * Ytk[j] for j in range(n_xcols))
    return Z
X = Matrix.random(shape)
Y = Matrix.random(shape)
%timeit -n100 cy_matrix_product(X, Y)
21.4 ms ± 1.36 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

Cython не способен эффективно оптимизировать работу со списками, в которых могут находиться элементы разных типов, поэтому перепишем matrix_product через ndarray.

X = np.random.randint(-255, 255, size=shape)
Y = np.random.randint(-255, 255, size=shape)
%%capture
%%cython -a
import numpy as np

def cy_matrix_product(X, Y):
    n_xrows, n_xcols = X.shape
    n_yrows, n_ycols = Y.shape
    Z = np.zeros((n_xrows, n_ycols), dtype=X.dtype)
    for i in range(n_xrows):
        for k in range(n_ycols):
            for j in range(n_xcols):
                Z[i, k] += X[i, j] * Y[j, k]
    return Z
%timeit -n100 cy_matrix_product(X, Y)
176 ms ± 4.65 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

Результат ухудшился: большая часть кода по-прежнему использует вызовы Python. Избавимся от них, аннотировав код типами.

%%capture
%%cython -a
import numpy as np
cimport numpy as np

def cy_matrix_product(np.ndarray X, np.ndarray Y):
    cdef int n_xrows = X.shape[0]
    cdef int n_xcols = X.shape[1]
    cdef int n_yrows = Y.shape[0]
    cdef int n_ycols = Y.shape[1]
    cdef np.ndarray Z
    Z = np.zeros((n_xrows, n_ycols), dtype=X.dtype)
    for i in range(n_xrows):
        for k in range(n_ycols):
            for j in range(n_xcols):
                Z[i, k] += X[i, j] * Y[j, k]
    return Z
%timeit -n100 cy_matrix_product(X, Y)
173 ms ± 4 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

Аннотации типов не изменили время работы: тело вложенного цикла Cython так и не смог оптимизировать. Укажем тип элементов в ndarray.

%%capture
%%cython -a
import numpy as np
cimport numpy as np

def cy_matrix_product(np.ndarray[np.int64_t, ndim=2] X,
                      np.ndarray[np.int64_t, ndim=2] Y):
    cdef int n_xrows = X.shape[0]
    cdef int n_xcols = X.shape[1]
    cdef int n_yrows = Y.shape[0]
    cdef int n_ycols = Y.shape[1]
    cdef np.ndarray[np.int64_t, ndim=2] Z = \
        np.zeros((n_xrows, n_ycols), dtype=np.int64)
    for i in range(n_xrows):
        for k in range(n_ycols):
            for j in range(n_xcols):
                Z[i, k] += X[i, j] * Y[j, k]
    return Z
%timeit -n100 cy_matrix_product(X, Y)
541 µs ± 5.14 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Отключим проверку выхода за границы массива. Проверку переполнения целых отключать не требуется: в Cython она и так выключена по умолчанию, а весь выигрыш даёт boundscheck. Взамен ошибка в индексе перестанет возбуждать IndexError и приведёт к обращению в чужую память без какого-либо сообщения, поэтому границы такого цикла необходимо выверять вручную.

%%capture
%%cython -a
import numpy as np

cimport cython
cimport numpy as np

@cython.boundscheck(False)
def cy_matrix_product(np.ndarray[np.int64_t, ndim=2] X, 
                      np.ndarray[np.int64_t, ndim=2] Y):
    cdef int n_xrows = X.shape[0]
    cdef int n_xcols = X.shape[1]
    cdef int n_yrows = Y.shape[0]
    cdef int n_ycols = Y.shape[1]
    cdef np.ndarray[np.int64_t, ndim=2] Z = \
        np.zeros((n_xrows, n_ycols), dtype=np.int64)
    for i in range(n_xrows):        
        for k in range(n_ycols):
            for j in range(n_xcols):
                Z[i, k] += X[i, j] * Y[j, k]
    return Z
%timeit -n100 cy_matrix_product(X, Y)
226 µs ± 2.84 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

NumPy

import numpy as np

X = np.random.randint(-255, 255, shape).astype(np.float64)
Y = np.random.randint(-255, 255, shape).astype(np.float64)
%timeit -n100 X.dot(Y)
2.7 µs ± 0.0 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit -n100 X@Y
2.7 µs ± 0.0 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Оператор @ выполняет то же матричное умножение, что и X.dot(Y). За обеими записями стоит один и тот же вызов, и измерения это подтверждают.

Приведение к float64 в первой строке является существенным. np.random.randint возвращает int64, а BLAS поддерживает только float32, float64 и комплексные типы; на целых матрицах NumPy выполняет расчёт собственным циклом и выдаёт около 70 мкс вместо 2.7. Тип данных на входе определяет больше, чем выбор между dot и @.

Наивная реализация на чистом Python вычисляла произведение матриц 64×64 около 0.1 секунды, NumPy справляется за единицы микросекунд: ускорение в десятки тысяч раз без единой строки на C со стороны разработчика. Внутри NumPy вызывает BLAS, библиотеку линейной алгебры, использующую векторные инструкции процессора и оптимально работающую с кешем.

Прежде чем компилировать Python, имеет смысл попытаться не писать циклы вообще. Numba и Cython необходимы там, где задача не векторизуется; в остальных случаях правильно применённый NumPy опережает их без сборки и объявленных типов.

Многопоточность и GIL

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

Необходимый минимум: процессы и потоки

Процесс

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

  • виртуальное адресное пространство, то есть собственную память, скрытую от остальных процессов;
  • указатель на исполняемую инструкцию;
  • стек вызовов;
  • системные ресурсы, например открытые файловые дескрипторы.

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

Поток

Поток исполняется независимо от других потоков, как и процесс, но существует внутри процесса и разделяет с ним адресное пространство и системные ресурсы. Два потока одного процесса могут свободно работать с общими данными, поскольку они видят одни и те же объекты, размещённые в общей памяти.

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

Порядком исполнения как процессов, так и потоков распоряжается операционная система, поочерёдно выделяющая каждому по нескольку тактов процессора.

Модуль threading

Поток в Python является обычным системным потоком, и его исполнением управляет операционная система, а не интерпретатор. Создать поток можно классом Thread, объявленным в модуле стандартной библиотеки threading.

import time
from threading import Thread

def countdown(n):
    for i in range(n):
        print(n - i - 1, "left")
        time.sleep(1)
t = Thread(target=countdown, args=(3,))
t.start()
2 left
1 left
0 left

Другой способ создать поток — наследование.

class CountdownThread(Thread):
    def __init__(self, n):
        super().__init__()
        self.n = n
        
    def run(self): # вызывается методом start
        for i in range(self.n):
            print(self.n - i - 1, "left")
            time.sleep(1)
t = CountdownThread(3)
t.start()
2 left
1 left
0 left

Этот подход ограничивает повторное использование кода: функциональность, заключённая в класс CountdownThread, доступна только в отдельном потоке.

У потока есть имя, по умолчанию Thread-N; имя, заданное разработчиком, лучше читается в журналах.

Thread().name
'Thread-6'
Thread(name="NumberCruncher").name
'NumberCruncher'

У каждого активного потока есть идентификатор, неотрицательное число, выданное операционной системой и уникальное среди всех активных потоков.

t = Thread()
t.start()
t.ident
123145519448064

Дождаться завершения потока позволяет метод join: вызывающий поток блокируется, пока t не завершит работу; повторный join на уже завершившемся потоке возвращается мгновенно.

t = Thread(target=time.sleep, args=(5, )) 
t.start()
t.join() # блокирует на 5 секунд
t.join() # выполняется мгновенно

Проверить, жив ли поток, можно методом is_alive.

t = Thread(target=time.sleep, args=(5, )) 
t.start()
t.is_alive() # False через 5 секунд
True

Поток, созданный с аргументом daemon=True, называется демоном: при выходе из интерпретатора он уничтожается автоматически, а не удерживает программу от завершения до окончания своей работы.

t = Thread(target=time.sleep, args=(5,), daemon=True)
t.start()
t.daemon
True

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

class Task:
    def __init__(self):
        self._running = True
    
    def terminate(self):
        self._running = False
    
    def run(self, n):
        while self._running:
            ...

Набор примитивов синхронизации в модуле threading стандартный:

  • Lock, обычный мьютекс, обеспечивающий эксклюзивный доступ к разделяемому состоянию.
  • RLock, рекурсивный мьютекс, разрешающий потоку, владеющему мьютексом, захватывать его более одного раза.
  • Semaphore, вариация мьютекса, захватываемая не более фиксированного числа раз.
  • BoundedSemaphore, семафор, следящий за тем, чтобы его захватывали и освобождали одинаковое число раз.

Все примитивы синхронизации реализуют единый интерфейс:

  • метод acquire захватывает примитив синхронизации,
  • а метод release освобождает его.
from threading import Lock


class SharedCounter:
    def __init__(self, value):
        self._value = value
        self._lock = Lock()
    
    def increment(self, delta=1):
        self._lock.acquire()
        self._value += delta
        self._lock.release()
    
    def get(self):
        return self._value

Модуль queue

Обмен данными между потоками с ручной расстановкой мьютексов неудобен. Обычно применяется готовая очередь; модуль queue реализует несколько потокобезопасных вариантов.

  • Queue, очередь FIFO,
  • LifoQueue, очередь LIFO, то есть стек,
  • PriorityQueue, очередь с приоритетом: элементы, обычно пары (priority, item), выдаются не в порядке поступления, а по возрастанию приоритета.

Все методы, изменяющие состояние, работают под мьютексом. Queue хранит элементы в deque, а LifoQueue и PriorityQueue — в обычном списке, защищённом тем же мьютексом.

Классическая схема «производитель-потребитель» на очереди приведена ниже.

def worker(q):
    while True:
        item = q.get() # блокирующе ждёт следующий
        do_something(item) # элемент
        q.task_done() # уведомляет очередь о выполнении
        
def master(q):
    for item in source():
        q.put(item)

    # блокирующе ждёт, пока все элементы
    # очереди не будут обработаны
    q.join()

Модуль futures

Модуль concurrent.futures предоставляет абстрактный класс Executor и его реализацию над пулом потоков, ThreadPoolExecutor. Интерфейс исполнителя состоит из трёх методов.

from concurrent.futures import *
executor = ThreadPoolExecutor(max_workers=4)
executor.submit(print, "Hello, world!")
Hello, world!




<Future at 0x1043b1d90 state=running>
list(executor.map(print, ["Knock?", "Knock!"]))
Knock?
Knock!





[None, None]
executor.shutdown()

Исполнители поддерживают протокол менеджеров контекста: пул, открытый в with, закрывать вручную не требуется.

with ThreadPoolExecutor(max_workers=4) as executor:
    ...

Метод Executor.submit возвращает объект Future, обёртку над ещё не завершённым вычислением.

С Future можно выполнить следующие действия.

with ThreadPoolExecutor(max_workers=4) as executor:
    f = executor.submit(sorted, [4, 3, 1, 2])
  • Запросить статус вычисления:
f.running(), f.done(), f.cancelled()
(False, True, False)
  • Блокирующе дождаться результата вычисления:
print(f.result())
[1, 2, 3, 4]
print(f.exception())
None
  • Добавить функцию, вызываемую после завершения вычисления:
f.add_done_callback(print)
<Future at 0x1043b7f10 state=finished returned list>

Пример с модулем futures: integrate

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

import math

def integrate(f, a, b, *, n_iter=1000):
    acc = 0
    step = (b - a) / n_iter
    for i in range(n_iter):
        acc += f(a + i * step) * step
    return acc
integrate(math.cos, 0, math.pi / 2)
1.0007851925466296
from functools import partial

def integrate_async(f, a, b, *, n_jobs, n_iter=1000):
    executor = ThreadPoolExecutor(max_workers=n_jobs)
    spawn = partial(executor.submit, integrate, f,
                    n_iter=n_iter // n_jobs)
    step = (b-a)/n_jobs
    fs=[spawn(a+i*step,a+(i+1)*step)
        for i in range(n_jobs)]
    return sum(f.result() for f in as_completed(fs))
integrate_async(math.cos, 0, math.pi / 2, n_jobs=2)
1.0007851925466305

Параллелизм и конкурентность

Сравним производительность последовательной и параллельной версий integrate магической командой timeit.

%%timeit -n100
integrate(math.cos, 0, math.pi / 2, n_iter=10**6)
154 ms ± 2.28 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit -n100
integrate_async(math.cos, 0, math.pi / 2, n_iter=10**6, n_jobs=2)
142 ms ± 2.11 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

Глобальная блокировка интерпретатора

Два потока вместо одного дали выигрыш в проценты вместо ожидаемого двукратного ускорения. Причиной является GIL (global interpreter lock, глобальная блокировка интерпретатора).

GIL представляет собой мьютекс, гарантирующий, что в каждый момент времени байт-код исполняет только один поток. Внутреннее состояние интерпретатора (в первую очередь счётчики ссылок у каждого объекта) не защищено от одновременного доступа, и глобальная блокировка является самым простым способом не позволить двум потокам одновременно нарушить его целостность. Платой за простоту является результат приведённого выше замера.

Следствие: в обычной сборке CPython потоки не ускоряют вычисления. Сколько бы их ни было, байт-код исполняется по очереди, а к вычислениям добавляются накладные расходы на переключение, из-за которых многопоточная версия иногда оказывается даже медленнее однопоточной.

Влияние GIL на разные задачи

Мешает ли GIL, зависит от того, чем занята программа.

Если программа вычисляет, потоки бесполезны. Обойти GIL можно двумя путями: перейти к отдельным процессам, у каждого из которых свой интерпретатор со своим GIL (об этом сказано ниже), или перейти к скомпилированному коду, способному освобождать GIL.

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

NumPy на длинных операциях также освобождает GIL: пока перемножаются большие матрицы, интерпретатор свободен для остальных потоков. Поэтому многопоточность в сочетании с NumPy работает лучше, чем можно было бы ожидать.

Оговорка «в обычной сборке CPython» существенна. Начиная с 3.13 интерпретатор собирается и вовсе без глобальной блокировки (PEP 703): такая сборка устанавливается рядом с обычной и называется python3.13t, а с 3.14 режим свободных потоков официально поддержан (PEP 779), и потоки в нём вычисляют параллельно, без процессов и без Cython. Платой являются 5–10 %, потерянные на однопоточном коде, и неготовность части C-расширений, написанных в расчёте на то, что блокировка защитит их сама. Определить, в какой сборке выполняется программа, позволяет sys._is_gil_enabled(), возвращающий False в свободнопоточной сборке. Пока она не стала используемой по умолчанию, всё изложенное в настоящей главе относится к обычной сборке.

C и Cython: освобождение GIL

Скомпилированный код может освободить GIL явно, на то время, пока он не обращается к объектам Python. В Cython для этого предусмотрен блок with nogil, внутри которого допустимы только типы C, зато остальные потоки работают параллельно.

%load_ext Cython
%%cython

from libc.math cimport cos

def integrate(f, double a, double b, long n_iter):
    cdef double acc = 0
    cdef double step=(b-a)/n_iter
    cdef long i
    with nogil:
        for i in range(n_iter):
            acc += cos(a + i * step) * step
    return acc

Первый аргумент здесь оставлен только ради совместимости с integrate_async, а сама функция жёстко привязана к cos: вызвать функцию Python f внутри with nogil невозможно.

%%timeit -n100
integrate_async(math.cos, 0, math.pi / 2, n_iter=10**6, n_jobs=2)
5.88 ms ± 126 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Те же два потока, тот же integrate_async, и 5.88 мс вместо 142. Этот выигрыш является составным. Потоков два, поэтому на снятие GIL приходится не более двукратного ускорения; остальное, примерно двенадцатикратное, обеспечила компиляция в машинный код. Таким образом, сначала применяется Cython, и только затем распараллеливается уже скомпилированный код.

О замерах ниже. Ячейка выше переопределила имя integrate: теперь под ним находится скомпилированная Cython-версия. Замеры двух следующих разделов — про процессы и про joblib — сняты не с неё, а с исходной, чистой Python-версии в свежем ядре; для их повторения необходимо перезапустить блокнот, пропустив ячейку с %%cython. В противном случае передать Cython-функцию в пул процессов и вовсе не удастся: она находится в модуле _cython_magic_<hash> из ~/.ipython/cython, который отсутствует в sys.path.

Модуль multiprocessing

Процессы как способ обойти GIL

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

Платой является изоляция. Процессы не разделяют память, поэтому всё, что необходимо передать между ними, приходится сериализовать и копировать. Для расчёта, в котором каждому процессу передаётся часть работы и забирается число, это несущественно; для задачи с большим общим состоянием накладные расходы на пересылку могут поглотить весь выигрыш.

За работу с процессами отвечает модуль multiprocessing, устроенный аналогично threading.

import multiprocessing as mp
p = mp.Process(target=countdown, args=(5, ))
p.start()
4 left
3 left
2 left
1 left
0 left

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

def ponger(conn):
    conn.send("pong")
parent_conn, child_conn = mp.Pipe()
p = mp.Process(target=ponger, args=(child_conn, ))
p.start()
parent_conn.recv()
'pong'
p.join()

Процессы и производительность

Реализация integrate_async на пуле потоков работала долго; рассмотрим пул процессов.

from concurrent.futures import ProcessPoolExecutor
def integrate_async(executor, f, a, b, *, n_jobs, n_iter=1000):
    spawn = partial(executor.submit, integrate, f,
                    n_iter=n_iter // n_jobs)

    step = (b - a) / n_jobs
    fs=[spawn(a + i * step, a + (i + 1) * step)
        for i in range(n_jobs)]

    return sum(f.result() for f in as_completed(fs))

Пул принимается аргументом, а не создаётся внутри. Запуск процесса стоит десятки миллисекунд: на macOS и Windows интерпретатор запускается заново, целиком, а начиная с Python 3.14 и на Linux место fork занял forkserver, порождающий процессы от отдельного чистого сервера. Наследования всего состояния родителя больше нет нигде, поэтому и функция, передаваемая в пул, должна находиться в импортируемом модуле, а не в ячейке блокнота. Пул, созданный внутри измеряемой функции, превратит замер вычислений в замер запуска интерпретатора. Кроме того, ProcessPoolExecutor, оставленный без закрытия, оставляет процессы работающими, и за сотню итераций %%timeit их число на машине станет значительным.

with ProcessPoolExecutor(max_workers=2) as executor:
    integrate_async(executor, math.cos, 0, math.pi / 2,
                    n_iter=10**6, n_jobs=2)          # прогрев
    %timeit -n5 integrate_async(executor, math.cos, 0, math.pi / 2, \
                                n_iter=10**6, n_jobs=2)
21 ms ± 0.2 ms per loop (mean ± std. dev. of 5 runs, 5 loops each)

В этом же прогоне последовательный расчёт занимает 44 мс, а два процесса дают 21 мс, то есть двукратное ускорение на паре ядер: у каждого процесса свой GIL. С числами из разделов выше эту пару сопоставлять нельзя: она снята отдельно.

Пакет joblib

Пакет joblib предоставляет параллельный аналог цикла for для независимых итераций, которые необходимо распределить по ядрам. Выбор между потоками и процессами задаётся аргументом backend конструктора.

Замеры ниже выполнены на исходной, чистой Python-версии integrate. Если в блокноте уже выполнена ячейка с %%cython, имя integrate указывает на скомпилированную функцию, и числа получатся на порядок меньше. Сравнивать их с результатами до Cython нельзя; для повторения замеров отсюда необходимо определить integrate заново.

from joblib import Parallel, delayed

def integrate_async(f, a, b, *, n_jobs, n_iter=1000, backend=None):
    step = (b - a) / n_jobs
    with Parallel(n_jobs=n_jobs, backend=backend) as parallel:
        fs = (delayed(integrate)(f, a + i * step,
                                 a + (i + 1) * step,
                                 n_iter=n_iter // n_jobs)
              for i in range(n_jobs))
        return sum(parallel(fs))   # внутри with: пул переиспользуется
%%timeit -n100
integrate_async(math.cos, 0, math.pi / 2, n_iter=10**6, n_jobs=2, backend="threading")
104 ms ± 280 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit -n100
integrate_async(math.cos, 0, math.pi / 2, n_iter=10**6, n_jobs=2, backend="multiprocessing")
290 ms ± 1.13 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

Сам по себе joblib ничего не ускоряет: он распределяет работу, которая упирается в тот же GIL на потоках или в ту же сериализацию на процессах.

JIT-компилятор numba

Numba из главы «Скорость выполнения программ» обходит GIL одновременно с ускорением: декоратор @jit(nopython=True, parallel=True) компилирует функцию в машинный код, освобождающий блокировку, после чего цикл prange распределяется по ядрам.

import math
from numba import jit, prange

@jit(nopython=True, parallel=True, fastmath=True)
def integrate(a, b, n_iter=1000):
    acc = 0
    step = (b - a) / n_iter
    for i in prange(n_iter):
        acc += math.cos(a + i * step) * step
    return acc
%%timeit -n100
integrate(0, math.pi / 2, n_iter=10**6)
5.11 ms ± 983 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Резюме

Перед выбором инструмента необходимо ответить на один вопрос: программа вычисляет или ожидает?

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

Если программа вычисляет, потоки в обычной сборке CPython не помогут, сколько бы их ни было. Существует три пути: вынести расчёт в отдельные процессы через multiprocessing, где у каждого свой интерпретатор и свой GIL; перейти к скомпилированному коду на Cython или C, способному освобождать GIL; или, что обычно проще всего, поручить расчёт NumPy, освобождающему блокировку самостоятельно.

Прежде чем распараллеливать, необходимо измерить. Часто узкое место находится не там, где предполагалось, и одна правка алгоритма даёт больше, чем любое количество потоков.

Асинхронность

Сколько бы потоков ни было запущено, GIL пропускает через интерпретатор только один из них, а запускать больше одновременно вычисляющих процессов, чем ядер, не имеет смысла.

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

Асинхронность поручает одному потоку тысячу ожиданий сразу: пока один запрос ожидает ответа, выполняется другой. Механику обеспечивают генераторы и корутины, разобранные в главе «Итераторы, генераторы и корутины»: планировщику необходима функция, способная приостановиться и продолжить с того же места.

Работа с разными типами задач

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

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

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

  • I/O bound-задачи (non-RAM I/O bound). Задачи, в которых основная часть работы приходится на ввод/вывод информации I/O или input/output, относящиеся в основном к работе с файловой системой и с сетью.

  • Memory bound-задачи (RAM I/O bound). Задачи с интенсивной работой с оперативной памятью, возникающие, как правило, в сложных математических моделях. Из-за медленной работы с оперативной памятью всё больше моделей обрабатывается на видеокартах, устроенных иначе. Другим примером служит обработка огромного объёма данных в Map-Reduce-системах, например таких как Spark, выполняющаяся тем быстрее, чем больше оперативной памяти.

Подробнее об этом рассказано в англоязычных статьях о значении терминов CPU bound и I/O bound и о производительности.

Из-за массового перехода на микросервисы количество сетевого взаимодействия между системами многократно возросло, а вместе с ним и нагрузка, приходящаяся на базы данных. Проблемы работы с сетью или с доступом к БД относятся к I/O bound-задачам, сводящимся к ожиданию ответа на запрос, отправленный во внешнюю систему. Такой класс задач в монолитных системах решался пулом потоков, thread pool, которого с ростом сетевой нагрузки между множеством сервисов стало недостаточно.

Классическим ответом на I/O bound-нагрузку является добавление ресурсов, однако приобретать серверы вместо того, чтобы разбираться с кодом, способны лишь компании с большими бюджетами. В лаборатории этот путь закрыт, и остаётся писать код, рассчитанный на такую нагрузку.

Рассмотрим приложение, обращающееся к некоторому сайту-агрегатору за данными по фильмам и сохраняющее полученное в БД (ссылка на сайт вымышленная):

import requests

def do_some_logic(data):
    pass
  
def save_to_database(data):
    pass

data = requests.get('https://data.aggregator.com/films')
processed_data = do_some_logic(data)
save_to_database(processed_data)

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

Схема выполнения программы:

1_1_AsyncAPI_1_1629286149.png

Тип задачи в каждой ячейке:

1.1_3_AsyncAPI_1_1629286157.png

Интуитивно представляется, что время распределено между ячейками примерно поровну, однако в действительности картина иная:

1_2_AsyncAPI_2_1629286153.png

Бо́льшую часть времени программа ожидает ввода/вывода, а полезная работа теряется на этом фоне. Код можно распараллелить на процессы и потоки. Это поможет, но ненадолго: расходы ресурсов сервера возрастут, а число процессов и потоков ограничено, поскольку закончится либо оперативная память под потоки, либо ядра под процессы. Добавляется GIL, пропускающий через интерпретатор только один поток за раз: массовый параллелизм на потоках он делает бессмысленным и добавляет собственные накладные расходы, пусть и небольшие.

Выполнение программы с потоками:

S1.1_4_AsyncAPI_1_1629286161.png

На I/O bound-задачах два потока работают почти вдвое лучше. Однако два потока, обращающиеся к одним и тем же данным, порождают проблему «состояния гонок», а многопоточный код требует от разработчика большей внимательности, чем линейный. Кроме того, создать неограниченное число потоков невозможно: памяти под каждый стек они потребляют несравнимо больше, чем корутины.

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

Решение состоит не в увеличении числа исполнителей, а в том, чтобы один исполнитель не простаивал. Эту задачу решает асинхронный код.

Event-loop

Цикл событий является ядром асинхронных программ в Python. Разберём простую реализацию, предложенную Дэвидом Бизли (David Beazley) в 2009 году: в ней отсутствуют конструкции, которыми с тех пор дополнились промышленные реализации, и устройство просматривается полностью. Код Бизли приведён к современной версии Python.

Архитектура цикла событий:

1_Event_Loop_1629282397.png

Рассмотрим блоки:

  • Планировщик (Scheduler). Корень всей программы. Обрабатывает задачи, собранные в очереди, и следит за их правильным переключением между собой.
  • Очередь задач (Task queue). Здесь накапливаются новые задачи, поставленные на исполнение.
  • Задача (Task). Основной блок работы цикла событий. В задачах хранится информация о выполняемой корутине. Способна обрабатывать цепочку вложенных корутин.
  • Корутина (Coroutine). Исполняемый код, которым оперирует планировщик задач.
  • Системный вызов (SystemCall). Блоки кода, расширяющие функциональность планировщика.
  • Корутина для выполнения работы с I/O (I/O-tasks). В планировщик добавляется специальная задача (Task), предназначенная для обработки I/O-событий от ОС.
  • Селектор (Selector). Он принимает события от ОС и передаёт работу корутинам, ожидающим обработки I/O-сообщений.

Планировщик принимает задачи и справедливо обрабатывает накопленный список.

from __future__ import annotations

import logging
from typing import Generator
from queue import Queue


class Scheduler:
    def __init__(self):
        self.ready = Queue()
        self.task_map = {}

    def add_task(self, coroutine: Generator) -> int:
        new_task = Task(coroutine)
        self.task_map[new_task.tid] = new_task
        self.schedule(new_task)
        return new_task.tid

    def exit(self, task: Task):
        del self.task_map[task.tid]

    def schedule(self, task: Task):
        self.ready.put(task)

    def _run_once(self):
        task = self.ready.get()
        try:
            result = task.run()
        except StopIteration:
            self.exit(task)
            return
        self.schedule(task)

    def event_loop(self):
        while self.task_map:
            self._run_once()

Вся работа происходит в функции event_loop(), извлекающей задачи одну за другой. В функции _run_once() осуществляется обработка одной итерации цикла событий, где поочерёдно извлекаются и запускаются задачи, поставленные в очередь. Если задача не завершилась, то она возвращается в очередь self.ready. Выполненные задачи удаляет из планировщика функция exit().

Задачу добавляет функция add_task(): она принимает корутину и создаёт с ней задачу в планировщике. Уже созданную задачу ставит в планировщик функция schedule().

Устройство задачи:

import types
from typing import Generator, Union

class Task:
    task_id = 0

    def __init__(self, target: Generator):
        Task.task_id += 1
        self.tid = Task.task_id  # Task ID
        self.target = target  # Target coroutine
        self.sendval = None  # Value to send
        self.stack = []  # Call stack

    # Run a task until it hits the next yield statement
    def run(self):
        while True:
            try:
                result = self.target.send(self.sendval)

                if isinstance(result, types.GeneratorType):
                    self.stack.append(self.target)
                    self.sendval = None
                    self.target = result
                else:
                    if not self.stack:
                        return
                    self.sendval = result
                    self.target = self.stack.pop()

            except StopIteration:
                if not self.stack:
                    raise
                self.sendval = None
                self.target = self.stack.pop()

Задача представляет собой обёртку вокруг корутины. У каждой задачи есть свой id, учитываемый в планировщике в словаре task_map. По его заполненности планировщик определяет, остались ли невыполненные задачи.

Задача выполняет корутины методом run(). Пусть имеется корутина, вызывающая другую корутину, а та вызывает третью:

def square(x):
    yield x * x

def add(x, y):
    yield from square(x + y)

def main():
    result = yield add(1, 2)
    print(result)
    yield

Это несколько изменённый код Бизли из его выступления. Выполним эту цепочку корутин внутри Task.

task = Task(main())
task.run()
9

Аналогично выполняются и остальные корутины, вложенные в цепочку. Остаётся научить планировщик работать с вводом-выводом.

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

import logging
from typing import Generator, Union
from queue import Queue
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE


logger = logging.getLogger(__name__)


class Scheduler:
    def __init__(self):
        self.ready = Queue()
        self.selector = DefaultSelector()
        self.task_map = {}

    def add_task(self, coroutine: Generator) -> int:
        new_task = Task(coroutine)
        self.task_map[new_task.tid] = new_task
        self.schedule(new_task)
        return new_task.tid

    def exit(self, task: Task):
        logger.info('Task %d terminated', task.tid)
        del self.task_map[task.tid]

    # I/O waiting
    def wait_for_read(self, task: Task, fd: int):
        try:
            key = self.selector.get_key(fd)
        except KeyError:
            self.selector.register(fd, EVENT_READ, (task, None))

        else:
            mask, (reader, writer) = key.events, key.data
            self.selector.modify(fd, mask | EVENT_READ, (task, writer))

    def wait_for_write(self, task: Task, fd: int):
        try:
            key = self.selector.get_key(fd)
        except KeyError:
            self.selector.register(fd, EVENT_WRITE, (None, task))

        else:
            mask, (reader, writer) = key.events, key.data
            self.selector.modify(fd, mask | EVENT_WRITE, (reader, task))

    def _remove_reader(self, fd: int):
        try:
            key = self.selector.get_key(fd)
        except KeyError:
            pass
        else:
            mask, (reader, writer) = key.events, key.data
            mask &= ~EVENT_READ
            if not mask:
                self.selector.unregister(fd)
            else:
                self.selector.modify(fd, mask, (None, writer))

    def _remove_writer(self, fd: int):
        try:
            key = self.selector.get_key(fd)
        except KeyError:
            pass
        else:
            mask, (reader, writer) = key.events, key.data
            mask &= ~EVENT_WRITE
            if not mask:
                self.selector.unregister(fd)
            else:
                self.selector.modify(fd, mask, (reader, None))

    def io_poll(self, timeout: Union[None, float]):
        events = self.selector.select(timeout)
        for key, mask in events:
            fileobj, (reader, writer) = key.fileobj, key.data
            if mask & EVENT_READ and reader is not None:
                self.schedule(reader)
                self._remove_reader(fileobj)
            if mask & EVENT_WRITE and writer is not None:
                self.schedule(writer)
                self._remove_writer(fileobj)

    def io_task(self) -> Generator:
        while True:
            if self.ready.empty():
                self.io_poll(None)
            else:
                self.io_poll(0)
            yield

    def schedule(self, task: Task):
        self.ready.put(task)

    def _run_once(self):
        task = self.ready.get()
        try:
            result = task.run()
        except StopIteration:
            self.exit(task)
            return
        self.schedule(task)

    def event_loop(self):
        self.add_task(self.io_task())
        while self.task_map:
            self._run_once()

Перед стартом цикла событий планировщик создаёт одну особую, бесконечную задачу io_task. Её бесконечный цикл забирает у селектора накопившиеся события и немедленно возвращает управление планировщику.

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

Поступившие из селектора события обрабатываются, а отработанные файловые дескрипторы удаляются. Одна и та же задача может ожидать присланных данных и одновременно пытаться записать свои, поэтому в поле data хранится кортеж (reader, writer).

event_loop предоставляет интерфейс для работы с сокетами, четыре метода:

  • wait_for_read,
  • wait_for_write,
  • _remove_reader,
  • _remove_writer.

Эти методы позволяют работать с циклом событий, встроенным в ОС.

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

Остаётся конструкция SystemCall. Цикл событий напоминает работу ОС, и механизм прерываний, передающий управление наверх, заимствован у неё: в асинхронном коде прерывание обеспечивает yield. После переключения контекста может вызываться системная функция, запрошенная корутиной. Например, для создания новых задач:

class SystemCall:
    def handle(self, sched: Scheduler, task: Task):
        pass


class NewTask(SystemCall):
    def __init__(self, target: Generator):
        self.target = target

    def handle(self, sched: Scheduler, task: Task):
        tid = sched.add_task(self.target)
        task.sendval = tid
        sched.schedule(task)

В Scheduler добавляется фрагмент:

class Scheduler:
    ...
    def _run_once(self):
        task = self.ready.get()
        try:
            result = task.run()
            if isinstance(result, SystemCall):
                result.handle(self, task)
                return
        except StopIteration:
            self.exit(task)
            return
        self.schedule(task)

А в Task — условие при выполнении корутин:

import types
from typing import Generator, Union

class Task:
    ...
    def run(self):
        while True:
            try:
                result = self.target.send(self.sendval)
                if isinstance(result, SystemCall):
                    return result
                ...

NewTask предоставляет интерфейс для создания новых задач в цикле событий и абстрагирует клиентский код. Это эмуляция защищённой среды ОС, предоставляющей безопасные методы для работы с ядром, чтобы клиентский код не мешал другим программам, запущенным в системе. Аналогичным образом можно реализовать KillTask или WaitTask.

Последней проблемой являются блокирующие операции. Пока запущенная операция не завершится, цикл событий останавливается вместе с ней. Решается это на уровне сокетов: вызов socket.setblocking(False) переводит сокет в неблокирующий режим, и вместо ожидания он немедленно сообщает, что данных пока нет. Ожидать их будет селектор, сразу за всех.

Asyncio

asyncio является основной встроенной библиотекой для асинхронного программирования.

С версии Python 3.5 в языке присутствует синтаксис async/await. Он предоставляет «нативные» корутины — отдельную сущность языка, а не повторно использованный генератор. Благодаря разделению появились асинхронные генераторы, и асинхронный код стал работать быстрее.

Простая программа с async/await:

import random
import asyncio


async def func():
    r = random.random()
    await asyncio.sleep(r)
    return r


async def value():
    result = await func()
    print(result)


if __name__ == '__main__':
    asyncio.run(value())

Функция asyncio.run создаёт планировщик задач, устроенный по разобранным выше принципам, и закрывает его по завершении. Переключением между корутинами управляет await.

Основные функции asyncio:

  • gather выполняет переданный список корутин одновременно и дожидается результатов от всех.
  • sleep приостанавливает корутину на определённое количество секунд.
  • wait/wait_for дожидаются выполнения уже запущенной корутины.

Основные функции event_loop:

  • get_event_loop возвращает цикл событий текущего потока, создавая его при необходимости. В новом коде вместо связки get_event_loop и run_until_complete используется одна строка asyncio.run(...).
  • run_until_complete/run запускают и проверяют асинхронные функции.
  • shutdown_asyncgens корректно завершает выполнение цикла событий и всех корутин; о ней часто забывают.
  • call_soon ставит обычную функцию (не корутину) в очередь на ближайшую итерацию цикла и не ожидает её выполнения. Поставленная таким образом функция может бесконечно переставлять саму себя.

Ключевым отличием asyncio от предложенной реализации является то, что asyncio работает на функциях обратного вызова, колбэках (callback). Этот механизм распределяет время между задачами справедливее. Каждая корутина встаёт в очередь и дожидается исполнения, тогда как в простом планировщике переключения не произойдёт, пока вся цепочка корутин не выполнится, а остальные задачи, поставленные в очередь, всё это время простаивают. Недостатком колбэков является callback hell, когда после вызова каждой функции необходимо вызвать ещё одну функцию и ещё одну:

func1.add_callback(
    func2.add_callback(
                func3.add_callback(func4)
        )
) 

Синтаксис async/await позволяет этого избежать.

await func4()
await func3()
await func2()
await func1() 

Это возможно благодаря классу Future, скрывающему колбэки и делающему код линейным. Создавать Future вручную в современном коде почти не приходится: это выполняют create_task и gather.

Асинхронные фреймворки

Поверх asyncio (а иногда и в обход него) сформировалась экосистема. Физику она необходима, когда вокруг готового расчёта требуется построить сервис: принимать данные с прибора, передавать результаты коллегам, обращаться к базе лаборатории. Ниже приведены три характерных представителя.

Twisted

Один из старейших асинхронных фреймворков, построенный на собственной реализации event-loop.

Основные концепции:

  1. Protocol, описание получения и отправки данных
  2. Factory, управление созданием объектов протокола
  3. Reactor, собственная реализация event-loop
  4. Deferred-объекты, цепочки обратных вызовов

Пример Deferred-объекта:

from twisted.internet import defer

def toint(data):
    return int(data)

def increment_number(data):
    return data + 1

def print_result(data):
    print(data)

def handleFailure(f):
    print("OOPS!")

def get_deferred():
    d = defer.Deferred()
    return d.addCallbacks(toint, handleFailure)\
           .addCallbacks(increment_number, handleFailure)\
           .addCallback(print_result)

Aiohttp

Асинхронные HTTP-клиент и сервер, построенные поверх asyncio.

Пример приложения:

import aiohttp
from aiohttp import web

async def get_phrase():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://fish-text.ru/get', 
                             params={'type': 'title'}) as response:
            result = await response.json(content_type='text/html; charset=utf-8')
            return result.get('text')

async def index_handler(request):
    return web.Response(text=await get_phrase())

async def response_signal(request, response):
    response.text = response.text.upper()
    return response

async def make_app():
    app = web.Application()
    app.on_response_prepare.append(response_signal)
    app.add_routes([web.get('/', index_handler)])
    return app

web.run_app(make_app())

FastAPI

Современный фреймворк для быстрой разработки API, построенный на Starlette и Pydantic.

Простой пример API:

from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI(title="Простые математические операции")

class Add(BaseModel):
    first_number: int = Field(title='Первое слагаемое')
    second_number: Optional[int] = Field(None, title='Второе слагаемое')

class Result(BaseModel):
    result: int = Field(title='Результат')

@app.post("/add", response_model=Result)
async def create_item(item: Add):
    return {
        'result': item.first_number + (item.second_number or 1)
    }

Резюме

Асинхронность не является универсальным ускорителем; это инструмент, предназначенный для задач, в которых программа ожидает. Для расчётов она бесполезна. Одна корутина, надолго занявшая процессор, остановит весь цикл событий вместе с очередью, накопленной к этому моменту: поток по-прежнему один.

Выбор инструмента определяется следующим образом:

  • задача ожидает сеть, диск или прибор — применяется асинхронность, выигрыш может составлять десятки раз;
  • задача вычисляет — применяются процессы (multiprocessing), векторизация NumPy или компиляция, разобранные в предыдущих главах;
  • и то и другое — применяется цикл событий для ожиданий в сочетании с пулом процессов для расчётов через loop.run_in_executor.

В асинхронном коде не должно быть блокирующих вызовов. Одна time.sleep() или синхронный запрос к базе останавливает не свою корутину, а всю программу.

CUDA и вычисления на GPU

В главе «Скорость выполнения программ» рассматривалось ускорение умножения матриц на CPU: перестановка циклов, компиляция кода через Numba и Cython и, в завершение, вызов NumPy, давший микросекунды вместо десятков миллисекунд, затраченных наивным циклом. В главе про GIL было установлено, что потоки в Python упираются в глобальную блокировку, а процессов, занятых вычислениями, имеет смысл держать столько, сколько ядер, то есть восемь-шестнадцать. Это предел CPU.

В той же машине установлена видеокарта. У современного GPU тысячи ядер, а пиковая производительность составляет десятки терафлопс против единиц у процессора. Если задача сводится к одной операции, повторяющейся над миллионами чисел (а в физике это почти всегда так), GPU даёт ускорение в 10–100 раз. Настоящая глава посвящена тому, как получить это ускорение из Python.

Назначение видеокарты в расчётах

GPU был создан для игр: необходимо независимо вычислить цвет каждого из миллионов пикселей 60 раз в секунду. Схема «одна формула на миллионах независимых элементов» встречается не только в пикселях:

  • Моделирование частиц. Метод частиц в ячейках (PIC), молекулярная динамика, трекинг пучка в ускорителе оперируют миллионами частиц, и каждая на каждом шаге движется по уравнениям, одинаковым для всех.
  • Сеточные задачи. Уравнения теплопроводности, Пуассона, Максвелла, Навье–Стокса, решаемые на разностных сетках: значение в каждом узле пересчитывается по шаблону, определяемому разностной схемой.
  • Обработка данных детекторов. Реконструкция треков, фильтрация изображений, томография сводятся к конвейерам, выполняющим однотипные операции над массивами, снятыми с детектора. Триггерные фермы, установленные на LHC, давно вычисляют на GPU.
  • Монте-Карло. Транспорт излучения, генерация событий: миллионы независимых историй, разыгранных по одному алгоритму.
  • Нейросети. Обучение сводится к миллиардам умножений матриц; без GPU глубокое обучение не состоялось бы.

Общим для этих задач является массовый параллелизм данных. Много элементов, над каждым выполняется одинаковая и относительно простая работа, и элементы друг от друга почти не зависят. Под этот профиль GPU и предназначен.

Чем GPU отличается от CPU

CPU оптимизирован под латентность, то есть под выполнение одной цепочки инструкций за минимальное время. Ради этого в ядре предусмотрены предсказатель переходов, внеочередное исполнение и большие кеши. GPU оптимизирован под пропускную способность, то есть под выполнение максимального объёма работы в секунду, пусть каждая операция, взятая отдельно, и потребует больше времени. Ядра простые, зато их тысячи.

CPUGPU
Ядер4–64 сложныхтысячи простых (у RTX 4090 их 16 384)
Частота3–5 ГГц1–2.5 ГГц
Логика ядрапредсказание переходов, внеочередное исполнениемаксимально простая
Скрытие задержек памятибольшие кешитысячи нитей: пока одни ждут память, считают другие
Полоса памяти~50–100 ГБ/с (DDR5)от сотен ГБ/с до единиц ТБ/с (GDDR6X, HBM)
Оптимизирован подлатентность одной задачисуммарную пропускную способность
Идеальная нагрузкаветвистая, последовательнаяоднотипная над большими массивами

SIMT и warp

Ядра GPU не полностью независимы. Нити (threads) исполняются группами по 32, и группа называется warp. Все нити warp'а в каждый момент выполняют одну и ту же инструкцию над разными данными. Модель называется SIMT (Single Instruction, Multiple Threads). От SIMD, где одна инструкция применяется к вектору данных, она отличается тем, что нить здесь является самостоятельным потоком управления и может перейти по собственной ветке; отсюда возникает дивергенция, о которой сказано ниже.

Если внутри warp'а код ветвится (if), и часть нитей перешла по одной ветке, а часть по другой, GPU выполнит обе ветки по очереди, отключая нити, не принадлежащие текущей ветке. Это называется дивергенцией warp'а. Ветвистый код на GPU не ускоряется, а деградирует; хорошей GPU-программой является та, в которой 32 соседние нити выполняют одно и то же.

Иерархия памяти

Память GPU устроена ступенями, и разница между ними составляет порядки:

  • Регистры, отведённые каждой нити отдельно, доступ за ~1 такт. Локальные переменные, объявленные в ядре-функции (kernel, определение ниже), размещаются здесь.
  • Разделяемая память (shared memory), общая для блока нитей, около 100 КБ, приходящихся на мультипроцессор, доступ за ~10–30 тактов. Это кеш, управляемый вручную: сюда заранее загружается фрагмент данных, читаемый блоком многократно.
  • Глобальная память, гигабайты, установленные на видеокарте. Видна всем нитям, полоса огромная (сотни ГБ/с), но латентность достигает сотен тактов.

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

Модель CUDA

CUDA является платформой NVIDIA для вычислений общего назначения на GPU. Её терминология используется во всех инструментах для GPU.

  • Host объединяет CPU и оперативную память, а Device объединяет GPU и его память. У них разные адресные пространства; данные из одного необходимо явно копировать в другой.
  • Ядром (kernel) называется функция, которую host отправляет на исполнение в device. Она исполняется одновременно тысячами нитей, и каждая нить выполняет один и тот же код, но обрабатывает свой элемент данных.
  • Нити организованы в двухуровневую иерархию: сетка (grid) → блоки (blocks) → нити (threads).
Grid (сетка — весь запуск ядра)
├── Block 0 ── [нить 0][нить 1] ... [нить 255]
├── Block 1 ── [нить 0][нить 1] ... [нить 255]
├── Block 2 ── [нить 0][нить 1] ... [нить 255]
└── ...

глобальный индекс нити = blockIdx.x * blockDim.x + threadIdx.x

Блок целиком попадает на один мультипроцессор, поэтому его нити могут взаимодействовать через разделяемую память и синхронизироваться между собой. Блоки же друг о друге ничего не знают, и планировщик распределяет блоки по свободным мультипроцессорам произвольным образом. Поэтому одна и та же программа масштабируется и на видеокарту ноутбука с 20 мультипроцессорами, и на серверную со 130.

Внутри ядра нить в первую очередь определяет свой индекс. У неё есть номер блока blockIdx, размер блока blockDim и номер внутри блока threadIdx. Глобальный индекс собирается из этих трёх чисел, которые предоставляет среда исполнения.

i = blockIdx.x * blockDim.x + threadIdx.x

Например, нить номер 5 в блоке номер 3 при блоках по 256 нитей получит i = 3 * 256 + 5 = 773 и будет обрабатывать 773-й элемент. Цикл for i in range(n), привычный по CPU-коду, на GPU исчезает: вместо него запускается n нитей, и каждая выполняет одну итерацию.

Главное правило: данные должны жить на GPU

У GPU собственная память, и соединена она с host'ом шиной PCIe, обеспечивающей полосу порядка 10–30 ГБ/с. Полоса памяти самого GPU составляет около терабайта в секунду. Копирование массива host → device часто обходится дороже всех вычислений над ним. Насколько дороже, определяется количеством арифметики, приходящейся на каждый переданный байт.

Рассмотрим гигабайт данных на host'е. Пересылка через PCIe займёт около 50 мс. Поэлементная операция вида \(y = 2x\) прочитает и запишет два гигабайта по внутренней полосе GPU и завершится за 2 мс. Вычисление в двадцать пять раз дешевле пересылки, и передавать массив туда и обратно бессмысленно. Умножение матриц представляет собой противоположный случай. Гигабайт float32 — это матрица \(16384 \times 16384\), её умножение требует \(2n^3 \approx 8{,}8\) терафлоп и занимает сотни миллисекунд даже на быстрой карте, то есть на порядок дороже пересылки.

Разница состоит в том, что умножение матриц выполняет \(O(n^3)\) работы над \(O(n^2)\) данных, а поэлементная операция выполняет \(O(n)\) работы над \(O(n)\) данных. Чем выше отношение работы к данным, тем легче окупается копирование в начале расчёта.

Отсюда следует антипаттерн номер один:

плохо:  [CPU: данные] → GPU → шаг → [CPU] → GPU → шаг → [CPU] → ...
хорошо: [CPU: данные] → GPU → шаг → шаг → шаг → ... → [CPU: результат]

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

CuPy: NumPy на видеокарте

Самым коротким путём к GPU из Python является CuPy. Это реализация интерфейса NumPy поверх CUDA, предоставляющая те же ndarray, sum, dot, fft, linalg, только массивы размещаются в памяти видеокарты, а операции над ними выполняет GPU.

import cupy as cp

x = cp.arange(10**7, dtype=cp.float32)   # массив рождается сразу в памяти GPU
y = cp.sin(x) ** 2 + cp.cos(x) ** 2      # считает GPU, host только раздаёт команды
s = y.sum()                              # редукция тоже на GPU, результат — 0-мерный массив

y_host = cp.asnumpy(y)                   # явное копирование device → host (в NumPy-массив)
x_gpu = cp.asarray(y_host)               # и обратно, host → device

Разработчик, владеющий векторизованным NumPy-кодом, уже владеет программированием GPU. В большинстве скриптов достаточно заменить import numpy as np на import cupy as cp.

Проверим это на умножении матриц.

import numpy as np
import cupy as cp
from time import perf_counter

n = 4096
A = np.random.rand(n, n).astype(np.float32)
B = np.random.rand(n, n).astype(np.float32)

# --- CPU ---
t0 = perf_counter()
C = A @ B
t_cpu = perf_counter() - t0

# --- GPU ---
A_gpu = cp.asarray(A)                  # копируем данные на видеокарту один раз
B_gpu = cp.asarray(B)

C_gpu = A_gpu @ B_gpu                  # прогрев: первый вызов компилирует ядра
cp.cuda.Stream.null.synchronize()

t0 = perf_counter()
C_gpu = A_gpu @ B_gpu
cp.cuda.Stream.null.synchronize()      # дожидаемся, пока GPU реально досчитает
t_gpu = perf_counter() - t0

print(f"CPU: {t_cpu:.3f} c, GPU: {t_gpu:.4f} c, ускорение x{t_cpu / t_gpu:.0f}")

На бесплатной T4, предоставляемой в Colab, это даёт ускорение в один-два порядка относительно NumPy, притом что сам NumPy уже упирается в предел процессора.

Два замечания относительно замеров на карте:

  • Запуск ядер асинхронный. Вызов A_gpu @ B_gpu ставит работу в очередь GPU и возвращается немедленно. Если измерить время без cp.cuda.Stream.null.synchronize(), получатся микросекунды — время постановки в очередь, а не вычислений. Синхронизация происходит и неявно, при копировании результата на host или вызове float(s), поэтому обычный код работает корректно; в бенчмарках синхронизация выполняется явно.
  • Первый вызов уходит на прогрев. При первом обращении CuPy компилирует ядра под конкретную видеокарту, на что уходят доли секунды. Замер выполняется со второго раза (как и у Numba).

Функция cp.get_array_module(x) возвращает numpy или cupy в зависимости от того, где размещён массив x. Так пишутся библиотечные функции, одинаково работающие и на CPU, и на GPU.

Numba CUDA: собственное ядро

CuPy покрывает всё, что выражается операциями над целыми массивами. Иногда алгоритм в них не укладывается: нестандартная поэлементная логика или собственный обход данных. В главе про профилирование в такой ситуации применялся декоратор @numba.jit к функции с циклами. У Numba есть и второй бэкенд: декоратор @cuda.jit компилирует Python-функцию в CUDA-ядро.

Каноническим примером является сложение векторов.

import numpy as np
from numba import cuda

@cuda.jit
def add_kernel(a, b, out):
    i = cuda.grid(1)              # глобальный индекс нити в одномерной сетке
    if i < out.size:              # нитей запущено чуть больше, чем элементов
        out[i] = a[i] + b[i]      # каждая нить обрабатывает ровно один элемент

n = 10_000_000
a = np.random.rand(n).astype(np.float32)
b = np.random.rand(n).astype(np.float32)

# явно переносим данные на device и заводим там массив под результат
d_a = cuda.to_device(a)
d_b = cuda.to_device(b)
d_out = cuda.device_array_like(d_a)

# конфигурация запуска: сколько нитей в блоке и сколько блоков в сетке
threads_per_block = 256
blocks_per_grid = (n + threads_per_block - 1) // threads_per_block  # округление вверх

add_kernel[blocks_per_grid, threads_per_block](d_a, d_b, d_out)
cuda.synchronize()                # дожидаемся завершения (запуск, как всегда, асинхронный)

out = d_out.copy_to_host()        # забираем результат на CPU

Разберём по шагам:

  • Тело ядра содержит код одной нити. Циклов по элементам нет: параллелизм создаётся миллионами запущенных нитей. cuda.grid(1) является сокращением для cuda.blockIdx.x * cuda.blockDim.x + cuda.threadIdx.x, формулы глобального индекса.
  • Проверка if i < out.size обязательна. Число запущенных нитей кратно размеру блока, поэтому для n = 10^7 при блоках по 256 запустится 39 063 блока, то есть 10 000 128 нитей, на 128 больше, чем элементов. Лишние нити без проверки обратились бы за границу массива.
  • threads_per_block обычно выбирается в диапазоне 128–512 (максимум 1024, предпочтительно кратно 32, размеру warp'а). Точное значение подбирается замерами; для начала 256 является хорошим выбором.
  • blocks_per_grid вычисляется из размера задачи: классическая формула округления вверх (n + tpb - 1) // tpb.
  • Конфигурация в квадратных скобках kernel[blocks, threads](...) сообщает CUDA через Numba, сколько нитей запустить.
  • Ядро ничего не возвращает, а способно только записывать в переданные ему массивы, поэтому d_out создаётся заранее.

Если передать в ядро обычные NumPy-массивы, Numba скопирует их на GPU и обратно самостоятельно, что удобно для первых проб, но в цикле по шагам это антипаттерн с копированием. Явные cuda.to_device / copy_to_host позволяют контролировать трафик через PCIe. Ядра Numba принимают и массивы CuPy.

Внутри @cuda.jit-функции доступно ограниченное подмножество Python: числа, арифметика, циклы, условия, обращение к массивам, математика из math. Списки, словари и создание массивов недоступны, как и в nopython-режиме обычной Numba, только ограничения строже.

Сетки и блоки бывают не только одномерными. Для матриц, изображений с детекторов и разностных сеток удобны двумерные: нить получает пару индексов и обрабатывает свой узел. Ядро, вычисляющее дискретный лапласиан для уравнений теплопроводности и Пуассона:

@cuda.jit
def laplace_kernel(u, out):
    i, j = cuda.grid(2)                   # у нити теперь два индекса: строка и столбец
    if 0 < i < u.shape[0] - 1 and 0 < j < u.shape[1] - 1:   # только внутренние узлы
        out[i, j] = (u[i + 1, j] + u[i - 1, j] +
                     u[i, j + 1] + u[i, j - 1] - 4 * u[i, j])

threads_2d = (16, 16)                     # блок 16x16 = 256 нитей
blocks_2d = ((nx + 15) // 16, (ny + 15) // 16)   # покрываем сетку nx на ny узлов
laplace_kernel[blocks_2d, threads_2d](d_u, d_lap)

Каждая нить читает пять соседних узлов и записывает один, а GPU повторяет это для всех узлов сетки. Явная разностная схема для двумерной теплопроводности отсюда получается в один шаг: u += alpha * dt / h**2 * lap.

Пример из физики: дрейф частиц в скрещенных полях

Рассмотрим облако из пяти миллионов электронов в скрещенных полях \( \vec{E} = (E_0, 0, 0) \) и \( \vec{B} = (0, 0, B_0) \) и проинтегрируем их движение схемой Бориса, стандартной в вычислительной физике плазмы (родственная ей схема применялась в примере REDPIC). Согласно теории, помимо ларморовского вращения облако как целое дрейфует поперёк обоих полей со скоростью \( E_0 / B_0 \), не зависящей ни от заряда, ни от массы.

import cupy as cp   # замени на "import numpy as cp" — и тот же код посчитает CPU

# --- параметры задачи (СИ) ---
q, m = -1.602e-19, 9.109e-31          # заряд и масса электрона
E0, B0 = 1e3, 1e-2                    # поля: В/м и Тл
dt, n_steps = 1e-11, 2000             # шаг 10 пс, всего 20 нс (~5 ларморовских периодов)
N = 5_000_000                         # пять миллионов частиц

# --- начальное состояние: облако ~1 мм с тепловым разбросом скоростей ---
# все массивы создаются сразу в памяти GPU и живут там до конца расчёта
rng = cp.random.default_rng(42)
x = rng.standard_normal(N, dtype=cp.float32) * 1e-3    # м
y = rng.standard_normal(N, dtype=cp.float32) * 1e-3
vx = rng.standard_normal(N, dtype=cp.float32) * 1e5    # м/с
vy = rng.standard_normal(N, dtype=cp.float32) * 1e5

# --- константы схемы Бориса: обычные числа, считаются один раз на CPU ---
h = q * dt / (2 * m)                  # q·dt/2m
t = h * B0                            # tan(θ/2) поворота в магнитном поле за шаг
s = 2 * t / (1 + t * t)

for step in range(n_steps):
    vx += h * E0                      # полшага разгона электрическим полем
    px = vx + vy * t                  # поворот скорости вокруг B:
    py = vy - vx * t                  #   промежуточный вектор v'
    vx, vy = vx + py * s, vy - px * s #   точный поворот на угол ω_c·dt
    vx += h * E0                      # ещё полшага разгона
    x += vx * dt                      # сдвигаем частицы
    y += vy * dt

# --- диагностика: на host уходят только два числа, не массивы ---
v_drift = float(y.mean()) / (n_steps * dt)   # float() неявно синхронизирует GPU
energy = float(0.5 * m * (vx**2 + vy**2).mean())
print(f"скорость дрейфа: {v_drift:.3e} м/с (теория: {-E0 / B0:.3e})")

Четыре существенных момента:

  • Данные создаются на GPU (cp.random) и не покидают его все 2000 шагов. На host передаются два числа диагностики, вычисленные там же; только в этот момент host дожидается GPU.
  • Физика умещается в несколько строк массивных операций. Каждая строка цикла разворачивается в одно-два CUDA-ядра над пятью миллионами элементов. Сам цикл по шагам остаётся на CPU: 2000 итераций дёшевы, дорогая работа выполняется внутри.
  • Тот же код работает на CPU. Замена первой строки даёт NumPy-версию, пригодную для отладки на ноутбуке без видеокарты. На GPU она выполняется в десятки раз быстрее.
  • float32. Для такой задачи одинарной точности достаточно, а на игровых и облачных картах она в разы быстрее двойной (об этом сказано ниже).

Заготовку можно развивать: неоднородные поля (поле зависит от координаты частицы, но это по-прежнему поэлементные операции), потери и рождение частиц (маски), самосогласованное поле (гистограмма зарядов + Пуассон через cp.fft). Всё это остаётся в рамках CuPy.

Границы применимости GPU

Ситуации, в которых GPU бесполезен или вреден:

  • Малые данные. Запуск ядра стоит ~5–10 микросекунд, а копирование через PCIe обходится ещё дороже. Если работы меньше чем на миллисекунду, накладные расходы поглотят выигрыш. Массивы из тысячи элементов NumPy сложит быстрее.
  • Ветвистая логика. Из-за дивергенции warp'ов код, в котором каждый элемент обрабатывается по-своему (деревья решений, разбор текста, сложные if-каскады), параллелится плохо.
  • Последовательные зависимости. Если шаг i+1 невозможно начать без результата шага i, а внутри шага работы мало, параллелить нечего. Одна частица на миллиард шагов остаётся задачей для CPU и Numba. Однако миллион независимых траекторий по миллиарду шагов является идеальной задачей для GPU; в физике часто распараллеливают по ансамблю или по параметрам, а не по времени.
  • Двойная точность на игровых картах. GeForce вычисляет float64 в 32–64 раза медленнее float32, так как блоки FP64 там почти отсутствуют. Полноценный FP64 (половина от FP32) присутствует только у вычислительных карт наподобие A100/H100. Если задаче (небесная механика, длинное интегрирование, плохо обусловленная алгебра) требуется двойная точность, её необходимо измерить отдельно.
  • Задачи про ожидание, а не про вычисления. Если программа ожидает сеть или диск, имеет смысл обратиться к главе про асинхронность; GPU не поможет.

Прежде чем переписывать на GPU, необходимо выполнить профилирование (см. главу про профилирование) и оценить арифметическую интенсивность, то есть число операций, приходящихся на каждый байт данных. Умножение матриц подходит отлично, а поэлементное сложение уже упирается в память, а не в вычислители.

Работа без собственной видеокарты

Бесплатный GPU предоставляется в Google Colab:

  1. Создаётся новый блокнот (это обычный Jupyter в облаке).
  2. В меню Среда выполнения → Сменить среду выполнения (Runtime → Change runtime type) выбирается T4 GPU и сохраняется.
  3. Проверяется наличие карты.
!nvidia-smi

Команда покажет модель GPU (обычно Tesla T4 с 16 ГБ памяти), версию драйвера и загрузку. CuPy, Numba и PyTorch в Colab уже установлены, и все примеры главы запускаются там без pip install. На бесплатном тарифе сессия живёт несколько часов и при простое отключается; для учёбы и прототипов этого достаточно. Похожий бесплатный GPU предоставляют Kaggle Notebooks, а для серьёзных расчётов у института, скорее всего, есть кластер с вычислительными картами, о котором целесообразно узнать у научного руководителя.

Экосистема: не CUDA единой

Писать ядра вручную требуется редко. Вокруг CUDA сформировался слой библиотек. PyTorch и JAX предоставляют NumPy на видеокарте в сочетании с автоматическим дифференцированием и JIT-компиляцией. Физики используют их как вычислительный бэкенд, не только для нейросетей. Перенос тензора на карту умещается в одно .to("cuda"), а jax.jit и jax.vmap компилируют и векторизуют целые модели. Библиотеки RAPIDS (cuDF, cuML) переносят на GPU pandas и scikit-learn, когда узким местом является не физика, а обработка таблиц с установки. В основе всех этих библиотек лежат те же ядра, сетки и блоки, поэтому модель CUDA пригодится в любой из них. Если пределы Python окажутся достигнуты, остаётся CUDA C++ с теми же концепциями.

Итог: лестница ускорения

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

  1. Профилирование. Пока line_profiler не показал, куда уходит время, оптимизация на глаз является гаданием.
  2. Проверка алгоритма. Никакое аппаратное обеспечение не спасёт \( O(n^2) \) там, где существует \( O(n \log n) \).
  3. Векторизация. NumPy вместо циклов обычно даёт первый и самый дешёвый порядок ускорения.
  4. Компиляция. Numba или Cython — для логики, не уложившейся в массивные операции.
  5. Распараллеливание на CPU. Процессы — для вычислений (GIL!), асинхронность — когда узким местом является ожидание, а не вычисления.
  6. Перенос на GPU. Однотипная работа над большими массивами — CuPy, собственная поэлементная логика — Numba CUDA. Два правила: данные живут на GPU, замеры — с явной синхронизацией.

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

Полезные ссылки

  • CUDA C++ Programming Guide, первоисточник: модель исполнения, иерархия памяти, все детали.
  • Документация CuPy, включая таблицу соответствия функций NumPy ↔ CuPy и советы по производительности.
  • Numba for CUDA GPUs, справочник по @cuda.jit: ядра, разделяемая память, атомарные операции.
  • NVIDIA Deep Learning Institute, курс «Fundamentals of Accelerated Computing with CUDA Python» про Numba и CuPy.

Коды и скрипты

Всё, изложенное в предыдущих частях, объединяется в настоящей части.

В ней разбираются реальные программы, а не примеры, придуманные для учебника: почти все они выполняют физические расчёты в институтах СО РАН, а последняя, веб-сервис без физического содержания, приведена ради одной архитектуры. Каждая из программ имеет свою историю и свои компромиссы, продиктованные задачей: в одном случае важнее скорость, в другом точность, в третьем возможность объяснить результат коллеге спустя год.

Сначала решается уравнение огибающей пучка, после чего та же задача рассматривается с другой стороны, через генетический алгоритм. Далее разбирается разностная схема для динамики частиц в электрических и магнитных полях. Показано, как матрица отклика и нейронная сеть совместно осуществляют коррекцию орбиты в ускорителе. Кодом строится трёхмерная модель установки, собранная из типовых элементов. Рассматриваются цифровой двойник ускорителя, мультиагентный помощник оператора и ограничения автономного исследователя на языковых моделях. В заключение разбирается устройство асинхронного веб-сервиса, поскольку данные эксперимента сегодня чаще передаются по HTTP, чем копируются на внешний носитель.

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

Уравнение огибающей Капчинского—Владимирского для пучка заряженных частиц

В настоящей главе сначала рассматривается теория, а затем на Python выполняется расчёт огибающей электронного пучка в линейном ускорителе, состоящем из соленоидов и резонаторов. Выкладки следуют классическим монографиям по физике пучков заряженных частиц [32, 33]; подробности, опущенные здесь, приведены там.

Уравнения Максвелла

Объёмная плотность тока пучка \(\jmath = \rho\upsilon\), где \(\rho\) — объёмная плотность заряда, \(\upsilon\) — скорость пучка. Запишем дифференциальные уравнения Максвелла. $$ \nabla \vec{D} = 4\pi\rho, $$ $$ \nabla\times \vec{H} = \frac{4\pi\vec{\jmath}}{c}, $$ где \(\vec{D}\) — индукция электрического поля, \(\vec{H}\) — напряжённость магнитного поля, \(c\) — скорость света. Используем теорему Стокса об интегрировании дифференциальных форм, чтобы получить уравнения Максвелла, записанные в интегральной форме. $$ \oint\limits_{\partial V} \vec{D}\vec{dS} = 4\pi\int\limits_V\rho{dV}, $$ $$ \oint\limits_{\partial S} \vec{H}\vec{dl} = \frac{4\pi}{c}\int\limits_S\vec{\jmath}\vec{dS}. $$

Найдём \(D_r\) для цилиндрического пучка радиуса \(a\), заполненного зарядом с постоянной плотностью \(\rho_0\). $$ D_r = \frac{4\pi}{r}\int\limits^r_0\rho(\xi)\xi d\xi = \begin{equation*} \begin{cases} \displaystyle 2\pi\rho_0 r, r < a, \ \displaystyle \frac{2\pi\rho_0 a^2}{r}, r > a. \end{cases} \end{equation*} $$

Учитывая, что в вакууме \(D = E\), \(H = B\), \(E\) — напряжённость электрического поля, \(B\) — индукция магнитного поля, и в плоском пространстве в декартовой системе координат \(H_\alpha = \beta D_r\), где \(\displaystyle\beta = \frac{\upsilon}{c}\), радиальная компонента силы \(F_r\) из силы Лоренца \(\vec{F} = e\vec{E} + \displaystyle\frac{e}{c}\vec{\upsilon}\times\vec{B}\) записывается так. $$ F_r = eE_r - \frac{e\upsilon_z B_\alpha}{c} = eE_r(1-\displaystyle \frac{\upsilon^2}{c^2}) = \displaystyle \frac{eE_r}{\gamma^2}. $$ Полезно выразить поле через ток $$ I = \rho_0\upsilon\pi a^2, $$ тогда $$ E_r = \begin{equation*} \begin{cases} \displaystyle \frac{2 I r}{a^2\upsilon}, r < a, \ \displaystyle \frac{2 I}{r\upsilon}, r > a. \end{cases} \end{equation*} $$

Уравнения движения

Второй закон Ньютона \(\dot p_r = F_r\), используем параксиальное приближение, считая \(\gamma = const\). $$ \gamma m \ddot r = \displaystyle \frac{eE_r}{\gamma^2} = \displaystyle \frac{2 I e}{\gamma^2 a^2 \upsilon} r, $$ получено линейное уравнение, однако, поскольку движутся все частицы сразу, необходимо учесть, что \(a = a(t)\). Решение линейного уравнения, выписанного выше, можно представить как линейное преобразование фазовой плоскости, а поскольку отрезок на фазовой плоскости при невырожденном линейном преобразовании переходит в отрезок, его можно охарактеризовать одной точкой. Поэтому выберем крайнюю точку \(r = a\), которая и представляет крайнюю траекторию. $$ \gamma m \ddot r = \displaystyle \frac{2 I e}{\gamma^2 a \upsilon}. $$ Перейдём к дифференцированию по \(z\), учитывая, что \(\displaystyle dt = \frac{dz}{v}\), и тогда $$ a'' = \displaystyle \frac{e}{a}\frac{2I}{m\gamma^3\upsilon^3}. $$ Введём характерный альфвеновский ток \(I_a = \displaystyle \frac{mc^3}{e} \approx\) 17 кА, и тогда $$ a'' = \displaystyle \frac{2I}{I_a (\beta\gamma)^3} \frac{1}{a}. $$ Учтём внешнюю фокусировку, предполагая суперпозицию полей (что верно не всегда: в нелинейных средах суперпозиция не выполняется), и получим $$ a'' + k(z)a - \displaystyle \frac{2I}{I_a (\beta\gamma)^3} \frac{1}{a} = 0, $$ что напоминает уравнение огибающей $$ w'' + kw - \displaystyle \frac{1}{w^3} = 0 ,$$ где \( w =\displaystyle \sqrt \beta .\)

Уравнения огибающей для эллиптического пучка с распределением Капчинского—Владимирского с внешней фокусировкой линейными полями

Запишем распределение Капчинского—Владимирского. $$ f = A\delta(1 - \displaystyle\frac{\beta_x x'^2 + 2\alpha_x x x' + \gamma_x x^2}{\epsilon_x} - \displaystyle\frac{\beta_y y'^2 + 2\alpha_y y y' + \gamma_y y^2}{\epsilon_y} ), $$ где \(A\) — нормировочная постоянная, задаваемая полным числом частиц, а квадратичная форма \(\beta_x x'^2 + 2\alpha_x x x' + \gamma_x x^2\) и аналогичная форма по \(y\), стоящие под дельта-функцией, представляют собой инварианты Куранта—Снайдера, отнесённые к эмиттансам \(\epsilon_x\) и \(\epsilon_y\); дельта-функция помещает все частицы на поверхность постоянного инварианта. Полуоси эллипса равны $$ a = \sqrt{\epsilon_x \beta_x}, b = \sqrt{\epsilon_y \beta_y}. $$ Поле получается линейно внутри заряженного эллиптического цилиндра. $$ E_x = \displaystyle \frac{4I}{\upsilon}\frac{x}{a(a+b)}, $$ $$ E_y = \displaystyle \frac{4I}{\upsilon}\frac{y}{b(a+b)}. $$ Проверим, что \(\nabla \vec{E} = 4\pi\rho:\) $$ \displaystyle I = \rho \upsilon \pi ab, $$ $$ \nabla \vec{E} = \displaystyle \frac{4I(a+b)}{\upsilon(a+b)ab} = \displaystyle \frac{4I}{\upsilon ab} = 4\pi\rho. $$ Так как поля линейные, они складываются с полями фокусирующей линзы, рассчитанными отдельно. Подставим в уравнение огибающей \(\displaystyle a = \sqrt \epsilon_x w_x, b = \sqrt \epsilon_y w_y:\) $$ a'' + k_{xt} a - \frac{\epsilon_x^2}{a^3} = 0, $$ где \(k_{xt} = k_x - k_{xsc}\) — полная жёсткость, \(k_x\) — жёсткость линзы, а \(\displaystyle k_{xsc} = \frac{4I}{I_a (\beta\gamma)^3}\frac{1}{a(a+b)}.\) В итоге получаем систему уравнений, связанных через пространственный заряд. $$ \begin{equation*} \begin{cases} \displaystyle a'' + k_xa - \frac{4I}{I_a (\beta\gamma)^3}\frac{1}{(a+b)} - \frac{\epsilon_x^2}{a^3} = 0 , \\ \displaystyle b'' + k_yb - \frac{4I}{I_a (\beta\gamma)^3}\frac{1}{(a+b)} - \frac{\epsilon_y^2}{b^3} = 0. \end{cases} \end{equation*} $$

Количественный критерий применимости приближения ламинарности течения

Эта система учитывает два эффекта, мешающих сфокусировать пучок в точку: конечность эмиттанса и пространственный заряд. В уравнение они входят соседними членами, поэтому допускают непосредственное сравнение. При малом токе \(I\) отталкивание слабое, при большом — сильное, и тогда эмиттансом можно пренебречь, а течение считать ламинарным. Количественным критерием ламинарности является сравнение этих двух членов. $$ \displaystyle \sqrt{\frac{2I}{I_a(\beta\gamma)^3}} \gg \sqrt{\frac{\epsilon_x}{\beta_x}}. $$ Видно, что пространственный заряд сильнее сказывается там, где \(\beta_x\)-функция больше, а вблизи фокуса его влияние пренебрежимо мало.

Теорема Буша

Теорема Буша связывает угловую скорость заряженной частицы, движущейся в аксиально-симметричном магнитном поле, с магнитным потоком через окружность, проведённую через точку, где находится частица, с центром на оси.

Рассмотрим заряд \(q\), движущийся в магнитном поле \(\vec B = (B_r,0,B_z)\). Приравняем \(\theta\)-составляющую силы Лоренца к производной момента импульса по времени, делённой на \(r\). $$ F_\theta = -q(\dot r B_z - \dot z B_r) = \frac{d}{rdt}(\gamma m r^2 \dot \theta). $$ Поток, пронизывающий площадь, охваченную окружностью радиуса \(r\), центр которой расположен на оси, а сама она проходит через точку, в которой расположен заряд, записывается в виде \(\psi = \int\limits^r_0 2\pi r B_z dr\). Когда частица перемещается на \(\vec{dl} = (dr,dz)\), скорость изменения потока, охваченного этой окружностью, можно найти из второго уравнения Максвелла \(\nabla \vec{B} = 0.\) Таким образом, $$ \dot\psi = 2\pi r (-B_r \dot z + B_z \dot r). $$ После интегрирования по времени отсюда получаем $$ \dot \theta = (-\frac{q}{2\pi \gamma m r^2})(\psi - \psi_0). $$

Уравнение параксиального луча

Чтобы вывести уравнение параксиального луча для системы с аксиальной симметрией при уже сделанных допущениях, приравняем силу радиального ускорения электрическим и магнитным силам внешних полей. Здесь \(\gamma = \gamma(t).\) $$ \frac{d}{dt}(\gamma m \dot r) - \gamma m r (\dot \theta)^2 = q(E_r + r\dot \theta B_z). $$ Применим теорему Буша и независимость \(B_z\) от \(r:\) $$ -\dot \theta = \frac{q}{2\gamma m}(B_z - \frac{\psi_0}{\pi r^2}). $$ Исключим \(\dot \theta\) и подставим \(\displaystyle \dot \gamma \approx \frac {\beta q E_z }{mc}\). $$ \ddot r + \frac{\beta q E_z}{\gamma m c} \dot r + \frac{q^2 B^2_z}{4\gamma ^2 m^2} r - \frac{ q^2 \psi^2_0}{4\pi^2\gamma^2 m^2} (\frac{1}{r^3}) - \frac{q E_r}{\gamma m } = 0. $$

Уравнение огибающей для круглого и эллиптического пучка

Учитывая, что $$ \dot r = \beta c r', $$ $$ \ddot r = r'' (\dot z)^2 + r'\ddot z \approx r'' \beta^2 c^2 + r' \beta' \beta c^2. $$ Если же в области пучка зарядов нет, то, разлагая в ряд Тейлора в окрестности оси и оставляя только первый член, с учётом $$\nabla \vec{E} = 0$$ получаем $$ E_r = -0.5 r E'_z \approx - 0.5 r \gamma'' mc^2/q. $$ Теперь можно окончательно записать уравнение огибающей круглого пучка радиуса \(r\), взятого с распределением Капчинского—Владимирского, при внешней фокусировке линейными полями. $$ \displaystyle r'' + \frac{1}{\beta^2\gamma} \gamma' r' + \frac{1}{2\beta^2\gamma}\gamma''r + kr - \frac{2I}{I_a (\beta\gamma)^3}\frac{1}{r} - \frac{\epsilon^2}{r^3} = 0 ; $$ и для эллиптического пучка $$ \begin{equation*} \begin{cases} \displaystyle a'' + \frac{1}{\beta^2\gamma} \gamma' a' + \frac{1}{2\beta^2\gamma}\gamma''a + k_xa - \frac{4I}{I_a (\beta\gamma)^3}\frac{1}{(a+b)} - \frac{\epsilon_x^2}{a^3} = 0 , \\ \displaystyle b'' + \frac{1}{\beta^2\gamma} \gamma' b' + \frac{1}{2\beta^2\gamma}\gamma''b + k_yb - \frac{4I}{I_a (\beta\gamma)^3}\frac{1}{(a+b)} - \frac{\epsilon_y^2}{b^3} = 0. \end{cases} \end{equation*} $$

Магнитные линзы

Фокусировка пучка осуществляется соленоидами и магнитными квадрупольными линзами.

Соленоиды

\(k_x = k_y = k_s\) — жёсткость соленоида. $$ k_s = \left ( \frac{eB_z}{2m_ec\beta\gamma} \right )^2 = \left ( \frac{e B_z}{2\beta\gamma\cdot 0.511\cdot 10^6 e \cdot \mathrm{volt}/c} \right )^2 = \left ( \frac{cB_z[\mathrm{T}]}{2\beta\gamma\cdot 0.511\cdot 10^6 \cdot \mathrm{volt}} \right )^2. $$

Квадруполи

\(k_q = \displaystyle\frac{eG}{p}\) — жёсткость квадруполя, где \(G = \displaystyle\frac{\partial B_x}{\partial y} = \displaystyle\frac{\partial B_y}{\partial x}\) — градиент магнитного поля, причём \(k_x = k_q, k_y = -k_q.\) $$ k_q = \left ( \frac{eG}{m_ec\beta\gamma} \right ) = \left ( \frac{eG}{\beta\gamma\cdot 0.511\cdot 10^6 e \cdot \mathrm{volt}/c} \right ) = \left ( \frac{cG}{\beta\gamma\cdot 0.511\cdot 10^6 \cdot \mathrm{volt}} \right ). $$

Продольная динамика пучка

Продольную динамику удобно рассчитывать отдельно от поперечной, находя сначала энергию пучка как функцию \(z\), а затем подставляя её в уравнение огибающей в виде готовой функции. Скорость электрона достаточно близка к скорости света, поэтому его продольная координата \(z \approx ct\), а импульс \(p_z \approx \gamma mc\), и тогда

$$ \frac{d\gamma}{dz} \approx \frac{eE_z}{mc^2}, $$

откуда задача сводится к однократному интегрированию \(E_z(z)\) вдоль тракта. Численные значения подставляются в разделе с расчётом на Python.

Решение уравнения огибающей для эллиптического пучка с фокусирующими элементами

Уравнение огибающей эллиптического пучка с полуосями \(a, b\) и распределением Капчинского—Владимирского при внешней фокусировке линейными полями, выведенное выше, имеет следующий вид. $$ \begin{equation*} \begin{cases} \displaystyle a'' + \frac{1}{\beta^2\gamma} \gamma' a' + \frac{1}{2\beta^2\gamma}\gamma''a + k_qa - \frac{2P}{(a+b)} - \frac{\epsilon_x^2}{a^3} = 0 , \\ \displaystyle b'' + \frac{1}{\beta^2\gamma} \gamma' b' + \frac{1}{2\beta^2\gamma}\gamma''b - k_qb - \frac{2P}{(a+b)} - \frac{\epsilon_y^2}{b^3} = 0. \end{cases} \end{equation*} $$

Пусть \(\displaystyle x = \frac{da}{dz}, y = \frac{db}{dz}, \displaystyle \frac{d\gamma }{dz}\approx \frac{e E_z}{m c^2},\) тогда $$ \begin{matrix} \displaystyle \frac{dx}{dz}= - \frac{1}{\beta^2\gamma} \gamma' a' - \frac{1}{2\beta^2\gamma}\gamma''a - k_qa + \frac{2P}{(a+b)} + \frac{\epsilon_x^2}{a^3}\\ \displaystyle\frac{da}{dz} = x\\ \displaystyle \frac{dy}{dz}= - \frac{1}{\beta^2\gamma} \gamma' b' - \frac{1}{2\beta^2\gamma}\gamma''b + k_qb + \frac{2P}{(a+b)} + \frac{\epsilon_y^2}{b^3}\\ \displaystyle\frac{db}{dz} = y \end{matrix}. $$ Пусть

$$ \vec X = \begin{bmatrix} x \\ a \\ y \\ b \end{bmatrix}, $$

теперь составим дифференциальное уравнение \(X' = F(X)\).

Уравнение траектории для одной частицы

Векторный потенциал в соленоиде имеет только одну компоненту и выражается через заданное на оси поле \(B_z(z)\). $$ A_\phi (z,r)=\sum_{n=0}^{\infty} \dfrac{(-1)^n}{n!(n+1)!} B_z^{(2n)}(z)\left(\dfrac{r}{2}\right)^{2n+1}. $$ Здесь \(B_z^{(2n)}\) — производная порядка \(2n\) от поля на оси, так что нулевой член даёт \(A_\phi = B_z(z),r/2\), из которого дальше и получается параксиальное приближение. Следовательно, лагранжиан в цилиндрической системе координат имеет вид $$ L = -mc^2(1-\beta^2)^{1/2} + \dfrac{e}{c}A_\phi r \dot\phi. $$ Из аксиальной симметрии следует существование интеграла движения, связанного с обобщённым импульсом. $$ P_\phi = \gamma mr^2\dot\phi + \dfrac{e}{c} r A_\phi = \gamma mr^2\dot\phi_0. $$ Продифференцировав по \(z\), получим $$ \phi' - \phi_0' = -\dfrac{e}{pc}\dfrac{A_\phi}{r}. $$ И в параксиальном приближении $$ \phi' - \phi_0' = -\dfrac{e}{2pc}B_z(z). $$ Откуда $$ \delta \phi = -\int\dfrac{e}{2pc}B_z(z)dz. $$ Тогда и уравнения траектории в приведённых координатах принимают следующий вид. $$ \begin{equation*} \begin{cases} \displaystyle \tilde x'' + \frac{1}{\beta^2\gamma} \gamma'\tilde x' + \frac{1}{2\beta^2\gamma}\gamma''\tilde x + k_s \tilde x = 0 , \\ \displaystyle \tilde y'' + \frac{1}{\beta^2\gamma} \gamma'\tilde y' + \frac{1}{2\beta^2\gamma}\gamma''\tilde y +k_s \tilde y = 0, \end{cases} \end{equation*} $$

Расчёт в Python

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

import numpy as np
import holoviews as hv
hv.extension('matplotlib')

%output size=200 backend='matplotlib' fig='svg'

%opts Curve Scatter Area [fontsize={'title':14, 'xlabel':14, 'ylabel':14, 'ticks':14, 'legend': 14}]

%opts Area Curve [aspect=3 show_grid=True]
%opts Area  (linewidth=1 alpha=0.25)
%opts Curve (linewidth=2 alpha=0.5)

import warnings
warnings.filterwarnings('ignore')

Зададим область счёта и шаг сетки вдоль оси ускорителя, от 0.7 до 15 метров с шагом 5 мм: на характерную ширину поля соленоида такой шаг даёт десятки точек, что позволяет безопасно интерполировать по ней.

dz = 0.005 # m
z_min = 0.7
z_max = 15
z = np.arange(z_min,z_max,dz) # m

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

class Element:
  def __init__(self, z0, MaxField, filename, name):
    self.z0 = z0
    self.MaxField = MaxField
    self.filename = filename
    self.name = name

Первыми задаются фокусирующие соленоиды. В таблице ниже первая колонка задаёт положение центра в метрах, вторая — максимум поля на оси в теслах. Три соленоида работают на полном поле (0.03 Тл), три практически выключены (1 мТл). Профиль \(B_z(z)\) у всех соленоидов один и тот же и хранится в общем файле: соленоиды одинаковы и различаются только током.

Bz_beamline = {}
for   z0,         B0,        filename,       name in [
    # m           T                          Unique name
    [ 0.95,       0.001,    'input/fields/B_z(z).dat', 'Sol. 1' ],
    [ 2.1,        0.03,     'input/fields/B_z(z).dat', 'Sol. 2' ],
    [ 2.9077,     0.001,    'input/fields/B_z(z).dat', 'Sol. 3' ],
    [ 4.0024,     0.03,     'input/fields/B_z(z).dat', 'Sol. 4' ],
    [ 5.642,      0.03,     'input/fields/B_z(z).dat', 'Sol. 5' ],
    [ 6.760,      0.001,    'input/fields/B_z(z).dat', 'Sol. 6' ],
]:
    Bz_beamline[name] = Element(z0, B0, filename, name)

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

Bz_beamline['Sol. 3'].z0
2.9077

Аналогично описываются ускоряющие резонаторы. Шесть резонаторов, каждый по −0.9 МВ/м, расположены через 1.382 метра во второй половине тракта; до седьмого метра пучок с входной энергией 2 МэВ только транспортируется, ускорение начинается дальше.

Ez_beamline = {}
for   z0,          E0,       filename,      name in [
    # m            MV/m                     Unique name
    [ 7.456,       -0.9,     'input/fields/E_z(z).dat',  'Cavity 3'],
    [ 8.838,       -0.9,     'input/fields/E_z(z).dat',  'Cavity 4'],
    [ 10.220,      -0.9,     'input/fields/E_z(z).dat',  'Cavity 5'],
    [ 11.602,      -0.9,     'input/fields/E_z(z).dat',  'Cavity 6'],
    [ 12.984,      -0.9,     'input/fields/E_z(z).dat',  'Cavity 7'],
    [ 14.366,      -0.9,     'input/fields/E_z(z).dat',  'Cavity 8'], 
]:
    Ez_beamline[name] = Element(z0, E0, filename, name)

Считывание профилей фокусирующего и ускоряющего поля

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

FieldFiles представляет собой простейший кеш: файл читается с диска один раз, а не по разу на каждый соленоид. fill_value=(0, 0) вместе с bounds_error=False заставляет интерполятор возвращать ноль за пределами профиля вместо возбуждения ошибки; без этого было бы невозможно суммировать элементы, разнесённые по тракту.

from scipy import interpolate

FieldFiles = {} # buffer for field files
def read_field(beamline, z):
    global FieldFiles
    F = 0
    for element in beamline.values():
        if not (element.filename in FieldFiles):
            print('Reading ' + element.filename)
            FieldFiles[element.filename] = np.loadtxt(element.filename)
        M = FieldFiles[element.filename]
        z_data = M[:,0]
        F_data = M[:,1]
        f = interpolate.interp1d(
            element.z0+z_data, element.MaxField*F_data,
            fill_value=(0, 0), bounds_error=False
        )
        F = F + f(z)
    return F

Рассчитаем суммарное поле шести соленоидов.

Bz = read_field(Bz_beamline, z)
Reading input/fields/B_z(z).dat

Строка «Reading …» напечатана один раз на шесть соленоидов, следовательно, кеш работает.

Решателю дифференциальных уравнений требуется не массив, а функция: он самостоятельно выбирает точки, в которых запрашивается поле, и эти точки не обязаны совпадать с узлами сетки. Поэтому массив Bz преобразуется в функцию Bz(z).

Bz = interpolate.interp1d(z, Bz, fill_value=(0, 0), bounds_error=False)

Проверим значение Bz вблизи центра второго соленоида.

Bz(2.11111)
array(0.02984419)

Получено около 30 мТл, что близко к максимуму второго соленоида, настроенного на 0.03 Тл, как и должно быть в 11 миллиметрах от его центра.

График суммарного поля \(B_z(z)\) позволяет убедиться, что соседние соленоиды не накладываются друг на друга и что в тракте не осталось протяжённых участков без фокусировки.

dim_z  = hv.Dimension('z',  unit='m', range=(0,z_max))
dim_Bz = hv.Dimension('Bz', unit='T', label='$B_z$')

z_Bz = hv.Area((z,Bz(z)), kdims=dim_z, vdims=dim_Bz)
z_Bz

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

Ez = 1*read_field(Ez_beamline, z)
Ez = interpolate.interp1d(z, Ez, fill_value=(0, 0), bounds_error=False)
dim_Ez = hv.Dimension('Ez', unit='MV/m', label=r'$E_z$')

z_Ez = hv.Area((z,Ez(z)), kdims=dim_z, vdims=dim_Ez)
z_Ez

Продольная динамика пучка: расчёт

Энергия пучка рассчитывается независимо от огибающей, а в уравнение огибающей подставляется готовая функция \(\gamma(z)\). Скорость электрона близка к скорости света, поэтому \(z \approx ct\) и \(p_z \approx \gamma mc\), откуда следует

$$ \frac{d\gamma}{dz} \approx \frac{eE_z}{mc^2}, $$

и задача сводится к однократному интегрированию \(E_z(z)\).

Энергия на входе составляет 2 МэВ; релятивистский фактор при этом равен около 4.9. mc — энергия покоя электрона в мегаэлектронвольтах, поэтому энергии и импульсы далее выражаются в тех же единицах.

mc = 0.511 # MeV, энергия покоя электрона
#p_z = 2.459 # MeV/c
E_0 = 2.000 # MeV
gamma_0 = E_0/mc + 1 # Начальный релятивистский фактор пучка
#gamma_0 = p_z/mc

Интегрирование выполняется функцией cumulative_trapezoid: она вычисляет накопленную сумму трапеций и даёт \(\gamma\) во всех точках сетки, кроме первой. Знак минус перед Ez компенсирует знак поля в резонаторах (-0.9 МВ/м), так что энергия по тракту растёт.

from scipy import integrate

gamma = gamma_0 + integrate.cumulative_trapezoid(-Ez(z)/mc, z)
dim_gamma = hv.Dimension('gamma', label=r'$\gamma$', range=(0,None))

z_gamma = hv.Curve((z,gamma), kdims=dim_z, vdims=dim_gamma)

Совместим на одном рисунке энергию и ускоряющее поле, что позволяет увидеть, где пучок набирает энергию.

(z_gamma + z_Ez).cols(1)

График \(\gamma(z)\) поднимается ступенями, по одной на резонатор, и остаётся постоянным в промежутках, где пучок движется по инерции.

Как и поля, массив gamma преобразуется в функцию. Срез z[1:] используется потому, что интегрирование по трапециям даёт на одну точку меньше, чем содержит сетка.

gamma = interpolate.interp1d(z[1:], gamma, fill_value=(gamma[0], gamma[-1]), bounds_error=False)
#hv.Curve((z,gamma(z)), kdims=dim_z, vdims=dim_gamma)

Решение уравнения огибающей для круглого пучка

Повторим уравнение огибающей для круглого пучка радиуса \(r\) с распределением Капчинского—Владимирского при внешней фокусировке линейными полями. $$ \displaystyle r'' + \frac{1}{\beta^2\gamma} \gamma' r' + \frac{1}{2\beta^2\gamma}\gamma''r + kr - \frac{2I}{I_a (\beta\gamma)^3}\frac{1}{r} - \frac{\epsilon^2}{r^3} = 0. $$

Перейдём к среднеквадратичному радиусу для распределения Капчинского—Владимирского и первеансу \(\displaystyle r_{rms} = \frac{r}{2}\) и \(\displaystyle P = \frac{2I}{I_a\beta^3\gamma^3} \). Получим $$ \displaystyle r_{rms}'' + \frac{1}{\beta^2\gamma} \gamma' r_{rms}' + \frac{1}{2\beta^2\gamma}\gamma''r_{rms} + kr_{rms} - \frac{P}{4r_{rms}} - \frac{\epsilon^2}{16{r_{rms}}^3} = 0. $$

Пусть \(\displaystyle y=\frac{dr_{rms}}{dz}, \displaystyle \frac{d\gamma }{dz}\approx \frac{e E_z}{m c^2}\), тогда $$ \begin{matrix} \displaystyle \frac{dy}{dz}= - \frac{1}{\beta^2\gamma} \gamma' r_{rms}' - \frac{1}{2\beta^2\gamma}\gamma''r_{rms} - kr_{rms} + \frac{P}{4r_{rms}} + \frac{\epsilon^2}{16r_{rms}^3}\\ \displaystyle\frac{dr_{rms}}{dz} = y \end{matrix}. $$ Пусть

$$ \vec X = \begin{bmatrix} y \ r_{rms} \ \end{bmatrix}, $$

теперь составим дифференциальное уравнение \(X' = F(X)\).

Здесь \(k\) — жёсткость соленоида.

$$ k = \left ( \frac{eB_z}{2m_ec\beta\gamma} \right )^2 = \left ( \frac{e B_z}{2\beta\gamma\cdot 0.511\cdot 10^6 e \cdot \mathrm{volt}/c} \right )^2 = \left ( \frac{cB_z[\mathrm{T}]}{2\beta\gamma\cdot 0.511\cdot 10^6 \cdot \mathrm{volt}} \right )^2. $$

Для расчёта потребуется скорость света, входящая в выражение для жёсткости соленоида.

c = 299792458 # (m/s) скорость света

Зададим параметры пучка и начальные условия на входе. Ток составляет 2 кА при альфвеновском токе 17 кА, нормализованный эмиттанс — 180 мм·мрад; на входе в тракт среднеквадратичный радиус равен 27.5 мм, а его производная — 35.4 мрад. Закомментированные значения рядом представляют собой предыдущий вариант: эти четыре числа варьируются при настройке.

I = 2000#2000.000 # A Ток в ускорителе    
Ia = 17000 # A Альфвеновский ток
emitt_n = 1.80e-4#1.80e-5 # m нормализованный эмиттанс
R_rms = 27.5e-3 #m r_rms пучка
dR_rmsdz = 35.4e-3 #50e-3 dr_rms/dz пучка ## 50/2*sqrt(2) = 35.4

Правая часть системы реализована функцией dXdz, которая получает вектор состояния X = [dr/dz, r] и координату z, а возвращает его производную. Внутри собираются все члены уравнения огибающей. Здесь P обозначает первеанс, то есть вклад пространственного заряда; emitt — геометрический эмиттанс, полученный из нормализованного делением на \(\beta\gamma\); K — жёсткость соленоида. Первые два члена расширяют пучок, третий его сжимает, а последние два описывают адиабатическое затухание при ускорении и потому требуют не только \(\gamma\), но и её производных, причём вторая вычисляется численно.

from scipy.integrate import odeint

def derivative(f, x, dx):
    return (f(x + dx) - f(x - dx)) / (2 * dx)

def dXdz(X, z):
    
    y     = X[0]
    sigma = X[1]
    
    g = gamma(z)
    
    dgdz = -Ez(z)/mc
    d2gdz2 = -derivative(lambda z:Ez(z),z, dz)/mc
    beta = np.sqrt(1-1/(g*g))
    K = ( c * Bz(z) / (2*0.511e6))**2
    emitt = emitt_n/(g*beta) # m
    P=2*I/(Ia*beta*beta*beta*g*g*g)
    dydz = P/(4*sigma) + emitt*emitt/(sigma*sigma*sigma*16) - K*sigma/(g*beta)**2 - \
           dgdz*y/(beta*beta*g) - d2gdz2*sigma/(2*beta*beta*g)
    dsigmadz = y
    
    return [dydz, dsigmadz]

Обёртка над odeint подставляет начальные условия и возвращает только среднеквадратичный радиус. Точность ослаблена до rtol = 0.001 вместо стандартных \(1.5\cdot10^{-8}\). Огибающая является гладкой функцией, лишние знаки в ней неразличимы, а расчёт заметно ускоряется; именно за счёт этого библиотеку KENV [45] удаётся вызывать тысячи раз в цикле оптимизации.

def find_sigma(z):
    X0 = [dR_rmsdz, R_rms]      
    
    sol = odeint(dXdz, X0, z, rtol = 0.001) # rtol - относительная точность (по умолчанию rtol = 1.49012e-8)
    sigma = sol[:, 1] # m

    return sigma # m

Настроим оформление кривой пучка, запустим решатель и построим огибающую.

%opts Area.Beam [aspect=3 show_grid=True] (color='red' linewidth=1 alpha=0.3)
sigma = find_sigma(z)
dim_sig = hv.Dimension('r_{rms}', label="$r_{rms}$", unit='mm', range=(0,150))
sigma_img = hv.Area((z,sigma*1e3), kdims=[dim_z], vdims=[dim_sig], group='Beam')
sigma_img

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

Расчёт объёмом в несколько десятков строк выполняется за доли секунды, поэтому уравнение огибающей остаётся рабочим инструментом наряду с PIC-кодами: там, где необходимо перебрать тысячи вариантов настройки соленоидов, ансамбль макрочастиц не может быть рассчитан требуемое число раз. Практическое применение рассматриваемого подхода разбирается в следующей главе.

Оптимизация огибающей пучка заряженных частиц с помощью генетического алгоритма

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

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

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

DEAP [41] представляет собой фреймворк для работы с генетическими алгоритмами, в котором уже есть множество готовых инструментов.

Создание пучка и ускорителя

Расчёт огибающей из предыдущей главы вынесен в библиотеку kenv, поэтому остаётся собрать установку из готовых частей.

import kenv as kv

Пучок задаётся четырьмя числами, которыми служат энергия 2 МэВ, ток 2 кА, начальный радиус 50 мм при такой же расходимости (rp) и нормализованный эмиттанс 1000 мм·мрад.

beam = kv.Beam(energy=2,
               current=2e3,
               radius=50e-3,
               rp=50e-3,
               normalized_emittance=1000e-6)

Ускоритель занимает отрезок от нуля до пяти метров с шагом сетки один сантиметр.

accelerator = kv.Accelerator(0, 5, 0.01)

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

Solenoids = [ 
    [ 0.5000, 0.02, 'Bz.dat', 'Sol. 1'],
    [ 1.0000, 0.02, 'Bz.dat', 'Sol. 2'],
    [ 1.5000, 0.02, 'Bz.dat', 'Sol. 3'],
    [ 2.0000, 0.02, 'Bz.dat', 'Sol. 4'],
    [ 2.5000, 0.02, 'Bz.dat', 'Sol. 5'],
    [ 3.0000, 0.02, 'Bz.dat', 'Sol. 6'],
    [ 3.5000, 0.02, 'Bz.dat', 'Sol. 7'],
    [ 4.0000, 0.02, 'Bz.dat', 'Sol. 8'],
    [ 4.5000, 0.02, 'Bz.dat', 'Sol. 9'],
]

Перечисление преобразуется в элементы линейки.

for   z0, B0, filename, name in Solenoids:
    accelerator.Bz_beamline[name] = kv.Element(z0, B0, filename, name)

Вызов compile() сшивает профили всех соленоидов в одно поле вдоль тракта, как в предыдущей главе делалось вручную. После этого пучок и ускоритель связываются в расчёт, а track() интегрирует уравнение огибающей от начала до конца.

accelerator.compile()
simulation = kv.Simulation(beam, accelerator)
simulation.track()

График

Прежде чем приступать к оптимизации, необходимо увидеть исходное состояние.

matplotlib

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

import holoviews as hv
hv.extension('matplotlib')

%opts Layout [tight=True]
%output size=150 backend='matplotlib' fig='svg'

%opts Area Curve [aspect=3 show_grid=True]
%opts Area  (alpha=0.25)
%opts Curve (alpha=0.5)
%opts Area.Beam [aspect=3 show_grid=True] (color='red' alpha=0.3)

import warnings
warnings.filterwarnings('ignore')

dim_z  = hv.Dimension('z',  unit='m', range=(accelerator.start, accelerator.stop))
dim_Bz = hv.Dimension('Bz', unit='T', label='Bz', range=(0, 0.1))
dim_Ez = hv.Dimension('Ez', unit='MV/m', label='Ez')

dim_r = hv.Dimension('r', label="Beam r", unit='mm', range=(0, 150))

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

z_Bz= hv.Area((accelerator.parameter,accelerator.Bz(accelerator.parameter)), kdims=[dim_z], vdims=[dim_Bz])
z_r = hv.Area(((accelerator.parameter,simulation.envelope_x(accelerator.parameter)*1e3)), kdims=[dim_z], vdims=[dim_r], group='Beam')

(z_r+z_Bz).cols(1)

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

Генетический алгоритм

Модельную огибающую построим простейшим способом, проведя параболу через три точки: 150 мм на входе, 50 мм в середине тракта и 30 мм на выходе. Физического смысла эта кривая не имеет и представляет собой техническое задание для алгоритма.

import numpy as np

coefficients = np.polyfit([accelerator.start, (accelerator.start+accelerator.stop)/2, accelerator.stop], [0.15, 0.05, 0.03], 2) 
envelope_mod = coefficients[0]*accelerator.parameter**2 +  coefficients[1]*accelerator.parameter+ coefficients[2]

z_env = hv.Curve((accelerator.parameter, envelope_mod*1e3), kdims=[dim_z], vdims=[dim_r], label='Envelope_mod', group='Beam')

Полученная кривая приведена ниже.

z_env

Далее используется DEAP.

import random
from deap import creator, base, tools, algorithms

Первым шагом создаются необходимые типы, обычно функция приспособленности (fitness) и особь (individual).

creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
creator.create("Individual", list, fitness=creator.FitnessMin)

Далее необходимо собрать набор инструментов (toolbox), задающий основные параметры алгоритма. Сначала задаются границы, в пределах которых разрешено изменяться полям соленоидов, и размеры эволюции, то есть сто особей в популяции, сто поколений, вероятность скрещивания 0.4 и мутации 0.6.

Sol_B = 0.02     # [T] average Bz in the solenoids
Sol_B_max = 0.05  # [T] max Bz
Sol_B_min = 0.005 # [T] min Bz
Sol_Num = 9     # quantity

CXPB = 0.4       # cross chance
CX_INDPB = 0.6  # вероятность обмена одним геном
MUTPB = 0.6     # Mutation probability
NGEN = 100       # Number of generations
POP = 100       # Number of individuals
TOURN = 3       # Tournament size

В toolbox регистрируются три операции: порождение одного гена (случайное поле около 0.02 Тл), сборка особи из девяти генов и формирование популяции из особей. Особь здесь представляет собой список из девяти чисел, по одному на соленоид.

toolbox = base.Toolbox()
toolbox.register("attr_float", random.gauss, Sol_B, (Sol_B_max - Sol_B_min)/2)
toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_float, n=Sol_Num)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)

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

def evalution_envelope(individual):

    for x in range(Sol_Num):
        accelerator.Bz_beamline['Sol. %.d'%(x+1)].max_field  = individual[x]
    
    accelerator.compile()
    simulation = kv.Simulation(beam, accelerator)
    simulation.track()
    
    abs_errors = np.abs(envelope_mod - simulation.envelope_x(accelerator.parameter))
    
    return abs_errors.mean(),

Остаётся указать DEAP операторы скрещивания, мутации и отбора. Скрещивание равномерное, потомок берёт каждый ген у одного из двух родителей; мутация гауссова, к полю соленоида добавляется случайная добавка; отбор турнирный.

toolbox.register("evaluate", evalution_envelope)
toolbox.register("mate", tools.cxUniform, indpb=CX_INDPB)
toolbox.register("mutate", tools.mutGaussian, mu=0, sigma=(Sol_B_max - Sol_B_min)/2, indpb=MUTPB)
toolbox.register("select", tools.selTournament, tournsize=TOURN)


def clip(func):
    """Загоняет поля обратно в коридор после скрещивания и мутации."""
    def wrapper(*args, **kwargs):
        offspring = func(*args, **kwargs)
        for child in offspring:
            for i, b in enumerate(child):
                child[i] = min(max(b, Sol_B_min), Sol_B_max)
        return offspring
    return wrapper

Без последней функции объявленные Sol_B_min и Sol_B_max остались бы комментарием, поскольку мутация гауссовым шумом ничего не знает о коридоре и выведет поле за паспортный предел источника питания. Само по себе объявление clip ничего не делает: чтобы ограничение срабатывало после каждой операции над особью, функция навешивается декоратором на скрещивание и мутацию.

toolbox.decorate("mate", clip)
toolbox.decorate("mutate", clip)

Размер турнира здесь является отдельной константой, а не долей от числа поколений. Турнир задаёт давление отбора: чем он больше, тем увереннее побеждают текущие лидеры и тем быстрее исчезает разнообразие. Три-пять особей представляют собой обычный рабочий выбор, а турнир в треть популяции превращает эволюцию в жадный поиск.

Для наблюдения за эволюцией DEAP ведёт журнал (logbook). Ему поручается вычислять по каждому поколению среднее, разброс, лучшее и худшее значения приспособленности.

stats_fit = tools.Statistics(lambda ind: ind.fitness.values)
mstats = tools.MultiStatistics(fitness=stats_fit)
mstats.register("avg", np.mean)
mstats.register("std", np.std)
mstats.register("min", np.min)
mstats.register("max", np.max)

Запускается eaSimple, реализующий простейшую эволюционную схему: популяция оценивается, отбирается, скрещивается, мутирует, и всё это повторяется сто раз.

population = toolbox.population(n=POP)
pop, logbook = algorithms.eaSimple(population, toolbox, cxpb=CXPB, mutpb=MUTPB, ngen=NGEN, stats=mstats, verbose=True)
   	      	                                 fitness                                  
   	      	--------------------------------------------------------------------------
gen	nevals	avg      	gen	max      	min      	nevals	std       
0  	100   	0.0373577	0  	0.0408331	0.0337452	100   	0.00113532
1  	82    	0.0348016	1  	0.0379858	0.0299872	82    	0.00134257
2  	69    	0.0317311	2  	0.0355503	0.0268419	69    	0.00158446
3  	70    	0.0284586	3  	0.0310798	0.0258454	70    	0.00116153
4  	77    	0.0262835	4  	0.028602 	0.0245999	77    	0.00073413
5  	72    	0.0248689	5  	0.0268379	0.0234427	72    	0.000541174
6  	84    	0.0237668	6  	0.0256165	0.0221611	84    	0.00067109 
7  	73    	0.0225147	7  	0.0241554	0.0203067	73    	0.000610466
8  	88    	0.0210856	8  	0.0235622	0.0194378	88    	0.000755119
9  	80    	0.0197334	9  	0.0209437	0.0190342	80    	0.000337098
10 	81    	0.0191692	10 	0.0204253	0.0183268	81    	0.00035722 
11 	75    	0.0185557	11 	0.019661 	0.0178986	75    	0.000283725
12 	78    	0.0181075	12 	0.0190688	0.0175483	78    	0.00029574 
13 	72    	0.0177022	13 	0.018703 	0.0172446	72    	0.000248748
14 	75    	0.0174452	14 	0.0183427	0.0169873	75    	0.000190804
15 	70    	0.017232 	15 	0.0178082	0.0169012	70    	0.000181106
16 	79    	0.0170371	16 	0.0180127	0.0167734	79    	0.000208848
17 	80    	0.0168772	17 	0.0176019	0.0165999	80    	0.000167511
18 	80    	0.0167085	18 	0.0175836	0.0164198	80    	0.000174468
19 	77    	0.0165316	19 	0.0171106	0.0162831	77    	0.000152431
20 	76    	0.0163782	20 	0.0168837	0.0160568	76    	0.000171506
21 	69    	0.0161959	21 	0.0170411	0.015828 	69    	0.000194345
22 	77    	0.0159477	22 	0.0166562	0.0156399	77    	0.000157436
23 	79    	0.0158229	23 	0.0184114	0.0155106	79    	0.000314105
24 	80    	0.0156802	24 	0.0167603	0.0153466	80    	0.000234712
25 	72    	0.0154841	25 	0.0165787	0.0152393	72    	0.000184083
26 	74    	0.0153536	26 	0.0160787	0.0150713	74    	0.000201229
27 	83    	0.0152175	27 	0.0160113	0.0147709	83    	0.000237473
28 	79    	0.0149943	28 	0.0159486	0.014535 	79    	0.00027873 
29 	80    	0.0147364	29 	0.0155165	0.0143068	80    	0.000219007
30 	78    	0.0145096	30 	0.0155232	0.01421  	78    	0.000219074
31 	76    	0.0143674	31 	0.0162176	0.0141153	76    	0.000292944
32 	77    	0.0142465	32 	0.0154183	0.0139755	77    	0.000228094
33 	74    	0.0141755	33 	0.016872 	0.0137429	74    	0.000370584
34 	65    	0.0140263	34 	0.0165405	0.0135535	65    	0.000369079
35 	79    	0.0138775	35 	0.0165023	0.0134757	79    	0.000469816
36 	78    	0.0136499	36 	0.0165048	0.0132913	78    	0.00036574 
37 	77    	0.0135281	37 	0.0162187	0.0131399	77    	0.000370037
38 	72    	0.0133894	38 	0.0161935	0.013018 	72    	0.000407104
39 	74    	0.0132855	39 	0.0151958	0.0129085	74    	0.000356725
40 	73    	0.0132202	40 	0.0158132	0.0127831	73    	0.000502361
41 	78    	0.0131469	41 	0.0154469	0.0126621	78    	0.000549534
42 	72    	0.0128525	42 	0.0153473	0.0125449	72    	0.000371909
43 	81    	0.0128408	43 	0.0143709	0.0124426	81    	0.00037498 
44 	80    	0.0128018	44 	0.0143546	0.0122802	80    	0.00042712 
45 	76    	0.0126884	45 	0.0149749	0.0122105	76    	0.000484442
46 	74    	0.0124913	46 	0.0141759	0.0121807	74    	0.000374844
47 	84    	0.0124814	47 	0.0150409	0.012149 	84    	0.000478495
48 	65    	0.012378 	48 	0.0143345	0.0120466	65    	0.000396209
49 	77    	0.0123797	49 	0.0141054	0.0119723	77    	0.000465145
50 	66    	0.0122275	50 	0.0136186	0.0119299	66    	0.000324548
51 	77    	0.0122757	51 	0.0142704	0.0118622	77    	0.000472376
52 	74    	0.0121928	52 	0.0138265	0.0118082	74    	0.000460488
53 	73    	0.0121423	53 	0.0145869	0.0117309	73    	0.000487374
54 	70    	0.0120654	54 	0.0132641	0.0116092	70    	0.000405956
55 	77    	0.012012 	55 	0.0137579	0.0115895	77    	0.00050723 
56 	73    	0.0118692	56 	0.0141939	0.0115563	73    	0.000437811
57 	83    	0.0118798	57 	0.0139667	0.0114851	83    	0.000426248
58 	71    	0.0117967	58 	0.01344  	0.0114796	71    	0.000419319
59 	72    	0.0117301	59 	0.0145933	0.0114334	72    	0.000445674
60 	78    	0.0116524	60 	0.0135045	0.0113808	78    	0.000333071
61 	78    	0.0116611	61 	0.0132638	0.0113299	78    	0.000437407
62 	78    	0.0116445	62 	0.0140102	0.0113001	78    	0.000467812
63 	80    	0.0116662	63 	0.0138093	0.0112541	80    	0.000496356
64 	78    	0.0115682	64 	0.0126411	0.0112403	78    	0.000360796
65 	73    	0.0115044	65 	0.0133941	0.0112293	73    	0.000428872
66 	81    	0.0114277	66 	0.0134346	0.0112152	81    	0.000315043
67 	72    	0.0114574	67 	0.0132544	0.0111495	72    	0.000390321
68 	81    	0.0114764	68 	0.0135241	0.0110932	81    	0.000486222
69 	75    	0.011434 	69 	0.0132443	0.0110874	75    	0.000469529
70 	74    	0.0113903	70 	0.0129446	0.0109944	74    	0.000420948
71 	80    	0.0112841	71 	0.0132937	0.0109944	80    	0.000408004
72 	75    	0.0112891	72 	0.0133998	0.010978 	75    	0.000465794
73 	79    	0.0112265	73 	0.0122116	0.0109464	79    	0.00029769 
74 	80    	0.0112526	74 	0.0129213	0.0108979	80    	0.000417258
75 	71    	0.0112418	75 	0.0128844	0.0108979	71    	0.000447559
76 	73    	0.0111925	76 	0.0138475	0.0108402	73    	0.000519714
77 	78    	0.0111465	77 	0.0132176	0.010835 	78    	0.000422602
78 	79    	0.0112091	78 	0.0133698	0.0107964	79    	0.000558782
79 	73    	0.0110862	79 	0.0131931	0.0107964	73    	0.000465272
80 	67    	0.011036 	80 	0.0127483	0.0107964	67    	0.000386788

Средняя приспособленность падает почти монотонно, а разброс std сначала падает, к пятнадцатому поколению достигает минимума и далее немного растёт. Это не признак того, что популяция нашла несколько разных хороших решений, а хвост неудачных мутантов. Вероятность мутации здесь высокая, и часть потомков каждого поколения заведомо оказывается непригодной.

Извлечём десять лучших особей и сохраним историю эволюции в массивы.

top = tools.selBest(pop, k=10)
gen = np.array(logbook.select("gen"))
fit_min = np.array(logbook.chapters["fitness"].select("min"))
fit_max = np.array(logbook.chapters["fitness"].select("max"))
fit_avg = np.array(logbook.chapters["fitness"].select("avg"))

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

for x in range(Sol_Num):
            accelerator.Bz_beamline['Sol. %.d'%(x+1)].max_field  = top[0][x]
        
accelerator.compile()
simulation = kv.Simulation(beam, accelerator)
simulation.track()

z_Bz_gen= hv.Area((accelerator.parameter,accelerator.Bz(accelerator.parameter)), kdims=[dim_z], vdims=[dim_Bz])
z_r_gen = hv.Area(((accelerator.parameter,simulation.envelope_x(accelerator.parameter)*1e3)), kdims=[dim_z], vdims=[dim_r], group='Beam')

(z_r_gen*z_env+z_Bz_gen).cols(1)

Результаты

Девять соленоидов, сто особей в популяции, сто поколений, и алгоритм подогнал огибающую под заданную, ни разу не потребовав понимания того, как поля соленоидов влияют на пучок. Журнал выше напечатан по восьмидесятое поколение, и к нему приспособленность лучшей особи упала с 0.034 до 0.0108, средняя по популяции с 0.037 до 0.011. Обе вышли на плато задолго до конца: последние двадцать напечатанных поколений не дали практически ничего. Дальнейшее улучшение невозможно, поскольку заданная огибающая физически недостижима с большей точностью.

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

  • Градиента нет. Целевая функция получается численным интегрированием уравнения огибающей; аналитической производной по полям соленоидов у неё не существует, а численная потребовала бы девяти дополнительных расчётов на каждый шаг.
  • Один расчёт дёшев. KENV решает уравнение огибающей за миллисекунды, на порядки быстрее PIC-кода. Поэтому можно позволить себе десять тысяч расчётов (100 особей × 100 поколений), и поэтому эволюционные методы, расточительные по числу вызовов целевой функции, здесь уместны.

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

Обратная задача. Восстановление огибающей по эксперименту

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

На линейном индукционном ускорителе средств наблюдения внутри тракта нет, поэтому измеряется не сама огибающая, а диаметр пучка на выходном экране при разных значениях поля одного из соленоидов. Получается кривая «размер пучка от силы линзы», и по ней требуется восстановить начальный радиус пучка \(r\), угловой разлёт \(dr/dz\) и нормализованный эмиттанс \(\epsilon_n\).

Постановка для генетического алгоритма меняется незначительно. Особью становится вектор из двенадцати сил соленоидальных линз и трёх начальных условий пучка, а функцией приспособленности — среднеквадратичное отклонение расчётной кривой «диаметр от поля соленоида» от измеренной. Стартовая популяция разыгрывается гауссовыми случайными величинами вокруг ожидаемых значений.

ПараметрСреднее \(\mu\)СКО \(\sigma\)
Поле соленоидов \(B_z\)0.04 Тл0.02 Тл
Начальный радиус \(r\)48 мм24 мм
Угловой разлёт \(dr/dz\)38 мрад20 мрад
Нормализованный эмиттанс \(\epsilon_n\)1150 мм·мрад500 мм·мрад

Размер популяции здесь другой, 1500 особей против 100 в прямой задаче, поскольку размерность пространства поиска выросла с 9 до 15 и популяцию необходимо наращивать соответственно, иначе алгоритм останавливается в локальном минимуме. Задача решается за те же 100 поколений.

Для пучка с током 1.5 кА восстановленные значения приведены ниже.

$$ r = 42 \div 49\ \text{мм}, \qquad \frac{dr}{dz} = 35 \div 38\ \text{мрад}, \qquad \epsilon_n = 1125 \div 1215\ \text{мм}\cdot\text{мрад}. $$

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

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

Проверка результата

Восстановленные начальные условия были подставлены обратно в модель, а полученная огибающая сравнена с результатами двух других программ, PIC-кода ASTRA и программы UltraSAM. На пятнадцатиметровом ускорительном тракте огибающие совпали.

KENV выполняет расчёт настолько быстрее ASTRA и UltraSAM, что поверх него удалось построить интерактивный интерфейс для настройки проводки пучка в реальном времени. На установке это означает меньшее число тестовых импульсов на настройку, больший ресурс установки и больше времени на физику.

Исходный код KENV опубликован как библиотека для Python и зарегистрирован в Реестре программ для ЭВМ (№ 2024611244 от 18.01.2024).

Релятивистская разностная схема для расчёта динамики частиц

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

Рассматриваемая ниже схема возникла в ходе работ по транспортировке сильноточного электронного пучка в линейном индукционном ускорителе ЛИУ-5 [38–40]; физическое обоснование формул изложено там.

Основные величины

В начальный момент времени \(t\) заданы декартовы координаты \(i\)-й частицы \(\overrightarrow{r_{i}} = (x_i, y_i, z_i)\) и её начальный импульс \(\overrightarrow{p_{i}} = (p_{x_i}, p_{y_i}, p_{z_i})\). Задан минимальный шаг по времени \(\delta t\).

Для удобства обезразмерим физические величины, обозначенные дальше тильдой. \begin{equation} \widetilde{\overrightarrow{r_i}} = \frac{\overrightarrow{r_i}}{c \delta t}, \end{equation} \begin{equation} \widetilde{\overrightarrow{E_i}} = \frac{\overrightarrow{E_i}e}{mc} \delta t, \end{equation} \begin{equation} \widetilde{\overrightarrow{H_i}} = \frac{\overrightarrow{H_i}e}{mc} \delta t, \end{equation} где \(c\) — скорость света, \(e\) — заряд частицы, \(m\) — масса частицы.

Алгоритм

  • Вычисление электрических полей \(\overrightarrow{E_i}\) в точках, занимаемых частицами.

  • Приращение импульса. \begin{equation} \overrightarrow{p_i} = \overrightarrow{p_i} + 2\cdot\overrightarrow{E_i}, \end{equation} где коэффициент означает, что приращение импульса выполняется за весь интервал времени \(2\delta t\).

  • По новым импульсам вычисляются новые скорости. \begin{equation} \overrightarrow{v_i} = \frac{\overrightarrow{p_i}}{\gamma_i}, \end{equation} где \(\gamma_i = \sqrt{1 + \overrightarrow{p_i}^2}.\)

  • По новым скоростям вычисляются новые координаты. \begin{equation} \overrightarrow{r_i} = \overrightarrow{r_i} + 1\cdot\overrightarrow{v_i}, \end{equation} где коэффициент означает, что приращение координаты выполняется за интервал времени \(\delta t.\)

  • Вычисление магнитных полей \(\overrightarrow{H_i}\) в новых точках, занимаемых частицами.

  • Вычисляются новые значения скоростей, полученные после поворота в магнитном поле.

    \[ b_1 = 1 - \frac{H_i^2}{\gamma_i^2}, b_2 = 1 + \frac{H_i^2}{\gamma_i^2}, b_3 = 2\cdot \frac{\overrightarrow{v_i}\cdot\overrightarrow{H_i}}{\gamma_i}, \]

    \[ \overrightarrow{f_i} = 2\cdot \frac{\overrightarrow{v_i}\times \overrightarrow{H_i}}{\gamma_i}, \]

    \begin{equation} \overrightarrow{v_i} = \frac{\overrightarrow{v_i}b_1 + \overrightarrow{f_i} + \frac{\overrightarrow{H_i}}{\gamma_i}b_3}{b_2}. \end{equation}

Все члены здесь построены на векторе поворота \(\overrightarrow{t} = \overrightarrow{H_i}/\gamma_i\), поэтому в \(b_1\) и \(b_2\) входит его квадрат. Это проверка, заложенная в записи схемы: поворот, взятый в таком виде, сохраняет модуль скорости точно, тогда как любая другая степень \(\gamma\) делает выражение неоднородным, и частица начинает набирать или терять энергию в чисто магнитном поле, не совершающем над ней работы.

  • По новым скоростям снова вычисляются новые координаты. \begin{equation} \overrightarrow{r_i} = \overrightarrow{r_i} + 1\cdot\overrightarrow{v_i}, \end{equation} где коэффициент означает, что приращение координаты выполняется за интервал времени \(\delta t.\)
  • По новым скоростям вычисляются новые импульсы. \begin{equation} \overrightarrow{p_i} = \overrightarrow{v_i}\gamma_i. \end{equation}

Один цикл схемы, собранный из перечисленных шагов, выполняется за интервал времени \(2\delta t.\)

Python

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

import redpic as rp
import numpy as np
import holoviews as hv
hv.extension('matplotlib')

import warnings
warnings.filterwarnings('ignore')

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

rp.__version__

Настройки графиков

Фазовые портреты, составленные из десятков тысяч точек, требуют растра с высоким разрешением, а не векторного формата, а сами точки необходимо сделать мелкими и полупрозрачными. В противном случае плотное ядро пучка сольётся в сплошное пятно.

%output size=100 backend='matplotlib' fig='png' dpi=300
%opts Curve Scatter [aspect=3 show_grid=True]
%opts Curve (linewidth=1 alpha=0.7 color='blue')
%opts Scatter (alpha=0.7 s=0.5)

Задаём параметры ускорительного тракта

Тракт, заданный границами по \(z\) и шагом сетки, описывается так же, как в KENV. Расчёт ведётся от 0.7 до 5 метров с шагом один сантиметр.

acc = rp.accelerator.Accelerator(0.7, 5, 0.01)

В тракте расположены два ускоряющих модуля, настроенных на −1.1 МВ/м.

#              Unique name,  z-position [m],  Ez [MV/m],  Ez(z) profile
acc.add_accel('Acc. 1',      4.096,          -1.1,         'Ez.dat')
acc.add_accel('Acc. 2',      5.944,          -1.1,         'Ez.dat')

Кроме того, в тракте расположены семь фокусирующих соленоидов. У первого, намотанного навстречу остальным, поле отрицательно.

#                 Unique name,  z-position [m],  Bz [T],  Bz(z) profile
acc.add_solenoid('Sol. 1',      0.450,          -0.0580,   'Bz.dat')
acc.add_solenoid('Sol. 2',      0.957,           0.0390,   'Bz.dat')
acc.add_solenoid('Sol. 3',      2.107,           0.0250,   'Bz.dat')
acc.add_solenoid('Sol. 4',      2.907,           0.0440,   'Bz.dat')
acc.add_solenoid('Sol. 5',      3.670,           0.0400,   'Bz.dat')
acc.add_solenoid('Sol. 6',      4.570,           0.0595,   'Bz.dat')
acc.add_solenoid('Sol. 7',      5.470,           0.0590,   'Bz.dat')

Три элемента из девяти расположены вне интервала счёта. Это Sol. 1 на 0.450 м, находящийся до его начала, а также Sol. 7 и Acc. 2 на 5.470 и 5.944 м, вышедшие за его конец. Профиль поля, заданный для каждого элемента, имеет конечную ширину, и своими краями он заходит внутрь окна. Однако полностью в расчёт попадает только один ускоряющий модуль из двух, и приведённые ниже графики, построенные на этом интервале, следует читать с поправкой: за \( z = 5 \) м расчёт не производится.

Метод compile() сшивает профили всех девяти элементов в две функции, \(E_z(z)\) и \(B_z(z)\), которые далее опрашиваются в каждой точке траектории каждой частицы.

acc.compile()

Опишем оси и построим оба поля вдоль тракта. Магнитное поле, измеряемое в теслах, переводится в гауссы умножением на \(10^4\).

dim_z  = hv.Dimension('z',  unit='m')
dim_Ez = hv.Dimension('Ez', unit='MV/m', label='$E_z$')
dim_Bz = hv.Dimension('Bz', unit='Gs', label='$B_z$')
z  = acc.z
z_Ez = hv.Curve((z, acc.Ez(z)), kdims=dim_z, vdims=dim_Ez)
z_Bz = hv.Curve((z, acc.Bz(z)*1e4), kdims=dim_z, vdims=dim_Bz)
(z_Ez + z_Bz).cols(1)

Собранную структуру полезно распечатать целиком: так проще заметить пропущенный или неверно расположенный элемент.

print(acc)
Accelerator structure.
	Solenoids:
	[ 0.45 m, -0.058 T, Bz.dat, Sol. 1, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 0.957 m, 0.039 T, Bz.dat, Sol. 2, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 2.107 m, 0.025 T, Bz.dat, Sol. 3, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 2.907 m, 0.044 T, Bz.dat, Sol. 4, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 3.67 m, 0.04 T, Bz.dat, Sol. 5, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 4.57 m, 0.0595 T, Bz.dat, Sol. 6, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 5.47 m, 0.059 T, Bz.dat, Sol. 7, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	Accelerating modules:
	[ 4.096 m, -1.1 T, Ez.dat, Acc. 1, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	[ 5.944 m, -1.1 T, Ez.dat, Acc. 2, 0.0 m, 0.0 rad, 0.0 m, 0.0 rad] 
	Quadrupoles:
	Correctors x:
	Correctors y:

У ускоряющих модулей поле напечатано в теслах, хотя задано оно было в МВ/м. Это недоработка метода __str__ в библиотеке, который выводит одну и ту же подпись для соленоидов и для ускоряющих секций. Числа верны, а единицы нет. Печать состояния объекта, написанная наспех, затем читается годами, и разбираться в чужих единицах, нигде не оговорённых, приходится по исходному коду.

Задаём параметры пучка

В отличие от KENV, где пучок описывался одной огибающей, здесь необходимо задать распределение, по которому разыгрываются макрочастицы; выбрано равномерное по радиусу распределение. Параметры пучка: электроны, энергия 1.32 МэВ, ток 500 А, радиус 48 мм, угловой разброс 70 мрад, нормализованный эмиттанс 200 мм·мрад. Продольный «радиус» 3.5 метра означает, что сгусток значительно длиннее самого тракта, и пучок здесь практически непрерывен, то есть неотличим от постоянного тока.

beam = rp.beam.RadialUniformBeam(
    type=rp.constants.electron, 
    energy = 1.32,          # MeV
    current = 0.5e3,  # A
    radius_x = 48e-3, # initial r (m)
    radius_y = 48e-3, # initial r (m)
    radius_z = 3.5,
    radius_xp = 2*35.0e-3,     # initial r' (rad)
    radius_yp = 2*35.0e-3,     # initial r' (rad)
    x  = 0.0e-3,   # horizontal centroid position (m)
    xp = 0.0e-3,     # horizontal centroid angle (rad)
    y = 0,          # vertical centroid position (m)
    normalized_emittance = 200e-6 # m*rad
)

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

beam.generate(10_000)
2023-08-31 21:35:33,649 - redpic.beam.base - INFO - Generate a radial-uniform beam with 10000 particles

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

beam.df
x y z px py pz
0 0.034491 -0.010901 -1.123685 0.100578 -0.031122 1.747093
1 0.007980 -0.003833 1.251044 0.022404 -0.010670 1.749303
2 -0.022694 -0.010773 -0.128508 -0.067049 -0.031592 1.741746
3 0.017086 0.014826 -2.608725 0.048956 0.043029 1.763242
4 -0.021815 0.015455 0.520279 -0.062786 0.044937 1.759832
... ... ... ... ... ... ...
9995 0.018885 0.028316 1.360050 0.054502 0.082155 1.746065
9996 -0.021948 0.040885 -2.615462 -0.064131 0.119014 1.759089
9997 -0.016885 -0.017578 1.473440 -0.048364 -0.051265 1.740909
9998 0.027568 -0.014905 -0.467425 0.080297 -0.042703 1.765850
9999 0.035333 0.010626 0.145074 0.103711 0.031025 1.771395

10000 rows × 6 columns

Опишем оси.

dim_x = hv.Dimension('x', unit='m', range=(-0.1, 0.1))
dim_y = hv.Dimension('y', unit='m', range=(-0.1, 0.1))
dim_z = hv.Dimension('z', unit='m', range=(acc.z_start, acc.z_stop))
dim_px = hv.Dimension('px', unit='MeV/c', label='$p_x$')
dim_py = hv.Dimension('py', unit='MeV/c', label='$p_y$')

и соберём четыре проекции, построенные по одному датафрейму: поперечное сечение \(x\)–\(y\), продольный вид \(z\)–\(x\) и два фазовых портрета, \(x\)–\(p_x\) и \(y\)–\(p_y\).

beam_x_y = hv.Scatter(beam.df, kdims=[dim_x, dim_y])
beam_z_x = hv.Scatter(beam.df, kdims=[dim_z, dim_x])
beam_x_px = hv.Scatter(beam.df, kdims=[dim_x, dim_px])
beam_y_py = hv.Scatter(beam.df, kdims=[dim_y, dim_py])
WARNING:param.Scatter: Chart elements should only be supplied a single kdim
2023-08-31 21:35:33,772 - param.Scatter - WARNING - Chart elements should only be supplied a single kdim
WARNING:param.Scatter: Chart elements should only be supplied a single kdim
2023-08-31 21:35:33,789 - param.Scatter - WARNING - Chart elements should only be supplied a single kdim
WARNING:param.Scatter: Chart elements should only be supplied a single kdim
2023-08-31 21:35:33,791 - param.Scatter - WARNING - Chart elements should only be supplied a single kdim
WARNING:param.Scatter: Chart elements should only be supplied a single kdim
2023-08-31 21:35:33,801 - param.Scatter - WARNING - Chart elements should only be supplied a single kdim

Предупреждения HoloViews можно не принимать во внимание: Scatter ожидает одну ключевую размерность, а передаются две; на изображение это не влияет.

(beam_x_y + beam_z_x + beam_x_px + beam_y_py).cols(2)

В поперечном сечении видно круглое пятно радиусом 48 мм. Фазовые портреты представляют собой наклонные полосы, а не облака; это видно и по числам в таблице, напечатанной сразу после генерации, где у первых же частиц \(p_x/x \approx 2.9\) для всех подряд. Пучок стартует расходящимся, поперечный импульс частицы почти пропорционален её координате, а толщина полосы соответствует эмиттансу, неустранимому никакой фокусировкой.

Параметры сгенерированного пучка распечатаем и сверим с заданными.

print(beam)
Beam parameters:
            Type	electron
            Distribution	radial-uniform
            Particles	10000
            Current	500.0 A
            Energy	1.32 MeV
            Total momentum	1.7582483378931433 MeV/c
            Rel. factor	3.583175582013202
            Radius x	48.0 mm
            Radius y	48.0 mm
            Radius z	3.5 m
            Radius x prime	70.0 mrad
            Radius y prime	70.0 mrad
            Horizontal centroid position	0.0 mm
            Vertical centroid position	0.0 mm
            Horizontal centroid angle	0.0 mrad
            Vertical centroid angle	0.0 mrad
            Normalized emittance x	200.0 mm*mrad
            Normalized emittance y	200.0 mm*mrad

Запуск моделирования

Полный импульс 1.7582 МэВ/c и \(\gamma = 3.58\) получены пересчётом заданной энергии и точно с ней согласуются.

Свяжем пучок с трактом и запустим трекинг. Внутри track() выполняется схема, выписанная в начале главы: поля, приращение импульса, поворот в магнитном поле и сдвиг координат, рассчитываемые для каждой из десяти тысяч частиц на каждом шаге.

rp_sim = rp.solver.Simulation(beam, acc)
rp_sim.track()
z = 4.98 m (99.5 %) 

Визуализация результатов моделирования

Расчёт дошёл до 4.98 метра, то есть до конца тракта. Результат rp_sim.result организован в виде словаря, где ключом служит координата вдоль тракта, а значением — датафрейм со всеми частицами, собранными в этом сечении. Это покадровая запись, хранимая целиком в памяти.

Напишем функцию, которая строит один кадр.

def plot(i):
    df = rp_sim.result[i]
    rp_z_x = hv.Scatter(df, kdims=[dim_z, dim_x], label='redpic')
    return rp_z_x

и объединим кадры в HoloMap — интерактивное изображение, управляемое ползунком по \(z\). Перемещение ползунка позволяет увидеть не только изменение размера пучка, но и перемешивание частиц внутри него и поведение его края.

items = [(i, plot(i)) for i in list(rp_sim.result.keys())]

holomap = hv.HoloMap(items, kdims = ['z'])

hv.output(holomap, widget_location='bottom')

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

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

Коррекция равновесной орбиты с применением матрицы отклика и нейронных сетей

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

Постановка задачи

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

Для устранения этих искажений в кольце установлены два набора устройств.

  • Мониторы положения пучка (BPM, пикапы) — \(N_{bpm}\) штук, измеряющие отклонение орбиты \(x_i\) в своих точках;
  • Дипольные корректоры — \(N_{cor}\) штук, слабые магниты, каждый из которых поворачивает траекторию на угол \(\theta_j\).

Задача коррекции, стоящая перед оператором каждую смену, состоит в следующем: по измеренному вектору отклонений \(\vec{x}\) найти такие углы корректоров \(\vec{\theta}\), при которых орбита окажется как можно ближе к расчётной.

Матрица отклика

Пока отклонения малы, ускоритель остаётся линейной системой. Реакция орбиты на \(j\)-й корректор, включённый в одиночку, описывается матрицей отклика (response matrix).

$$ x_i = \sum_{j} R_{ij},\theta_j, \qquad R_{ij} = \frac{\partial x_i}{\partial \theta_j}. $$

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

$$ R_{ij} = \frac{\sqrt{\beta_i \beta_j}}{2\sin(\pi\nu)}\cos\left(|\varphi_i - \varphi_j| - \pi\nu\right), $$

где \(\beta_i\), \(\beta_j\) — значения бета-функции в точках монитора и корректора, \(\varphi_i\), \(\varphi_j\) — набеги бетатронной фазы, \(\nu\) — бетатронная частота (число колебаний на оборот). Матрицу можно рассчитать по модели оптики, а можно измерить, отклоняя каждый корректор по очереди и записывая отклик всех мониторов. Обычно поступают именно так, одновременно проверяя модель.

Построим модельное кольцо на Python, устроенное по этой же формуле.

import numpy as np

rng = np.random.default_rng(42)

n_bpm, n_cor = 64, 48       # мониторы и корректоры
nu = 8.42                   # бетатронная частота кольца

# расставим элементы по кольцу и разыграем бета-функции
phi_bpm = np.sort(rng.uniform(0, 2 * np.pi * nu, n_bpm))   # фазы мониторов
phi_cor = np.sort(rng.uniform(0, 2 * np.pi * nu, n_cor))   # фазы корректоров
beta_bpm = rng.uniform(4.0, 24.0, n_bpm)                   # бета в мониторах, м
beta_cor = rng.uniform(4.0, 24.0, n_cor)                   # бета в корректорах, м

def response_matrix(beta_b, phi_b, beta_c, phi_c, nu):
    """Матрица отклика замкнутой орбиты кольца, мм/мрад."""
    dphi = np.abs(phi_b[:, None] - phi_c[None, :])
    return (np.sqrt(beta_b[:, None] * beta_c[None, :])
            / (2 * np.sin(np.pi * nu))
            * np.cos(dphi - np.pi * nu))

R = response_matrix(beta_bpm, phi_bpm, beta_cor, phi_cor, nu)
print(R.shape)   # (64, 48)

Внесём в машину нормально распределённые случайные ошибки и рассмотрим полученную орбиту.

# истинные ошибки — как будто от смещений квадруполей
theta_err = rng.normal(0, 0.05, n_cor)          # мрад
noise = rng.normal(0, 0.02, n_bpm)              # шум мониторов, мм
x_measured = R @ theta_err + noise              # измеренная орбита

print(f"СКО орбиты до коррекции: {x_measured.std():.3f} мм")
СКО орбиты до коррекции: 1.539 мм

Коррекция через SVD

Нахождение токов корректоров сводится к решению обратной задачи \(R\vec{\theta} \approx -\vec{x}\). Матрица прямоугольная (\(64 \times 48\)), система переопределена, поэтому решение ищется в смысле наименьших квадратов. Стандартным инструментом в физике ускорителей является сингулярное разложение (SVD).

$$ R = U,\Sigma,V^T, \qquad \vec{\theta} = -V,\Sigma^{-1}U^T \vec{x}. $$

Часть сингулярных чисел \(\sigma_k\) близка к нулю. Соответствующие комбинации корректоров почти не влияют на орбиту в мониторах, и при обращении \(1/\sigma_k\) для таких мод неограниченно растёт, вследствие чего шум мониторов превращается в огромные токи корректоров. Это классическая некорректная обратная задача, и решается она отсечением малых сингулярных чисел.

def svd_correction(R, x, n_modes):
    """Коррекция орбиты с обрезанием SVD-спектра до n_modes мод."""
    U, s, Vt = np.linalg.svd(R, full_matrices=False)
    s_inv = np.zeros_like(s)
    s_inv[:n_modes] = 1.0 / s[:n_modes]     # оставляем только сильные моды
    return -(Vt.T * s_inv) @ (U.T @ x)

for n_modes in [10, 30, 48]:
    theta_corr = svd_correction(R, x_measured, n_modes)
    x_after = R @ (theta_err + theta_corr) + rng.normal(0, 0.02, n_bpm)
    print(f"мод: {n_modes:2d} | СКО орбиты: {x_after.std():6.3f} мм"
          f" | максимальный угол: {np.abs(theta_corr).max():6.3f} мрад")
мод: 10 | СКО орбиты:  0.297 мм | максимальный угол:  0.061 мрад
мод: 30 | СКО орбиты:  0.028 мм | максимальный угол:  0.126 мрад
мод: 48 | СКО орбиты:  0.030 мм | максимальный угол: 1343310628796.820 мрад

Десять мод уменьшают орбиту в пять раз, тридцать — в пятьдесят пять, причём токи корректоров остаются приемлемыми. Все сорок восемь мод орбиту не улучшают (0.030 против 0.028 мм), однако требуемые углы возрастают до \(10^{12}\) мрад.

Происхождение величины \(10^{12}\) видно из спектра. Наибольшее сингулярное число здесь равно 183.6, сорок первое — 0.057, а последние семь лежат в районе \(10^{-14}\), то есть на уровне ошибки округления. Матрица модели вырождена: её ранг равен 41, а не 48. Семь комбинаций корректоров не влияют на орбиту, и деление на малое число оказывается делением на ноль, размытый арифметикой с плавающей точкой.

На реальном кольце измеренная матрица отклика обусловлена плохо, но не вырождена: отношение наибольшего сингулярного числа к наименьшему там составляет \(10^2 - 10^3\), а не \(10^{16}\), как здесь. Усекать спектр всё равно приходится, однако по другой причине.

Число удерживаемых мод является компромиссом между качеством коррекции и величиной токов. Родственный подход даёт регуляризация Тихонова, где вместо жёсткого усечения минимизируется \(|R\theta + x|^2 + \lambda|\theta|^2\).

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

Причём здесь нейронные сети

Линейный подход имеет границы применимости.

  • при больших искажениях и в сильно нелинейных элементах (секступолях) отклик, предсказанный линейной теорией, расходится с действительным;
  • реальная оптика дрейфует, и матрица, измеренная утром, к вечеру уже несколько отличается;
  • измерение полной матрицы отклика является длительной процедурой, отнимающей пучковое время.

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

Построим такой корректор на многослойном перцептроне.

from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# обучающая выборка: случайные ошибки -> орбиты (с шумом мониторов)
n_samples = 20_000
thetas = rng.normal(0, 0.05, (n_samples, n_cor))
orbits = thetas @ R.T + rng.normal(0, 0.02, (n_samples, n_bpm))

model = make_pipeline(
    StandardScaler(),
    MLPRegressor(hidden_layer_sizes=(128, 128), activation="relu",
                 max_iter=200, early_stopping=True, random_state=0),
)
model.fit(orbits, thetas)                      # учимся восстанавливать ошибки

theta_pred = model.predict(x_measured[None, :])[0]
x_after_nn = R @ (theta_err - theta_pred) + rng.normal(0, 0.02, n_bpm)
print(f"СКО орбиты после нейросетевой коррекции: {x_after_nn.std():.3f} мм")
СКО орбиты после нейросетевой коррекции: 0.151 мм

Значение 0.151 мм в десять раз лучше, чем до коррекции, но в пять раз хуже, чем даёт SVD с тридцатью модами. Это ожидаемо. Модельная машина линейна по построению, а для линейной задачи SVD является точным решением в смысле наименьших квадратов; превзойти его невозможно, и сеть, обученная на тех же данных, может лишь приблизиться к нему, затратив двадцать тысяч примеров и минуту обучения.

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

Гибридный корректор. Сеть учится на остатках

Реальная машина линейна лишь приближённо. Корректоры насыщаются, секступоли вносят нелинейный отклик, железо магнитов имеет гистерезис, и после SVD-коррекции в орбите остаётся систематический остаток, не описываемый линейной моделью. Именно он передаётся сети.

Такая конструкция используется на действующих установках; проверим её на модели.

$$ \Delta\theta = \underbrace{\Delta\theta_{\text{SVD}}}{\text{линейная часть}} + \underbrace{\Delta\theta{\text{НС}}}_{\text{поправка на нелинейность}}. $$

Сеть обучается не отображению «орбита → токи» целиком, а только разности между истинной ошибкой и ответом, предложенным SVD. Задача на порядок проще: вместо всей линейной алгебры кольца сеть уточняет почти правильный ответ линейной части. Сети достаточно двух скрытых слоёв 64–32.

Усилим нелинейность: примем предел корректора в несколько раз меньше типичного требуемого угла (ошибки разыгрываются с \(\sigma = 0.05\) мрад), чтобы линейная модель заведомо перестала быть точной. Кольцо, матрица отклика R и число удерживаемых мод остаются прежними, заданными в начале главы.

THETA_MAX = 0.01          # предел корректора, мрад
sigma_bpm = 0.02          # шум мониторов, мм
n_modes = 30              # удерживаемых SVD-мод, как и раньше

def machine(theta):
    # кольцо с насыщающимися корректорами: отклик уже не линеен
    return R @ (THETA_MAX * np.tanh(theta / THETA_MAX))

Псевдообратную матрицу из усечённого спектра рассчитаем один раз заранее: коррекция сводится к умножению на неё, и разложение \(R\) на каждом шаге не требуется.

U, s, Vt = np.linalg.svd(R, full_matrices=False)
s_inv = np.zeros_like(s)
s_inv[:n_modes] = 1.0 / s[:n_modes]
svd_matrix = -(Vt.T * s_inv) @ U.T        # то же, что svd_correction, но матрицей

Обучающая выборка строится так же, как раньше, однако целью служит остаток, не устранённый SVD.

n_samples = 20_000
thetas = rng.normal(0, 0.05, (n_samples, n_cor))
orbits = (THETA_MAX * np.tanh(thetas / THETA_MAX)) @ R.T \
         + rng.normal(0, sigma_bpm, (n_samples, n_bpm))

svd_answer = orbits @ svd_matrix.T        # что предложил бы SVD
residuals = -thetas - svd_answer          # чего ему не хватило

Сеть обучается предсказывать этот остаток по орбите, а шаг коррекции суммирует оба вклада.

net = make_pipeline(
    StandardScaler(),
    MLPRegressor(hidden_layer_sizes=(64, 32), max_iter=300,
                 early_stopping=True, random_state=0),
)
net.fit(orbits, residuals)

def hybrid_step(x):
    # один шаг коррекции: линейное решение плюс поправка сети
    return svd_matrix @ x + net.predict(x[None, :])[0]

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

Проведём 100 испытаний со случайными ошибками, генерируемыми заново для каждого, и сравним чистый итеративный SVD с гибридом. В таблице приведены среднее по испытаниям и среднеквадратичный разброс.

def run(step, n_iter=6, n_trials=100):
    out = np.zeros((n_trials, n_iter + 1))
    for t in range(n_trials):
        theta = rng.normal(0, 0.05, n_cor)
        for k in range(n_iter + 1):
            x = machine(theta) + rng.normal(0, sigma_bpm, n_bpm)
            out[t, k] = x.std()
            if k < n_iter:
                theta = theta + step(x)
    return out.mean(0), out.std(0)

svd_mean, svd_std = run(lambda x: svd_matrix @ x)   # чистый итеративный SVD
nn_mean, nn_std = run(hybrid_step)                  # гибрид SVD плюс сеть

print(f"до коррекции {svd_mean[0]:.3f} ± {svd_std[0]:.3f} мм")
print()
print("итер |             SVD |   гибрид SVD+НС | выигрыш")
for k in range(1, len(svd_mean)):
    gain = 100 * (svd_mean[k] - nn_mean[k]) / svd_mean[k]
    print(f"{k:4d} | {svd_mean[k]:7.3f} ± {svd_std[k]:5.3f} |"
          f" {nn_mean[k]:7.3f} ± {nn_std[k]:5.3f} | {gain:+5.1f}%")
до коррекции 0.306 ± 0.099 мм

итер |             SVD |   гибрид SVD+НС | выигрыш
   1 |   0.263 ± 0.093 |   0.191 ± 0.063 | +27.1%
   2 |   0.226 ± 0.079 |   0.198 ± 0.068 | +12.2%
   3 |   0.196 ± 0.063 |   0.214 ± 0.078 |  -9.1%
   4 |   0.160 ± 0.047 |   0.223 ± 0.081 | -38.9%
   5 |   0.138 ± 0.045 |   0.232 ± 0.084 | -68.0%
   6 |   0.117 ± 0.039 |   0.242 ± 0.077 | -107.1%

Из таблицы следуют три вывода.

Сеть выигрывает на первых итерациях. На первом шаге гибрид даёт орбиту более чем на четверть лучше, 0.191 против 0.263 мм. Это вклад нелинейности, которую SVD не учитывает, а сеть выучила. На реальной машине выигрыш измеряется не в миллиметрах, а в пучковом времени: каждая итерация коррекции представляет собой отдельный цикл «выставить токи, дождаться стабилизации, измерить орбиту», и сокращение их числа ценнее, чем выигрыш последних микрон.

Начиная с третьей итерации гибрид проигрывает, и разрыв растёт. Чистый SVD к шестой итерации доводит орбиту до 0.117 мм, а гибрид выходит на плато и медленно растёт к 0.242 мм. Плато возникает из-за систематической ошибки сети. Обученная на конечной выборке, она предсказывает остаток с погрешностью, и на каждой итерации эта погрешность вносится в орбиту. Сеть сама становится источником ошибки.

Итеративность решает ту же задачу, что и сеть, и решает её лучше. Два улучшения направлены на одну проблему, поэтому суммировать их бессмысленно. Если сравнить гибрид после трёх итераций (0.214 мм) с однократным SVD (0.263 мм), получилось бы улучшение на 19 %, которое было бы приписано сети, тогда как принадлежит оно итерациям. Вклад каждого улучшения необходимо разделять. В противном случае легко опубликовать результат, который не выдержит попытки воспроизведения.

У гибридного корректора существует оптимальное число итераций, и его необходимо измерить. Здесь оно равно единице: уже на второй итерации гибрид даёт 0.198 мм против 0.191 мм на первой, а его преимущество над чистым SVD сокращается с +27.1 % до +12.2 %, чтобы на третьей смениться проигрышем. Останавливаться следует там, где поправка, предсказанная сетью, перестаёт превышать её собственную погрешность.

Величина выигрыша зависит от кольца: на семи разыгранных наборах бета-функций и фаз первая итерация давала от 2 % до 27 % улучшения. Форма кривой — выигрыш на первых шагах, плато, проигрыш далее — повторяется всегда. Поэтому в таблице приведён разброс, а не одно среднее; без него из таких данных можно «доказать» что угодно.

Стоимость одного шага также имеет значение. Один шаг SVD сводится к умножению матрицы \(48\times64\) на вектор и занимает 1.2 мкс на Apple M4; один вызов сети, обёрнутой в scikit-learn, занимает 110 мкс, почти в сто раз дольше. Для медленной обратной связи с частотой в единицы герц это несущественно, а для быстрой (килогерцы) уже существенно, и сеть потребуется вынести из Python в скомпилированный код.

Применимость на реальной установке

На линейном ускорителе инжектора ЦКП «СКИФ» коррекция орбиты выполняется по измеренной матрице отклика, для чего служат 7 датчиков положения пучка, 6 больших и 8 малых рамочных двухкоординатных корректоров. Матрица там измеряется, а не берётся из модели. Каждый корректор по очереди отклоняется на известную величину, и записывается отклик всех пикапов. Процедура длительная, однако одновременно она проверяет модель оптики. Коррекция выполняется итеративно, с повторным измерением матрицы отклика между итерациями. Без неё транспортировка пучка по ускорителю и каналу сопровождается значительными потерями, что обнаруживается при запуске каждой новой машины.

Ниже приведены три обстоятельства, не заметные на модели, но определяющие успех на пульте.

  • Выбор числа сингулярных чисел остаётся главным настроечным параметром. При слишком большом числе в решение попадают шумовые компоненты, усиленные делением на малые сингулярные числа, и коррекция ухудшается; при слишком малом коррекция оказывается недостаточной. Число выбирается по минимуму невязки, перебором, как показано выше.
  • Ограничения на токи корректоров входят в алгоритм, а не в проверку после него. Источник питания корректора имеет предел в несколько ампер; если ограничение не заложено в постановку задачи, оптимизатор выдаёт неисполнимый ответ, подобный варианту со всеми сорока восемью модами выше.
  • Сеть, если она добавляется, инициализируется SVD-решением. Это та же работа по остаткам, что и выше на модели: линейная часть даёт разумное приближение, сеть отвечает за поправку. Декомпозиция обеспечивает и предсказуемую деградацию: если сеть выдаст некорректный результат, останется рабочее SVD-решение. Гибридная схема с ограничениями на токи и SVD-инициализацией проверялась, в частности, на модели накопителя ВЭПП-5, где стоят 16 датчиков положения и 16 корректоров с пределом \(\pm 7\) А.

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

Задания для самостоятельной работы

  1. На основе приведённого выше кода построить зависимость СКО орбиты и максимального тока корректора от числа удерживаемых SVD-мод, получив классическую L-кривую регуляризации.
  2. Усилить нелинейность (снизить \(\theta_{max}\) вдвое) и повторить таблицу из 100 испытаний, определив величину насыщения, при которой преимущество гибрида перестаёт исчезать к третьей итерации.
  3. Испортить один монитор (умножить его показания на 3) и определить, какой из методов устойчивее к неисправному датчику; обучить сеть на данных, испорченных такими же неисправностями.
  4. Заложить в коррекцию предел корректора, ограничив предлагаемые углы по \(\pm\theta_{max}\) до подачи на машину, и оценить, насколько это меняет число итераций до выхода на плато.

Полезные ссылки

Трёхмерная схема установки кодом

Преимущества программного описания геометрии

Трёхмерная модель установки обычно создаётся вручную в CAD-редакторе; для машиностроения такой подход подходит, а для ускорителя, перестраиваемого каждый сезон, — нет.

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

В библиотеке CadQuery геометрия описывается кодом на Python. Модель размещается в репозитории рядом с расчётом, различия между версиями видны в git diff, сборка чертежа включается в конвейер тестов, запускаемый на каждом коммите, и тело установки строится непосредственно из описания структуры, заданного один раз, а не параллельно ему.

Структура как данные

Рассмотрим участок тракта, содержащий два квадруполя, дрейфы между ними и два поворотных магнита по 45°.

import numpy as np
import cadquery as cq

# тип элемента, длина в метрах, параметр
LATTICE = [
    ("drift", 0.30, None), ("quad", 0.20, +1), ("drift", 0.30, None),
    ("bend",  0.50, np.pi / 4),
    ("drift", 0.30, None), ("quad", 0.20, -1), ("drift", 0.30, None),
    ("bend",  0.50, np.pi / 4),
]

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

Один тип элемента, одна функция

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

def drift(length, _):
    return cq.Workplane("XY").circle(0.02).extrude(length)

def quad(length, sign):
    body = cq.Workplane("XY").rect(0.24, 0.24).extrude(length)
    return body.edges("|Z").fillet(0.03) if sign > 0 else body

def bend(length, angle):
    r = length / angle                       # радиус по длине дуги и углу
    return (cq.Workplane("XY").rect(0.20, 0.16)
              .revolve(np.degrees(angle), (r, 0, 0), (r, 1, 0)))

BUILDERS = {"drift": drift, "quad": quad, "bend": bend}

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

Словарь BUILDERS в конце заменяет длинную цепочку if, разрастающуюся с каждым новым типом элемента. Добавление секступоля сводится к написанию функции и одной строки в словаре; код расстановки, написанный один раз, изменять не требуется.

Размещение элементов

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

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

def local_step(kind, length, param):
    """Смещение конца элемента относительно начала, в его собственных осях."""
    if kind != "bend":
        return np.array([0.0, 0.0, length]), 0.0
    r = length / param
    return np.array([r * (1 - np.cos(param)), 0.0, r * np.sin(param)]), param

Прямой элемент сдвигает пучок вперёд на отведённую ему длину и направления не меняет. Поворотный ведёт пучок по дуге радиуса \(R = L/\theta\), с концом, смещённым вперёд на \(R\sin\theta\) и вбок на \(R(1-\cos\theta)\), а направление доворачивается на \(\theta\).

Остаётся пройти по описанной структуре, поворачивая локальное смещение на накопленный угол.

def build(lattice):
    assembly, pos, heading = cq.Assembly(), np.zeros(3), 0.0
    for i, (kind, length, param) in enumerate(lattice):
        assembly.add(BUILDERS[kind](length, param), name=f"{kind}{i}",
                     loc=cq.Location(cq.Vector(*pos),
                                     cq.Vector(0, 1, 0), np.degrees(heading)))
        step, turn = local_step(kind, length, param)
        c, s = np.cos(heading), np.sin(heading)
        pos = pos + np.array([c * step[0] + s * step[2], 0.0,
                              -s * step[0] + c * step[2]])
        heading += turn
    return assembly, pos, heading

Порядок трёх действий, выполняемых внутри цикла, существен. Сначала элемент ставится в текущее положение с текущим направлением, затем сдвигается положение, и только после этого доворачивается направление. При перестановке двух последних строк каждый элемент, поставленный после поворотного магнита, окажется смещённым.

Проверка

Геометрию, построенную кодом, легко проверить, не открывая просмотрщик. Два поворота по 45° должны развернуть пучок в точности на 90°, а сумма длин элементов, выписанных в структуре, даёт длину траектории.

acc, pos, heading = build(LATTICE)
print("элементов:", len(acc.children))
bb = acc.toCompound().BoundingBox()
print(f"габарит: X {bb.xlen:.2f} м, Z {bb.zlen:.2f} м")
print(f"конец траектории: X {pos[0]:.3f} м, Z {pos[2]:.3f} м")
print(f"поворот: {np.degrees(heading):.1f}°")
assert bb.zmax >= pos[2] and bb.xmax >= pos[0]   # тела обязаны накрывать траекторию
элементов: 8
габарит: X 1.32 м, Z 2.10 м
конец траектории: X 1.202 м, Z 2.002 м
поворот: 90.0°

Два поворота по 45° дают в точности 90°, полученная длина составляет 2.60 м, а конец траектории лежит внутри габарита. Таким образом, чертёж покрывается тестом.

assert в последней строке требует, чтобы тела элементов накрывали траекторию, по которой они расставлены. В процессе написания этой главы магнит строился вращением вокруг неверной оси, и обнаружено это было не визуально (изображение выглядело правдоподобно), а по тому, что Z-габарит, измеренный по сборке, составил 1.83 м при конце траектории на 2.002 м. Тело оказалось смещённым относительно пучка.

Готовая сборка выгружается в обменный формат, читаемый любым CAD.

cq.exporters.export(acc.toCompound(), "layout.step")

Выводы

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

Разбиение на конструкторы повторяет декомпозицию, описанную в главе «От скрипта к приложению». Одна функция, отвечающая за одно тело, ничего не знает о соседях; расстановка не знает, из чего собран квадруполь. Граница проведена там, где ожидаются изменения: новые типы элементов появляются часто, а правило расстановки, записанное однажды, практически никогда не меняется.

Проверяемость важнее наглядности изображения. Ошибка, скрытая в трёхмерной модели, визуально почти не обнаруживается. Установка, выведенная на экран, выглядит правдоподобно, даже если один магнит смещён на полметра. Арифметика — сумма углов, длина траектории и габарит — обнаруживает её сразу.

Полезные ссылки

  • Документация CadQuery, описание операций, применимых к телам.
  • CQ-editor, редактор с трёхмерным просмотром для отладки.
  • build123d, развитие той же идеи, переписанное с другим синтаксисом.

Идея строить трёхмерную схему ускорителя непосредственно из файла магнитной структуры принадлежит А. В. Петренко (ИЯФ СО РАН). Пример, приведённый в этой главе, написан заново и устроен иначе.

Цифровой двойник ускорителя

Задача

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

Цифровой двойник представляет собой программу, неотличимую от настоящей машины для всех остальных программ. Для проекта Accumulator требуется ферма двойников, то есть десятки виртуальных ускорителей, запущенных одновременно и отвечающих на те же команды, что и оборудование. На такой ферме отрабатываются алгоритмы AI/ML и параллельная оптимизация без какого-либо риска и в обстановке, приближенной к реальной смене.

Ферма собрана из четырёх частей. Физическим ядром служит Elegant, расчётный код динамики пучка. Оболочкой является SCAUT, библиотека, оркеструющая эксперименты на линаке инжектора СКИФ; её слои разобраны в главе «От скрипта к приложению». Внешним интерфейсом выступает EPICS IOC, а тиражирование собранной системы осуществляется контейнерами Docker.

Устройство одного двойника

Двойник реализован не как скрипт, запускающий Elegant, а как сервер, работающий по протоколу EPICS Channel Access с теми же именами каналов и в тех же единицах, что и реальная машина.

        ┌──────────────────────────────┐
        │  Клиент (Python / CS-Studio) │◄────────────┐
        └───────────────┬──────────────┘             │
                        │ caput / caget              │ readback PV
                        ▼                            │
        ┌──────────────────────────────┐             │
        │     EPICS Channel Access     │             │
        └───────────────┬──────────────┘             │
                        │ PV: ACC1:MG-LA1:QLF1:K1    │
                        ▼                            │
        ┌──────────────────────────────┐             │
        │      ElegantIOC (pcaspy)     │─────────────┘
        └───────────────┬──────────────┘◄────────────┐
                        │ eleput / eleget            │ результат
                        ▼                            │
        ┌──────────────────────────────┐             │
        │   Elegant (физическое ядро)  │─────────────┘
        └──────────────────────────────┘

Любой клиент, написанный для настоящей машины, работает с двойником без единой правки. Скрипт коррекции, операторский экран и оптимизатор вызывают caget и caput и не различают, с чем взаимодействуют: с оборудованием или с контейнером, запущенным на ноутбуке.

Сервер построен на библиотеке pcaspy. Точка входа accumulator/main.py разбирает файл структуры и передаёт полученный словарь каналов объекту SimpleServer вместе с префиксом экземпляра, взятым из переменной окружения. Затем запускается цикл, в котором запросы, принимаемые по сети, обрабатываются с частотой, заданной в IOC_REFRESH_RATE. Вся физика вынесена в драйвер ElegantIOC, унаследованный от pcaspy.Driver: он содержит два метода, write и read, вызываемые сервером при каждом обращении к каналу.

Структура вместо списка каналов

Список каналов нигде не задаётся вручную, а выводится из описания магнитной структуры. Файл config.lte описывает линак пятью секциями, первая из которых приведена ниже.

MG-LA1.QLF1: QUAD, L=0.115, K1=-10.115
MG-LA1.D250: DRIFT, L=0.250
MG-LA1.CL2:  KICKER
MG-LA1.D150: DRIFT, L=0.150
MG-LA1.QLD1: QUAD, L=0.115, K1=8.67
MG-LA1.D400: DRIFT, L=0.400
BI-LA1.PK3:  MONI

Секции состоят из квадруполей, корректоров, пикапов и дрейфов. Импульс, набираемый пучком, растёт от 38 МэВ/c на первой секции до 182 МэВ/c на пятой, а ускорение обеспечивают четыре резонатора с напряжением 36 МВ и частотой 2856 МГц, записанные тем же синтаксисом. Дрейфы и линии, перечисленные в файле, каналов не порождают: канал создаётся только для элемента, тип которого найден в таблице обработчиков.

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

ELEMENT_HANDLERS = {
    'QUAD': {
        'K1':    {'pv_suffix': 'K1',    'is_read_only': False, 'precision': 6, 'scale': 1.0},
        'DX':    {'pv_suffix': 'DX',    'is_read_only': False, 'precision': 6, 'scale': PK_MONITOR_SCALE_FACTOR},
        'betax': {'pv_suffix': 'betax', 'is_read_only': True,  'precision': 6, 'scale': 1.0},
        'betay': {'pv_suffix': 'betay', 'is_read_only': True,  'precision': 6, 'scale': 1.0},
        ...
    },
    'MONI': {
        'Cx': {'pv_suffix': 'Cx', 'is_read_only': True, 'precision': 6, 'scale': PK_MONITOR_SCALE_FACTOR},
        'Cy': {'pv_suffix': 'Cy', 'is_read_only': True, 'precision': 6, 'scale': PK_MONITOR_SCALE_FACTOR},
        ...
    },
}

Построчный разбор для каждого распознанного элемента порождает каналы в соответствии с этой таблицей.

etype = tokens[0].upper()
if etype in ELEMENT_HANDLERS:
    for param_name, field_def in ELEMENT_HANDLERS[etype].items():
        pv_name = get_pv_name(name, field_def['pv_suffix'])
        initial_val_ele = parse_param_value(rest, param_name)
        ...
        pvdb[pv_name] = {'type': 'float', 'prec': field_def['precision'],
                         'value': initial_val_ele * scale}
        element_map[pv_name] = {'name': name, 'type': etype,
                                'param': param_name,
                                'read_only': field_def['is_read_only'],
                                'scale': scale}

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

Граница, на которой переводятся единицы

Физика и система управления оперируют разными величинами. Внутри расчёта квадруполь описан так, как принято в физике пучков: K1 означает градиент, нормированный на жёсткость. Величиной же, задаваемой с пульта, является ток источника питания в амперах. Перевод между ними осуществляется по формуле

$$ k_1 = 0{,}2998,\frac{G\ [\text{Тл/м}]}{p\ [\text{ГэВ}/c]}, $$

а коэффициент записан в конфигурации вместе с его выводом.

ENERGY_LA1 = 38e-3  # [GeV/c]
# 6 A ~ 6.3 [T/m] & k1 = 0.2998 * G [T/m] / P [GeV/c]
QL_QUAD_SCALE_FACTOR = 1 / 6 * 6.3 * 0.2998
PK_MONITOR_SCALE_FACTOR = 1e3  # m to mm

PV_MAPPING = {
    "MG-LA1.QLF1.K1": "MG-LA1:QLF1-Iadd:Set",
    "BI-LA1.PK3.Cx":  "BI-LA1:PK3-fastAvX:Mea",
}
SCALE_MAPPING = {
    "MG-LA1.QLF1.K1": ENERGY_LA1 / QL_QUAD_SCALE_FACTOR,
}

При подстановке чисел коэффициент оказывается равным 0,1207, и значение \( K_1 = -10{,}115 \) соответствует −1,22 А. В скане, записанном на работающей машине, тот же канал держит −1,3 А, то есть двойник и оборудование расходятся на 6 %. Для пересчёта «ток — градиент», построенного по одной опорной точке 6 А ~ 6,3 Тл/м, такая точность является ожидаемой, и на этом уровне сценарии смены уже можно проверять.

Здесь же решается вторая задача — задача имён. По умолчанию канал называется MG-LA1:QLF1:K1, однако таблица PV_MAPPING переименовывает его в MG-LA1:QLF1-Iadd:Set, то есть так, как этот источник питания назван на пульте. Пикап, измеряющий горизонтальное положение, отвечает на имя BI-LA1:PK3-fastAvX:Mea. Именно эти имена ожидает помощник оператора из следующей главы.

Обработка одного caput

Запись в канал не сохраняет переданное значение, а пересчитывает физику, заложенную в структуру.

def write(self, reason, value):
    element_info = self.element_map[reason]
    if element_info['read_only']:
        logger.warning(f"Attempt to write to read-only monitor {reason}")
        return False
    sim_name = get_sim_name(element_info['name'], element_info['param'])
    scale = element_info.get('scale', 1.0)
    val_to_write = value / scale
    eleput(sim_name, val_to_write)
    self.setParam(reason, value)
    return True

Сервер находит элемент, указанный в имени канала, проверяет право на запись и делит значение на масштаб, переводя амперы обратно в \( K_1 \). Далее функция eleput, взятая из SCAUT, записывает параметр и заново запускает Elegant, трассирующий пучок через всю структуру. Один вызов caput влечёт полный пересчёт: за ответом стоит решение уравнений движения, а не заранее заготовленная таблица. Обратная функция eleget извлекает рассчитанные величины из файлов результатов, когда клиент читает канал-монитор.

Проверка описанного механизма выполняется двумя командами.

# положение пучка на третьем пикапе, миллиметры
caget ACC1:BI-LA1:PK3-fastAvX:Mea

# добавка к току первого квадруполя, амперы
caput ACC1:MG-LA1:QLF1-Iadd:Set -1.3

Ферма

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

                     Локальная сеть
   ┌────────────────────────────────────────────┐
   │  Компьютер 1                               │
   │     ACC1   ACC2   ...   ACC30              │◄──────┐
   │                                            │       │
   │  Компьютер 2                               │       │   ┌───────────────┐
   │     ACC31  ACC32  ...   ACC60              │◄──────┼───│  Оркестратор  │
   │                                            │       │   │  Optuna / ГА  │
   │  Компьютер 3                               │       │   └───────────────┘
   │     ACC61  ACC62  ...   ACC90              │◄──────┘
   └────────────────────────────────────────────┘

Девяносто контейнеров распределены по трём компьютерам, соединённым локальной сетью. Все они запущены из одного образа с разными переменными окружения, что соответствует принципу «артефакт один, конфигурация снаружи», разобранному в главе про асинхронный веб-сервис.

accelerator-1:
  image: accumulator:latest
  container_name: acc-emulator-1
  env_file: accumulator.env
  environment:
    - 'ACCUMULATOR_PREFIX=ACC1:'
  ports: ['8501:8501', '5064:5064', '5065:5065/udp']

Описание из девяноста одинаковых блоков вручную не составляется. Скрипт generate_compose.py, вызванный с числом экземпляров, распределяет порты с заданным шагом и печатает готовый файл: python scripts/generate_compose.py --count 90. Веб-панель занимает порты 8501, 8502 и далее подряд, а порты Channel Access разнесены с шагом в тысячу. Клиенту, которому необходимы все девяносто экземпляров одновременно, передаётся список адресов в переменной EPICS_CA_NAME_SERVERS: поиск канала широковещательной рассылкой заменяется прямым опросом перечисленных серверов.

Внутри каждого контейнера supervisor поддерживает два процесса, IOC и веб-панель, а образ содержит Elegant 2025.2.0 и набор утилит SDDS, установленных поверх Ubuntu 22.04. Панель, реализованная на Streamlit, читает тот же файл структуры, отображает каналы, сгруппированные по элементам, и позволяет править уставки из графического интерфейса без кода на стороне клиента.

Оркестратор распределяет испытания по свободным экземплярам через очередь.

INSTANCE_POOL = queue.Queue()
for _p in PREFIXES:            # ACC1:, ACC2:, ... ACC89:
    INSTANCE_POOL.put(_p)

def objective(trial: optuna.Trial) -> float:
    prefix = INSTANCE_POOL.get()      # ждём свободный экземпляр
    try:
        values = [trial.suggest_float(p, lo, hi)
                  for p, (lo, hi) in zip(actuator_pvs, bounds)]
        apply_actuators(prefix, actuator_pvs, values, strip=REAL_PREFIX)
        ...
        return float(np.mean(step_losses))
    finally:
        INSTANCE_POOL.put(prefix)     # возвращаем при любом исходе

Очередь здесь передаёт не данные, а право пользоваться ресурсом. Восемьдесят девять префиксов помещаются в пул, а девяностый экземпляр остаётся нетронутым: по нему впоследствии сверяется состояние двойника до подгонки. Число параллельных заданий равно размеру пула, а перебор ограничен тысячей испытаний и тысячей секунд.

Тысяча вариантов коррекции оптики: по осям — добавки к токам квадруполей

Цикл, ради которого всё затевалось

Двойник является звеном цикла, превращающего снятые измерения в уставки. Тетрадь optuna_tuner.ipynb проходит этот цикл целиком.

  1. Скан на реальной машине. Десять корректоров проходят заданную сетку, семнадцать каналов записываются как отклик, и результат сохраняется в JSON, снятый за 28 шагов.
  2. Подгонка двойника. Optuna перебирает 26 параметров, девятнадцать сдвигов и углов рассогласования и семь токов квадруполей, добиваясь совпадения отклика двойника с измеренным. Невязка вычисляется как средняя абсолютная разность, нормированная на шум пикапов, а испытание, прерванное ошибкой связи, получает штраф вместо остановки всего перебора. Заведомо неудачные варианты отсекает MedianPruner, и на каждое испытание берётся десять шагов скана из двадцати восьми.
  3. Коррекция на двойнике. Подогнанной модели в качестве цели задаются бета-функции номинального расчёта, четырнадцать чисел на семи квадруполях, и подбираются токи в пределах ±0,2 А от текущих.
  4. Перенос на оборудование. Функция apply_to_accelerator сначала читает текущие значения, сохраняет прочитанное в файл снимка, печатает таблицу «было, станет, разница» и только после этого, и только при явно переданном dry_run=False, записывает значения в машину.
  5. Проверка. Новый скан подтверждает улучшение.

После второго шага двойник моделирует не «линак вообще», а конкретную машину в конкретный день, со всеми рассогласованиями, зафиксированными в скане. В этом заключается разница между моделью и цифровым двойником.

Настроенный экземпляр получает отдельный префикс ACCT:, номинальный расчёт размещён под префиксом ACCM:, а за ACC: находится настоящая машина. Бета-функции, снятые со всех трёх, сводятся в одну таблицу, в которой видна невязка как до подгонки, так и после неё.

Назначение фермы

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

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

Оптимизация бета-функции на виртуальном ускорителе

Обучение ML/AI-моделей. Девяносто экземпляров порождают размеченные данные в объёмах, недостижимых на живой установке: каждая пара «уставки — отклик» обходится в один прогон Elegant, а не в часть смены. Выборка, набранная на ферме, покрывает и те режимы, до которых на реальной машине очередь не доходит. На этих данных обучаются модели коррекции орбиты и помощник оператора Сава, которому посвящена следующая глава.

Коррекция орбиты моделью машинного обучения

Полезные ссылки

  • Accumulator, исходники двойника, разобранного в этой главе.
  • SCAUT, библиотека, оркеструющая эксперименты на ускорителе.
  • Elegant, код расчёта динамики пучка, положенный в основу двойника.
  • EPICS, система управления, протокол которой воспроизводит двойник.
  • pcaspy, библиотека, позволяющая написать сервер EPICS на Python.
  • Optuna, оптимизатор, с помощью которого двойник подгоняется под машину.

Мультиагентный помощник оператора

Настоящая глава посвящена программе, установленной рядом с действующей машиной и взаимодействующей с оператором на естественном языке.

Задача

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

Первый массив составляют приборы. Сотни мониторов и тысячи PV-переменных названы по принятому в проекте регламенту наподобие ACC1:BI-LA1:PK3-fastAvX:Mea. Имя, собранное из участка, устройства, измеряемой величины и вида обработки, читается однозначно, однако удерживать в памяти тысячу таких имён невозможно. Для просмотра орбиты требуется знать и имена каналов, и панель, отведённую под них.

Второй массив представляет собой документацию, разбросанную по вики, чертежам, статьям и инструкциям, накопленным за годы проектирования. Ответ на вопрос «как измерить эмиттанс» где-то записан, но для его поиска необходимо вспомнить, в каком документе он находится, а документы, собранные разными коллективами за разные годы, единого оглавления не имеют.

Третий массив — журнал смены, заполняемый вручную. Запись, сделанная в конце дежурства по памяти, всегда короче происходившего, а слово «успешно» в ней остаётся мнением, никем не проверенным.

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

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

Устройство системы

Оператор взаимодействует с системой из Telegram-бота, из веб-интерфейса или из терминала. Запрос поступает в роутер — сервис, запрашивающий у языковой модели, какой инструмент требуется в этом случае, и рассылающий команды агентам. Каждый агент выполняет одну задачу и владеет одним хранилищем.

                       Telegram / Web / CLI
                                │
                                │ запрос
                                ▼
                          ┌───────────┐
                    ┌────▶│   Роутер  │◀────┐
                    │     └─────┬─────┘     │
              ответ │           │           │ ответ
        опрос,      │  докумен- │           │  запись
     управление     │   тация   │           │
            ┌───────┴───┐  ┌────▼──────┐  ┌─┴─────────┐
            │Контроллер │  │Консультант│  │ Секретарь │
            │   EPICS   │  │    RAG    │  │  журнал   │
            └─────┬─────┘  └─────┬─────┘  └─────┬─────┘
                  │              │              │
            ┌─────▼─────┐  ┌─────▼─────┐  ┌─────▼─────┐
            │   EPICS   │  │  Vector   │  │ PostgreSQL│
            │GetPV/SetPV│  │   Store   │  │           │
            └─────┬─────┘  └───────────┘  └───────────┘
                  │
            ┌─────▼─────┐
            │ Ускоритель│
            └───────────┘

Ответы, возвращаемые агентами, роутер собирает и передаёт оператору одним сообщением. Агенты обмениваются сообщениями, передаваемыми через очереди Apache Kafka; сама механика очередей рассмотрена в главе про сети и веб-технологии. Журнал смены хранится в PostgreSQL, устройство которого описано в главе про базы данных.

Работа роутера состоит из двух шагов. Сначала модель, получившая историю разговора и список инструментов, называет подходящий; затем вызовы, названные ею, преобразуются в команды, отправляемые агентам. Инструментов, видимых модели, десять, и ещё пять остаются внутренними: график, голос и сводку смены заказывает сама система, не расходуя на них решение модели. Результаты, поступающие от агентов, роутер объединяет в один ответ и отправляет туда, откуда пришёл запрос.

У системы объявлено пять специализаций.

  • Консультант осуществляет поиск по документации через RAG и отвечает с указанием источника.
  • Секретарь ведёт журнал смены: записывает и то, что выполнила система, и то, что продиктовал оператор.
  • Контроллер опрашивает PV и выполняет базовые проверки через EPICS.
  • Тренер помогает обучать новых операторов, разбирая с ними учебные ситуации.
  • Исследователь помогает анализировать данные и готовить отчёты.

Состав системы объявлен один раз, в реестре. ToolSpec описывает инструмент, видимый языковой модели, а AgentSpec — сервис, исполняющий инструменты. Из реестра выводится всё остальное: схемы, передаваемые модели, маршрутизация «инструмент → агент», список очередей и цели, опрашиваемые мониторингом.

@dataclass(frozen=True)
class ToolSpec:
    """Инструмент: что видит модель и кто его исполняет."""

    name: str
    schema: dict | None = None      # None — внутренний, модели не показывается
    writes: bool = False            # трогает оборудование
    long_running: bool = False
    min_role: Role = Role.OPERATOR

Описание инструмента в реестре, sava_lib/registry.py

Инструмент, объявленный без схемы, модели не показывается: график строит и голосом отвечает сама система, а не модель, решившая, что это уместно. Поле min_role задаёт наименьший уровень доступа: knowledge открывает только документацию, observer разрешает наблюдать и задавать вопросы, operator — работать с машиной. Научному руководителю, посетившему смену, требуется чтение, а студенту достаточно документации.

Контроллер: опрос и управление

Агент представляет собой набор функций, помеченных декоратором. Цикл опроса очереди, метрики, повторы и разбор ошибок реализованы в общем AgentRunner, и в самом агенте остаётся только полезная работа.

@runner.tool("GetPV")
def get(ctx: Ctx) -> dict:
    """Читает один PV."""
    pv_name = ctx.payload.get("pv_name", "")
    ctx.policy.enforce_read(pv_name)
    res = get_pv(pv_name, use_monitor=bool(ctx.payload.get("use_monitor", False)))
    return {"result": json.dumps(res, ensure_ascii=False)}


@runner.tool("SetPV")
def put(ctx: Ctx) -> dict:
    """Запись одного PV — только по конверту, подтверждённому оператором."""
    pv_name = ctx.payload.get("pv_name", "")
    value = float(ctx.payload.get("value"))
    if not bool(ctx.payload.get("confirmed", False)):
        raise PolicyError(f"Запись {pv_name} без подтверждения оператора.")
    applied = ctx.writer.put(pv_name, value, confirmed=True)
    return {"result": json.dumps({"pv_name": pv_name, "value": applied})}

Чтение и запись переменной EPICS, agents/pv-agent/main.py

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

Оператор: Опроси датчики положения пучка
          BI-LA1:PK3-fastAvX:Mea, BI-LA2:PK4-fastAvX:Mea

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea")  -> 0.15 мм
      -> GetPV("BI-LA2:PK4-fastAvX:Mea")  -> -0.08 мм

      Датчик PK3 показывает смещение 0.15 мм,
      PK4: -0.08 мм. Орбита близка к опорной.

Флаг writes, объявленный в реестре, делит инструменты на два класса. Читающему инструменту достаточно проверки политики, а пишущему выдаётся конверт — описание операции, составленное до разговора с оператором и подтверждаемое им целиком, одним «да». В конверте перечислены каналы, границы, назначенные каждому из них, предельное число записей и срок действия.

@dataclass(frozen=True)
class WriteEnvelope:
    """Что операции разрешено записать, в каких границах и сколько раз."""

    envelope_id: str
    command_id: str
    purpose: str                      # «матрица отклика орбиты»
    bounds: tuple[WriteBound, ...]
    max_writes: int
    ttl_s: float = 1800.0

    def check(self, pv: str, value: float) -> None:
        """Проверка одной записи."""
        b = self.bound_for(pv)
        if b is None:
            raise PolicyError(f"{pv} не входит в состав операции «{self.purpose}».")
        if value < b.lo or value > b.hi:
            raise PolicyError(f"{pv}={value:g} вне границ [{b.lo:g}, {b.hi:g}].")

Конверт записи, sava_lib/write_guard.py

Подтверждается операция целиком, а не каждая запись по отдельности. Одна коррекция орбиты складывается из десятков caput, выполняемых подряд, и оператор, перегруженный вопросами, перестанет их читать уже на пятом. Текст, показываемый оператору перед пуском, называет корректоры и пикапы поимённо, а не «сейчас поправлю орбиту», и рядом с каждым параметром отмечено, взят он из значений по умолчанию или из запроса. Значение, вышедшее за объявленную границу, приводит к отказу, а не к обрезке до допустимого.

Коррекция, состоящая из чтения, расчёта и двух записей, выполняется следующим образом.

Оператор: Орбита в линаке смещена, помоги скорректировать

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea")  -> 0.15 мм
      -> GetPV("BI-LA2:PK4-fastAvX:Mea")  -> -0.08 мм
      -> CalculateCorrection(0.15, -0.08)
         -> {"MG-LA1:CL1:HKICK": 0.5, "MG-LA1:CL2:HKICK": -0.3}
      -> SetPV("MG-LA1:CL1:HKICK", 0.5)
      -> SetPV("MG-LA1:CL2:HKICK", -0.3)

      Коррекция выполнена: MG-LA1:CL1:HKICK=0.5, MG-LA1:CL2:HKICK=-0.3.
      Проверьте новые показания датчиков.

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

Консультант: поиск по документации

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

1. Оператор: «Как измерить эмиттанс?»
        │
        ▼
2. Векторизация запроса
        │
        ▼                                Chunk 1: Ma Xiaochao, стр. 45
3. Поиск в Vector Store  ─────────────▶  Chunk 2: EPICS Wiki
   топ-3 фрагмента                       Chunk 3: регламент СКИФ
        │
        ▼
4. Добавление контекста в промпт
        │
        ▼
5. Модель отвечает с указанием источника

Третья ступень в репозитории реализована двойной. Плотностной поиск по векторам объединён со списком, найденным классическим BM25, а два порядка слиты по правилу обратного ранга: таким образом, слово, редко встречающееся в корпусе, не теряется среди фрагментов, близких по смыслу. Инструмент, объявленный в реестре, называется FileSearch.

Оператор: Как измерить эмиттанс?

Sava: -> FileSearch("Как измерить эмиттанс?")
         Находит: Ma Xiaochao, стр. 45

      Ответ: Для измерения эмиттанса используйте метод...
      Источник: Ma Xiaochao, стр. 45

Ссылка на источник является обязательной частью ответа. Номер страницы, привязанный к фрагменту ещё при разбиении, передаётся оператору, и тот открывает названное место самостоятельно. Устройство инструментов, предоставляемых языковой модели, описано в главе «Инструментарий».

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

Секретарь: журнал смены

Журнал наполняется из двух источников. Первый — сама система: каждая запись, применённая к оборудованию, попадает в журнал по факту, одной строкой на подтверждённое действие. Слово «успешно» здесь является не мнением модели, а результатом выполнения caput. Второй источник — оператор, сообщающий о том, чего система не наблюдает: о действиях у стойки, о наблюдениях, о переговорах со сменой.

Оператор: Я сделал 2 раза коррекцию орбиты
          в линейном ускорителе, запиши!

Sava: -> ActionRecord(
          action="Коррекция орбиты",
          system="Линейный ускоритель",
          details="Выполнено 2 итерации",
          result="Успешно")

      Записал в журнал: коррекция орбиты линака.

Поле system приводится к закрытому словарю подсистем. Название, полученное от модели, само по себе непригодно: одна и та же подсистема называлась бы то «Орбита», то «Уставки машины», и группировка, собираемая по свободному тексту, нарушилась бы на первой же сотне записей. Поле с именем оператора модель не заполняет — его проставляет система поверх присланных аргументов.

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

Настройка по шагам

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

Оператор: Проверь орбиту в линаке по x

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea" ... "PK7-fastAvX:Mea")
      Макс. отклонение PK5: 207 мкм

Оператор: Сделай коррекцию — используй первые два корректора

Sava: -> GetPV("MG-LA1:CL1:HKICK") -> 0.05
      -> GetPV("MG-LA1:CL2:HKICK") -> 0.0
      -> SetPV("MG-LA1:CL1:HKICK",  0.03)   # было 0.05
      -> SetPV("MG-LA1:CL2:HKICK", -0.02)   # было 0.0
      Коррекция выполнена. Рекомендую проверить результат.

Оператор: Проверь орбиту

Sava: -> GetPV("BI-LA1:PK3-fastAvX:Mea" ... "PK7-fastAvX:Mea")
      Результат: макс. отклонение 77 мкм (↓ в 2.7 раза)

За три шага с обратной связью отклонение, измеренное на пикапе PK5, уменьшилось с 207 до 77 мкм — в 2.7 раза. Прежние уставки корректоров указаны в комментариях: оператор видит, откуда и куда произведён сдвиг, и может вернуть исходное значение. Такие прогоны выполняются на цифровом двойнике. Двойник работает по тому же протоколу Channel Access и с теми же именами каналов, поэтому помощник не отличает его от установки, а цена ошибки при обучении оказывается нулевой. На нём же отрабатывается специализация тренера.

Пока коррекция, запущенная оператором, изменяет уставки корректоров, машина занята. Блокировка, объявленная у агента в реестре, заставляет остальных отвечать «оборудование занято», а не передавать показания, снятые посреди чужой процедуры. Длительная операция отправляет прогресс, и шлюз редактирует одно уже отправленное сообщение, не заполняя чат лентой статусов. Прогон, прерванный на середине, остаётся в таблице открытым, и заступающая смена видит его в сводке отдельной строкой.

Инициатива со стороны системы

Разговор начинает не только человек. Сторож, приставленный к списку каналов, следит за порогами и отправляет оператору сообщение, минуя языковую модель: факт, доставляемый в обход неё, доходит и при недоступном провайдере. Объяснение, составленное моделью, приходит вторым сообщением, и его потеря не является критичной.

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

Доступ к системе

Интерфейсов три.

  • Telegram-бот — доступ с любого устройства и в любое время. Текст, фото и голос на входе, текст, графики и голос на выходе; кнопки подтверждения расположены здесь же.
  • Веб-интерфейс — расширенные возможности: длинные диалоги, графики, встроенные непосредственно в ленту, микрофон.
  • CLI — встраивание в скрипты и автоматизацию.

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

Полезные ссылки

  • Apache Kafka, шина сообщений, связывающая агентов.
  • LangGraph, библиотека, строящая граф рассуждений модели.
  • EPICS, система управления, в которую в конечном счёте поступает запись.
  • faiss, поиск ближайших соседей, на котором основана векторная часть RAG.
  • Глава про цифровой двойник — установка, на которой всё это отработано, а глава про автономного исследователя — соседний случай, в котором языковая модель работает без человека.

Автономный исследователь на языковых моделях

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

Назначение системы

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

Основой служит открытая система AI Scientist, описанная в работе Lu et al. (2024) [43] и адаптированная здесь к ускорителям. Адаптация свелась к одному новому шаблону, размещённому рядом с готовыми шаблонами про языковые модели, диффузию и гроккинг. Шаблон, добавленный в форк, содержит постановку задачи, рабочий код, затравочные идеи и каркас статьи. Ничего специфически ускорительного в самой обвязке нет: вся физика сосредоточена в шаблоне, подключаемом по имени каталога.

Устройство автономного исследователя

Схема делит работу на три стадии. На первой модель формулирует идею, проверяет её на новизну поиском по литературе и помещает в архив с оценками. На второй идея превращается в код: правки в experiment.py вносит aider, редактор, управляемый моделью, а обвязка запускает переписанный код и возвращает модели напечатанные числа. Третья стадия собирает статью из каркаса LaTeX, после чего отдельный проход рецензирует написанное. Пунктирная стрелка, замыкающая схему, возвращает накопленные идеи в начало, так что следующая идея формулируется с учётом уже проверенных.

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

Постановка задачи

Всё, что системе известно о предметной области, взято из файла prompt.json. Роль и цель, поставленные перед моделью, заданы в нём первыми двумя полями.

System: You are an AI scientist specializing in accelerator physics and
beam dynamics with expertise in machine learning applications for particle
accelerator control systems. Your knowledge spans MAD-X simulations, beam
position monitoring (BPM) systems, and both traditional (SVD-based) and
AI-driven orbit correction methods.

Task: Minimize orbit deviations in the accelerator by optimizing corrector
magnet settings using a combination of numerical optimization and machine
learning techniques.

В том же файле перечислены требования, ограничивающие решение, и среда, в которой рассчитывается орбита.

"technical_requirements": [
    "Process beam position data from 16 BPMs (x/y coordinates)",
    "Account for hidden misalignment parameters affecting magnetic elements",
    "Maintain magnet currents within ±7A operational limits",
    "Achieve orbit residuals below 1e-4 m at all BPMs"
],
"simulation_environment": {
    "tool": "MAD-X",
    "configurations": [
        "vepp5_full.seq lattice",
        "0.4 GeV electron beam energy",
        "Error definitions: ealign with tgauss-distributed displacements"
    ]
}

Это задача, разобранная в главе про коррекцию равновесной орбиты: 16 датчиков положения, дающих по две координаты, предел корректора \(\pm 7\) А, модель накопителя ВЭПП-5. Разница заключается в том, что ставится она не человеку, а программе.

Смещения магнитных элементов, названные в требованиях «hidden», разыгрываются внутри модели и решателю не сообщаются, поэтому судить о них можно только по орбите, измеряемой в мониторах. Порог в \(10^{-4}\) м записан там же, и по нему система оценивает как свои прогоны, так и базу, рассчитанную заранее.

Сам шаблон представляет собой обычный каталог, размещённый среди прочих.

templates/closed_orbit_correction/
├── prompt.json       постановка задачи и системная роль
├── seed_ideas.json   пять затравочных идей
├── experiment.py     расчёт орбиты и коррекция по SVD
├── plot.py           графики по собранным результатам
├── vepp5_full.seq    оптика ВЭПП-5, читаемая MAD-X
└── latex/            каркас статьи

Прогон запускается командой python launch_scientist.py --experiment closed_orbit_correction, после чего участие человека не требуется до самого конца. Каталог результатов, названный по метке времени и имени идеи, создаётся в начале работы.

Точка отсчёта

Вместе с постановкой модели выдаётся работающий код, служащий одновременно образцом и базой сравнения. Ускоритель инициализируется через cpymad, пучок электронов задан с энергией 0.43 ГэВ, в постановке округлённой до 0,4 ГэВ, а ошибки расстановки квадруполей разыгрываются директивой ealign с гауссовым распределением, усечённым на двух с половиной сигмах. Орбита снимается в шестнадцати мониторах, отобранных по классу monitor. Коррекция выполнена классически, через псевдообратную матрицу отклика с отсечением малых сингулярных чисел.

def correct_orbit(self):
    elem_val_limit = 7
    svd_cutoff = 1e-3
    target_orbit = np.zeros(2 * self.num_bpms)

    matrix = self.calculcate_resp_mat()
    inv_mat = np.linalg.pinv(matrix, rcond=svd_cutoff)

    madx = self.start_madx()
    x, y = self._get_orbit(madx)
    current_orbit = np.concatenate((x, y))

    tmp_elem_val = -inv_mat.dot(current_orbit - target_orbit)
    tmp_elem_val = np.clip(tmp_elem_val, -elem_val_limit, elem_val_limit)

    elems_deltas = dict(zip(self.globals.keys(), tmp_elem_val))
    self.change_elements(elems_deltas)
    x, y = self._get_orbit(madx)

    self.stop_madx(madx)
    return x, y, elems_deltas

Матрица отклика здесь не берётся из модели, а измеряется: каждый корректор смещается на шаг вверх и вниз, а разность откликов, снятых в мониторах, делится на этот шаг. Так же поступают и на реальной машине. Ограничение np.clip по \(\pm 7\) А расположено внутри алгоритма, до подачи токов, а не в проверке, добавленной после него. Опечатка в имени calculcate_resp_mat оставлена без изменений, поскольку на неё опирается код, написанный системой позже.

Этот файл модель видит целиком, вместе с постановкой. Идеи, предложенные далее, сформулированы в терминах уже написанных методов: «доработать correct_orbit», «добавить в calculcate_resp_mat выбор подмножества корректоров».

Идеи, придуманные системой

Пять затравочных идей содержатся в seed_ideas.json, и модель дополняет их новыми, читая постановку и код. В запрос включается весь архив, накопленный к этому моменту, чтобы новая идея не повторяла предыдущие. Каждая идея описана коротким планом эксперимента и снабжена тремя оценками от одного до десяти: интересность, выполнимость и новизна. Оценки выставляет сама модель, следуя указанию, включённому в запрос: быть осторожной и реалистичной.

Сформулированное проходит через три раунда правки, в которых модель перечитывает и улучшает собственный ответ, а когда улучшать нечего, ответ помечается словами I am done. Затем идея проходит проверку на новизну: модель формулирует поисковые запросы, получает по десять статей с аннотациями из Semantic Scholar или OpenAlex и выносит вердикт строкой Decision made: novel. На решение отведено десять раундов, однако обычно достаточно двух-трёх. Идеи, признанные несамостоятельными, до эксперимента не допускаются.

Ниже приведены идеи, предложенные системой по коррекции орбиты.

ИдеяInt./Feas./Nov.
Hybrid SVD-Neural Network Correction Framework. Каскад: грубая коррекция по SVD, затем нейросеть, обученная на остатках. Сравнить чистые NN и SVD с гибридом. До 100 000 особей и до 10 итераций9 / 7 / 8
Genetic Algorithm for Optimizing Orbit Correction. Генетический алгоритм с отбором, скрещиванием и мутацией для настройки корректоров. Сравнить норму \(L_2\) и время счёта с SVD и NN9 / 8 / 9
Predictive Control Using LSTM-Based Forecasting. Предсказание положения пучка по истории BPM и упреждающая подстройка корректоров до появления отклонения9 / 5 / 9

В работу была принята первая из них.

Эксперимент

Идею принимает цикл, отмеряющий системе лимит попыток. Прогонов по умолчанию не более пяти, неудачных заходов подряд не более четырёх, и работа, не завершённая в отведённых рамках, прерывается.

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

MAX_ITERS, MAX_RUNS = 4, 10

def perform_experiments(idea, folder_name, coder, baseline_results) -> bool:
    current_iter, run = 0, 1
    next_prompt = coder_prompt.format(
        title=idea["Title"], idea=idea["Experiment"],
        max_runs=MAX_RUNS, baseline_results=baseline_results,
    )
    while run < MAX_RUNS + 1:
        if current_iter >= MAX_ITERS:
            break
        coder_out = coder.run(next_prompt)
        if "ALL_COMPLETED" in coder_out:
            break
        return_code, next_prompt = run_experiment(folder_name, run)
        if return_code == 0:
            run += 1
            current_iter = 0
        current_iter += 1

Каждый прогон запускается как python experiment.py --out_dir=run_i, и модель получает обратно либо рассчитанные средние из final_info.json, либо последние полторы тысячи знаков stderr. Завершившийся с ошибкой прогон не прерывает работу, а возвращается модели текстом ошибки, по которому она исправляет код. Каталог неудачного захода при этом удаляется, чтобы незавершённые результаты не попали в статью. Результаты, признанные пригодными, дописываются в notes.txt вместе с описанием прогона, и оттуда их впоследствии берёт стадия написания статьи. Код, исполняемый на этом шаге, написан языковой моделью, поэтому авторы системы рекомендуют изолировать прогон в контейнере, и Dockerfile для этого включён в репозиторий.

По окончании расчёта той же модели поручается переписать plot.py и перечислить в нём прогоны, заслуживающие графика. Заголовки, подписи осей и легенда выбираются на этом шаге, а подробное описание каждого графика заносится в те же заметки.

Код гибридной коррекции система написала самостоятельно. Сеть в нём обучается не отображению «орбита — токи» целиком, а поправке к решению, найденному через SVD, и эта поправка накапливается по итерациям.

def hybrid_correct_orbit(self, model=None, iterations=0, svd=True):
    elem_val_limit = self.corr_limit
    x_new, y_new, elems_deltas = self.correct_orbit()   # грубая коррекция
    R = np.concatenate((x_new, y_new))
    if model is None:
        return x_new, y_new, elems_deltas

    dI_nn_total = np.zeros(len(self.globals)) + list(elems_deltas.values())
    for it in range(iterations):
        dI_nn = model.predict(R.reshape(1, -1))[0]      # поправка по остатку
        dI_nn_total += dI_nn
        dI_nn_total = np.clip(dI_nn_total, -elem_val_limit, elem_val_limit)

        self.change_elements(dict(zip(self.globals.keys(), dI_nn_total)))
        madx = self.start_madx()
        x_new, y_new = self._get_orbit(madx)
        self.stop_madx(madx)
        R = np.concatenate((x_new, y_new))

    return x_new, y_new, elems_deltas

Обучающая выборка набиралась отдельным прогоном: 100 000 случайных расстроек, для каждой из которых снят остаток после SVD и рассчитана идеальная добавка по номинальной матрице отклика, измеренной без расстроек. Сеть выбрана простая, MLPRegressor с двумя скрытыми слоями 64 и 32, а обученная модель сохранена в nn_model.joblib и далее только читается. Прогоны с первого по десятый отличаются одним числом — количеством итераций сети, извлечённым из имени каталога.

Коррекция орбиты гибридом SVD и нейросети: начальная и остаточная невязка по числу итераций

На рисунке приведены одиннадцать пар столбцов, по паре на прогон, шкала логарифмическая. Синий столбец — начальная невязка \(\sum (x_i^2 + y_i^2)\) в м², оранжевый — остаточная, а планки погрешностей, построенные по стандартной ошибке среднего, показывают разброс. В каждом прогоне сто независимых расстроек, разыгранных заново, поэтому начальная невязка остаётся примерно одинаковой от прогона к прогону, и оранжевые столбцы сравнимы между собой. Нулевая итерация, рассчитанная без сети, соответствует чистому SVD: \(1.9 \cdot 10^{-4}\) м² до коррекции и \(4.8 \cdot 10^{-5}\) после. Три итерации сети дают \(5.9 \cdot 10^{-6}\), что является лучшей точкой на графике. К десятой итерации остаток возвращается к \(3.5 \cdot 10^{-5}\).

Статья и рецензия

Написанием статьи занимается тот же aider, но с другим набором файлов, открытых для правки: код эксперимента, заметки и latex/template.tex. Ссылки, вставляемые в текст, система находит самостоятельно тем же поиском по литературе. Готовый PDF собирается pdflatex, а chktex проверяет разметку. Ошибки сборки, найденные этими программами, возвращаются модели тем же способом, что и ошибки расчёта.

Последним включается рецензент. Статья, преобразованная обратно в простой текст, подаётся модели с формой отзыва в стиле NeurIPS, в которой необходимо выставить оценки за оригинальность, качество, ясность и значимость, дать общую оценку от одного до десяти и вынести решение Accept или Reject. Собирается пять отзывов, которые сводятся мета-рецензентом; температура установлена равной 0.1. Статья про гибридную коррекцию, оценённая собственным рецензентом на 3 из 10, была им же отклонена.

Ограничения и перспективы

Ниже перечислены ограничения, признанные за системой.

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

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

Полезные ссылки

Асинхронное API для кинотеатра

Задача каталога фильмов совпадает с задачей электронного журнала эксперимента, портала выдачи данных или системы управления установкой: по идентификатору получить запись, обратившись сначала к кешу, а затем к хранилищу, и отдать её по HTTP, не заставляя при этом ждать остальных клиентов. При замене существительных, когда вместо фильма подставляется выстрел, а вместо жанра тип детектора, получается сервис, отдающий данные, снятые в эксперименте.

Предметная область, посторонняя физике, выбрана намеренно: когда в примере присутствует пучок, внимание сосредотачивается на пучке, а устройство программы остаётся незамеченным. Здесь же рассматривается именно архитектура: слои, асинхронный ввод-вывод, кеш и работа с подключёнными хранилищами. Та же архитектура лежит в основе главы про цифрового двойника, где наружу выставляются каналы EPICS, и главы про помощника оператора, где сервисы взаимодействуют через очереди.

Данные хранятся в полнотекстовом поисковом движке Elasticsearch, а часто запрашиваемые ответы кешируются в Redis, чтобы не обращаться к поиску повторно. Всё взаимодействие с внешними хранилищами асинхронное, и пока один запрос ожидает ответа от базы, сервис обслуживает десятки других, поступивших в ту же секунду. Механика async/await была разобрана в главе про асинхронность.

Структура проекта

Проект разделён на слои. Каждый каталог, названный по отведённой ему роли, отвечает за одну задачу.

project/
├── Dockerfile
├── requirements.txt
├── src/
│   ├── main.py
│   ├── api/
│   │   └── v1/
│   │       └── film.py
│   ├── core/
│   │   ├── config.py
│   │   └── logger.py
│   ├── db/
│   │   ├── elastic.py
│   │   └── redis.py
│   ├── models/
│   │   └── film.py
│   └── services/
│       └── film.py

Такое разделение называется слоистой (луковичной) архитектурой, в которой api знает про services, services знает про db и models, но не наоборот. Благодаря этому источник данных заменяется без изменения ни одного обработчика HTTP-запросов: при переходе с Elasticsearch на PostgreSQL правятся слой db и один метод сервиса, _get_film_from_elastic, а api и models остаются прежними. Чтобы изменения затрагивали только db, хранилище скрывается за интерфейсом-репозиторием с методом get_by_id, который сервис получает через Depends.

Основные зависимости

aioredis==1.3.1
elasticsearch[async]==7.9.1
fastapi==0.61.1
orjson==3.4.1
uvicorn==0.12.2
uvloop==0.14.0

Ядром служит FastAPI, асинхронный веб-фреймворк, самостоятельно генерирующий документацию к API из аннотаций типов. Запускается он сервером uvicorn, uvloop представляет собой быструю замену стандартного цикла событий, а orjson служит сериализатором JSON, в разы превосходящим стандартный по скорости. Клиенты к Redis и Elasticsearch взяты в асинхронных версиях, поскольку обычные, блокирующие, остановили бы весь цикл событий на всё время выполнения запроса к базе.

Этот список зафиксирован осенью 2020 года, и с тех пор половина перечисленных имён сменилась. aioredis заброшен, а его наследник перенесён внутрь основного клиента, и сегодня пул создаётся через redis.asyncio, где Redis.from_url заменяет create_redis_pool, а срок жизни ключа задаётся аргументом ex= вместо expire=. Обработчики @app.on_event('startup') и @app.on_event('shutdown') в FastAPI помечены устаревшими, и вместо пары обработчиков пишется один менеджер контекста lifespan, передаваемый конструктору приложения. У современного клиента Elasticsearch аргументы get стали именованными, так что вызов выглядит как await es.get(index='movies', id=film_id). В pydantic 2 не осталось ни Config.json_loads/json_dumps, ни parse_raw с .json(): их место заняли model_validate_json и model_dump_json, а подмену сериализатора на orjson берёт на себя ORJSONResponse, передаваемый FastAPI как класс ответа. Разбор слоёв от этого не устаревает; переписывание имён вызовов на современные является упражнением на полчаса.

Конфигурация

core/config.py:

import os
from logging import config as logging_config
from core.logger import LOGGING

logging_config.dictConfig(LOGGING)

PROJECT_NAME = os.getenv('PROJECT_NAME', 'movies')
REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
ELASTIC_HOST = os.getenv('ELASTIC_HOST', '127.0.0.1')
ELASTIC_PORT = int(os.getenv('ELASTIC_PORT', 9200))
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

Все настройки читаются из переменных окружения со значениями по умолчанию, подобранными для локальной разработки. Это 12-факторный подход, при котором один и тот же собранный образ приложения работает и на ноутбуке, и в продакшене, а меняются только переменные окружения.

База данных

db/elastic.py:

from typing import Optional
from elasticsearch import AsyncElasticsearch

es: Optional[AsyncElasticsearch] = None

async def get_elastic() -> AsyncElasticsearch:
    return es

db/redis.py:

from typing import Optional
from aioredis import Redis

redis: Optional[Redis] = None

async def get_redis() -> Redis:
    return redis

Здесь создаются глобальные соединения с хранилищами. Модуль хранит объект клиента, а функции get_elastic()/get_redis() его возвращают; впоследствии эти функции подставляет механизм зависимостей FastAPI, разрешающий их на каждый запрос. Пока приложение не запущено, оба клиента равны None, поэтому тип помечен как Optional.

Основное приложение

main.py:

import logging
import aioredis
import uvicorn
from elasticsearch import AsyncElasticsearch
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

from api.v1 import film
from core import config
from core.logger import LOGGING
from db import elastic, redis

app = FastAPI(
    title=config.PROJECT_NAME,
    docs_url='/api/openapi',
    openapi_url='/api/openapi.json',
    default_response_class=ORJSONResponse,
)

@app.on_event('startup')
async def startup():
    redis.redis = await aioredis.create_redis_pool(
        (config.REDIS_HOST, config.REDIS_PORT),
        minsize=10,
        maxsize=20
    )
    elastic.es = AsyncElasticsearch(
        hosts=[f'{config.ELASTIC_HOST}:{config.ELASTIC_PORT}']
    )

@app.on_event('shutdown')
async def shutdown():
    redis.redis.close()
    await redis.redis.wait_closed()
    await elastic.es.close()

app.include_router(film.router, prefix='/api/v1/film', tags=['film'])

if __name__ == '__main__':
    uvicorn.run('main:app', host='0.0.0.0', port=8000)

Это точка входа. Соединения с базами, созданные в обработчике события startup, закрываются в парном обработчике shutdown: открывать их на каждый запрос было бы расточительно, поэтому используется пул соединений (minsize=10, maxsize=20). Обработчики запросов подключаются роутером с префиксом /api/v1/, а версия, включённая в URL, позволит впоследствии выпустить v2, не нарушая работу уже написанных клиентов.

API слой

api/v1/film.py:

from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from services.film import FilmService, get_film_service

router = APIRouter()

class Film(BaseModel):
    id: str
    title: str

@router.get('/{film_id}', response_model=Film)
async def film_details(
    film_id: str, 
    film_service: FilmService = Depends(get_film_service)
) -> Film:
    film = await film_service.get_by_id(film_id)
    if not film:
        raise HTTPException(
            status_code=HTTPStatus.NOT_FOUND, 
            detail='film not found'
        )
    return Film(id=film.id, title=film.title)

Слой HTTP является тонким: он принимает запрос, вызывает сервис и возвращает ответ или ошибку 404. Бизнес-логика в нём отсутствует.

Строка Depends(get_film_service) осуществляет внедрение зависимостей. FastAPI самостоятельно вызывает get_film_service, а тот получает клиентов Redis и Elasticsearch, созданных при старте. Обработчику не требуется знать, откуда берутся соединения, а в тестах их легко подменить заглушками. Класс Film(BaseModel) описывает формат ответа, а pydantic проверяет типы и добавляет схему в автодокументацию, опубликованную по адресу /api/openapi.

Сервисный слой

services/film.py:

from functools import lru_cache
from typing import Optional
from aioredis import Redis
from elasticsearch import AsyncElasticsearch, NotFoundError
from fastapi import Depends

from db.elastic import get_elastic
from db.redis import get_redis
from models.film import Film

class FilmService:
    def __init__(self, redis: Redis, elastic: AsyncElasticsearch):
        self.redis = redis
        self.elastic = elastic
    
    async def get_by_id(self, film_id: str) -> Optional[Film]:
        film = await self._film_from_cache(film_id)
        if not film:
            film = await self._get_film_from_elastic(film_id)
            if not film:
                return None
            await self._put_film_to_cache(film)
        return film
    
    async def _get_film_from_elastic(self, film_id: str) -> Optional[Film]:
        try:
            doc = await self.elastic.get('movies', film_id)
        except NotFoundError:
            return None
        return Film(**doc['_source'])
    
    async def _film_from_cache(self, film_id: str) -> Optional[Film]:
        data = await self.redis.get(film_id)
        if not data:
            return None
        film = Film.parse_raw(data)
        return film
    
    async def _put_film_to_cache(self, film: Film):
        await self.redis.set(film.id, film.json(), expire=60 * 5)

@lru_cache()
def get_film_service(
    redis: Redis = Depends(get_redis),
    elastic: AsyncElasticsearch = Depends(get_elastic),
) -> FilmService:
    return FilmService(redis, elastic)

В этом слое сосредоточена вся логика. Метод get_by_id реализует шаблон cache-aside: сначала выполняется обращение к кешу, при промахе — к основному хранилищу, после чего найденный результат помещается в кеш на будущее (здесь на 5 минут, expire=60 * 5). Приватные методы с подчёркиванием разделяют три операции: получение из Elasticsearch, получение из кеша и запись в кеш.

Декоратор @lru_cache(), применённый к фабрике сервиса, гарантирует, что объект FilmService создаётся один раз, а не на каждый HTTP-запрос.

Модели

models/film.py:

import orjson
from pydantic import BaseModel

def orjson_dumps(v, *, default):
    return orjson.dumps(v, default=default).decode()

class Film(BaseModel):
    id: str
    title: str
    description: str
    
    class Config:
        json_loads = orjson.loads
        json_dumps = orjson_dumps

Модель является единственным описанием фильма, на которое опираются все слои. Pydantic разбирает по ней ответ, полученный от Elasticsearch, и он же сериализует разобранный объект в кеш и обратно. Класс Config подменяет стандартный модуль json на более быстрый orjson, поскольку на кешируемых ответах сериализация является далеко не самой дешёвой частью запроса.

Моделей здесь две: models.Film с полным набором полей и Film из слоя API всего с двумя. Это не дублирование: первая описывает содержимое хранилища, вторая — контракт, обещанный наружу. Добавление поля в хранилище не нарушает контракт, обещанный клиентам.

Приёмы, заслуживающие заимствования

В примере собраны решения, окупающиеся в любом сервисе, в том числе написанном для собственной установки.

  • разделение на слои, при котором API, логика и доступ к данным существуют отдельно и заменяются независимо;
  • внедрение зависимостей, когда код не создаёт необходимые соединения сам, а получает их извне, что одновременно делает его тестируемым;
  • конфигурация через переменные окружения, не оставляющая в исходниках ни одного адреса или пароля;
  • кеширование, самый дешёвый способ ускорить сервис, если данные меняются редко, а запрашиваются часто;
  • версионирование API, при котором /api/v1/ появляется с первого дня, чтобы впоследствии не нарушать работу существующих клиентов;
  • асинхронность, дающая при работе с сетью и базами выигрыш практически без дополнительных затрат.

Запуск осуществляется в контейнерах, где Dockerfile для приложения соседствует с образами Redis и Elasticsearch, связанными через Docker Compose. Устройство такой сборки было разобрано в главе про Docker.

Структура заданий

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

Почти у каждого задания есть репозиторий, в котором размещена его формулировка и в который удобно сдавать решение форком, как это устроено в главе про Git. Заготовка есть не везде. В задании на ревью размещён код, который предстоит разбирать; в «Деплое стартапа» код разнесён по веткам front_code и my_backend, и свести их вместе означает выполнить первую часть работы. Остальным заданиям заготовка не требуется, поскольку в них настраивается собственное окружение, обрабатываются собственные данные или решаются задачи на внешнем сайте.

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

Адрес репозитория складывается из https://github.com/ и имени из таблицы.

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

Ревью чужого кода

Опирается на главы «Жизненный цикл ПО» и «Требования к коду».

Задание

  • Провести ревью предложенного кода, отыскав в нём ошибки, неточности и неэффективные места и дав рекомендации по улучшению.
  • Задача не сводится к исправлению ошибок: требуются комментарии и рекомендации.

Требования к сдаче

  • Ссылка на форк, в котором замечания оформлены комментариями к строкам кода. Наиболее простой способ: завести в собственном форке ветку с исправлениями и открыть pull request внутри форка, из этой ветки в main; в диффе GitHub позволяет комментировать каждую строку. Отдельный текстовый файл со списком замечаний не подходит: замечание должно располагаться рядом с кодом.
  • Замечания разделены на два вида: ошибки, из-за которых программа работает неверно или завершается аварийно, и рекомендации, где код работает, но написан неудачно.
  • У каждой ошибки указано, при каких данных она проявится. «Тут может упасть» замечанием не считается.
  • У каждой рекомендации указано, что она улучшает: скорость, читаемость или устойчивость к изменениям.

Ссылка на репозиторий

Генерация текста на основе данных

Опирается на главы «Технические основы» и «Коллекции». Цепь Маркова является простейшей языковой моделью, и на ней видно, откуда у больших моделей берутся связность и склонность выдумывать.

Требуется реализовать на цепях Маркова генератор текста: программу, которая читает готовый корпус, запоминает, какое слово за каким встречалось, и выдаёт новый текст, похожий на исходный настолько, насколько позволяет память в одно-два слова.

Логика программы

  • Файл с исходным текстом считывается и разбивается на слова.
  • Все слова, стоящие рядом, соединяются в пары (или более длинные последовательности).
  • На основе этих пар составляется словарь цепочек, содержащий первое слово и все слова, которые могут следовать за ним.
  • Стартовое слово выбирается случайно, с единственным требованием: первая буква заглавная, иначе текст начнётся с середины фразы.
  • Задаётся длина текста на выходе и формируется результат.

Требования к сдаче

  • Программа, принимающая имя файла с исходным текстом и длину результата в словах.
  • Сгенерированный текст на двух разных исходных корпусах. Заготовки в репозитории нет, тексты подбираются самостоятельно: подойдёт любая книга с «Проекта Гутенберг» или lib.ru при условии, что авторы различны, а объём составляет не менее сотни тысяч слов. На коротком тексте цепь вырождается в пересказ источника.
  • Сравнение цепочек по два и по три слова: короткий ответ на вопрос, что меняется в связности текста и почему.
  • Ответ на вопрос, что произойдёт, если слово встретилось в тексте всего один раз и оказалось последним.

Ссылка на репозиторий

Установка Arch Linux вручную с RAID1 и UEFI

Опирается на главы «Процесс загрузки», «Файловые системы» и «Установка Arch Linux».

Условия задания

  1. Настройка виртуальной машины.

    • Поднять виртуальную машину на localhost
    • Первоначально выбрать Legacy BIOS (SeaBIOS) в настройках
    • После установки переключить на UEFI
  2. Требования к установке.

    • Установить Arch Linux полностью вручную, без направляемого установщика archinstall (штатные pacstrap, genfstab и arch-chroot использовать допускается и рекомендуется)
    • Использовать программный RAID1 (зеркало)
    • Система должна оставаться работоспособной при отказе любого из дисков
    • Загрузчик должен работать через UEFI (должна появиться отдельная запись в efibootmgr)
  3. Особенности разметки.

    • Обязательно выделить отдельный раздел под загрузчик, причём на обоих дисках: прошивка не способна читать ESP с mdraid-метаданными, поэтому его копию и отдельную запись в efibootmgr необходимо создать вручную. Без этого система со второго диска не загрузится
    • Продемонстрировать работу как в Legacy BIOS, так и в UEFI режиме

Требования к сдаче

  • Главное: система загружается с одним диском. Необходимо отключить в настройках виртуальной машины первый диск, загрузиться со второго и показать cat /proc/mdstat: массив в состоянии degraded, система работает. Затем требуется вернуть диск и показать, что массив пересобрался. Без этой демонстрации задание не принимается: RAID1, не проверенный на отказ, ничем не лучше одного диска.
  • Вывод efibootmgr -v с записью загрузчика, доказывающий, что загрузка осуществляется через UEFI, а не через BIOS.
  • Отчёт в свободной форме: пошаговое руководство, перечень команд с пояснениями, скриншоты ключевых этапов или их сочетание. Отчёт должен позволять повторить установку.

Ссылка на репозиторий

Настройка окружения и работа с утилитами

Опирается на главы «Инструменты Linux» и «Внутреннее устройство Linux»; strace и perf в книге только названы, поэтому разбираться с ними необходимо по man perf-record, man perf-report и по схемам Брендана Грегга.

Условия задания

Настройка окружения

Эта часть книгой не покрывается: рабочее место настраивается каждым студентом самостоятельно по документации выбранных инструментов.

  1. Эмулятор терминала.

    • Установить и настроить современный эмулятор терминала
    • В отчёте указать выбранный эмулятор и доводы, определившие выбор
  2. Настройка PS1.

    • Оформить приглашение командной строки, выводимое перед каждой командой, в формате login@your-login-at-nsu:
    • Использовать три разных цвета для:
      • Логина
      • Символа @
      • Последней части приглашения
  3. Установка asciinema.

    • Установить утилиту
    • Освоить базовые функции, отвечающие за запись скринкастов

Практические задания

  1. Работа с gtypist.

    • Установить gtypist
    • Записать скринкаст, показывающий успешное прохождение упражнения S3
    • В кадре должны быть видны:
      • Raw speed
      • Adjusted speed
      • Процент ошибок
  2. Command line murders.

    • Записать скринкаст решения задачи
  3. Мониторинг системы.

    • Создать виртуальную машину с Ubuntu
    • Подключиться по SSH
    • Записать скринкаст выполнения команд, проверяющих:
      • Число ядер и модель CPU
      • Общий размер и свободная оперативная память
      • Занятое/свободное место в системе
      • Утилизация дисковой подсистемы (IOPS)
      • Скорость и утилизация сетевого линка
  4. Curl-однострочник.

    • Написать однострочник, который раз в секунду обращается с помощью curl по адресу https://storage.mds.yandex.net/ping и выводит для каждого запроса в одну строку только: таймстемп запуска команды, код ответа, время установки tcp-соединения, время установки tls-соединения, time to first byte, и общее время выполнения запроса
    • Записать скринкаст работы написанного однострочника
  5. Работа с perf.

    • Записать perf record и просмотреть perf report
    • В виртуальной машине аппаратные счётчики процессора обычно недоступны, и perf record, запущенный без аргументов, завершится ошибкой на событии cycles. Достаточно использовать программное событие, поддерживаемое ядром, например perf record -e cpu-clock -g …. Если и оно отказывает, доступ к счётчикам ограничивается параметром kernel.perf_event_paranoid: необходимо посмотреть текущее значение через sysctl и понизить его настолько, насколько требуется, понимая, какие возможности при этом разрешаются
    • (*) Дополнительное задание.
      • Собрать zstd с debug-символами
      • Запустить сжатие: cat /dev/urandom | zstd -19 -f -T4 -v - -o out.zst
      • Снять perf record с процесса zstd
      • Найти в исходниках самую нагруженную функцию

Требования к сдаче

  • Скринкасты в формате asciinema, по одному на каждый пункт практической части. На записи должен быть виден полученный результат, а не только набор команды. У gtypist это итоговые Raw speed, Adjusted speed и процент ошибок, у однострочника с curl — несколько строк вывода со всеми шестью полями, у мониторинга — ответы на все пять вопросов.
  • Название выбранного эмулятора терминала и одна фраза, объясняющая выбор.
  • Для задания со звёздочкой требуется ссылка на запись perf report, сделанную с процесса zstd, и имя самой нагруженной функции с указанием, в каком файле исходников она найдена.

Формат отчёта свободный (допускается комбинировать текст, скриншоты и ссылки на опубликованные записи)

Ссылка на репозиторий

Работа с долгоиграющими процессами

Опирается на главы «Внутреннее устройство Linux» и «Инструменты Linux», в которых разобраны процессы, состояния, сигналы и планировщик.

Условия задания

Основная часть

  1. Тестовая программа — скрипт с шебангом #!/usr/bin/bash и бесконечным циклом while true; do echo "I am still alive"; sleep 1; done; развёрнутый листинг размещён в репозитории задания.

  2. Запустить программу

  3. Обеспечить её продолжение работы после отключения от сервера

Задание со звездочкой (*)

  1. Программа должна продолжать работу в фоне (*)

  2. Обеспечить возможность чтения stdout и stderr (*)

  3. Перенаправить выводы в соответствующие файлы (*)

Требования к сдаче

  • Команда или способ, благодаря которому процесс переживает отключение от сервера, и объяснение, почему он работает: какой сигнал приходит процессу при закрытии сессии и что с ним происходит.
  • Вывод ps, снятый после повторного входа и доказывающий, что процесс жив.
  • Для задания со звёздочкой: файлы, в которые перенаправлены stdout и stderr, и способ прочитать их у работающего процесса, не останавливая его.

Ссылка на репозиторий

Решение задач на Python и анализ сложности

Опирается на главы «Введение в алгоритмы» и «Сложность операций с коллекциями».

Задача: необходимо решить две любые задачи «легкого» уровня с сайта CodeRun от Яндекса.

Требования к сдаче

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

  1. Ссылка на задачу.
  2. Исходный код решения на Python.
  3. Пояснение: подробное описание хода решения.
  4. Обоснование: объяснение, почему выбран этот алгоритм и эти структуры данных (например, список, словарь, множество).
  5. Анализ сложности:
    • Временная сложность (Big O Notation).
    • Пространственная сложность.

Цель: научиться не только писать рабочий код, но и понимать его эффективность.

Ссылка на репозиторий

Деплой стартапа «Котики в мир»

Опирается на главы «От скрипта к приложению», «Docker», «Сети и веб-технологии» и «Базы данных».

Контекст проекта

Код в репозитории размещён не в main, а в ветках front_code и my_backend. Сначала необходимо разобраться в их содержимом и свести его вместе.

Моделируется ситуация присоединения к стартапу, в котором:

  • Фронтенд-разработчик и бэкенд-разработчик оставили незавершённый код
  • Необходимо интегрировать их наработки в рабочую систему
  • Проект должен быть готов к промышленному деплою

Технические требования

1. Подготовка репозитория

  • Привести Git-репозиторий в порядок
  • Слить все рабочие ветки в main
  • Организовать код в структурированные директории (frontend, backend, nginx)

2. Docker

  • Создать отдельные Docker-образы для бэкенда и фронтенда
  • Настроить взаимодействие через compose.yaml (старое имя docker-compose.yml также читается)
  • Обеспечить сборку образов через docker compose build

Требования к сдаче

1. Состояние репозитория

  • Чистая ветка main
  • Логичное разделение кода по директориям
  • Рабочие Dockerfile для каждого сервиса

2. Запуск системы

  • Фронтенд доступен на http://localhost
  • Бэкенд отвечает на API-запросы

3. Архитектура

  • Наружу опубликован только один порт, 80-й у Nginx. У остальных сервисов в docker-compose.yml отсутствует секция ports, присутствует только expose (разница между ними разобрана в главе про Docker).
  • Проверка осуществляется следующим образом: docker compose ps показывает публикацию порта лишь у одного сервиса, а curl на порт бэкенда с хоста не отвечает.
  • Между собой сервисы обращаются по именам, которые Compose назначает в созданной им сети, а не по адресам, прописанным вручную.

Ссылка на репозиторий

Итоговый проект

Опирается на всю книгу, прежде всего на главы «Требования к коду», «Жизненный цикл ПО» и «От скрипта к приложению», откуда следуют требования к сборке, тестам и документации. Примеры доведённой до конца работы приведены в части «Разбор реальных кодов».

Формальные требования к выполнению проекта:

  1. Задача формулируется участниками самостоятельно.
  2. Требования необходимо разделить на базовые и дополнительные.
  3. Базовые требования, как правило, связаны друг с другом, и реализовывать их приходится вместе, а из дополнительных выбирается то подмножество, которое возможно выполнить в срок.
  4. Проект должен быть связан с физикой, а основным языком реализации предполагается Python.
  5. Работа ведётся командой в два-три человека (исключения возможны).
  6. Каждый участник отвечает за свою часть.
  7. Решение задачи предполагает как минимум три этапа:
    • Формулировка задачи (срок: последняя неделя ноября). Результатом этапа становится документ, содержащий уточнённую формулировку задачи, а также предполагаемый путь её решения. Рекомендуемый объём этой части документа — 1-2 страницы. Кроме того, документ должен содержать табличное описание оставшихся этапов: сроки, полученная функциональность, разделение ответственности между участниками.
    • Реализация базовой функциональности (срок: вторая неделя декабря). На этом этапе должны быть реализованы базовые требования.
    • Расширенная функциональность (срок: зачётная неделя). На этом этапе должны быть частично или полностью реализованы дополнительные требования.

Требования к сдаче

Артефакты, получаемые по результатам этапов:

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

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

Репозитория-заготовки у этого задания нет: репозиторий создаётся участниками самостоятельно, и его устройство входит в состав работы. Устройство репозитория разобрано в главе «Git», конвейер сборки — в главе «Жизненный цикл ПО».

Основы языка

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

Python является высокоуровневым мультипарадигменным языком программирования с динамической типизацией; код на нём часто сравнивают с псевдокодом: сложные идеи выражаются в нескольких строках и легко читаются.

Рекомендуется ознакомиться с PEP 8.

Версии Python и Дзен Python

Поддерживаются версии Python 3.X; поддержка Python 2.7 прекратилась в 2020 году. Весь код в книге рассчитан на Python 3.10 и новее.

Версию Python показывает команда python --version.

!python --version
Python 3.12.3
import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Основные типы данных

Числа

Целые и вещественные числа ведут себя так же, как в других языках.

x = 3
print(x, type(x))
3 <class 'int'>
print(x + 1)   # Сложение;
print(x - 1)   # Вычитание;
print(x * 2)   # Умножение;
print(x ** 2)  # Возведение в степень;
4
2
6
9
x += 1
print(x)  # Выведет "4"
x *= 2
print(x)  # Выведет "8"
4
8
y = 2.5
print(type(y)) # Выведет "<class 'float'>"
print(y, y + 1, y * 2, y ** 2) # Выведет "2.5 3.5 5.0 6.25"
<class 'float'>
2.5 3.5 5.0 6.25

В отличие от многих языков, в Python отсутствуют унарные операторы инкремента (x++) и декремента (x--). Целые числа имеют неограниченную точность — отдельного типа для длинных целых, как в Python 2, нет, и int растёт, пока хватает памяти, — кроме того, имеется встроенный тип комплексных чисел; подробности приведены в документации.

Булевы значения

В Python есть все привычные операторы булевой логики, но вместо символов (&&, || и т.д.) используются английские слова.

T, F = True, False
print(type(T)) # Выведет "<class 'bool'>"
<class 'bool'>

Операции:

print(T and F) # Логическое И;
print(T or F)  # Логическое ИЛИ;
print(not T)   # Логическое НЕ;
print(T != F)  # Логическое исключающее ИЛИ;
False
True
False
True

Строки

hello = 'hello'   # Строковые литералы можно записывать в одинарных кавычках
world = "world"   # или в двойных — это не имеет значения.
print(hello, len(hello))
hello 5
hw = hello + ' ' + world  # Конкатенация строк
print(hw)  # выведет "hello world"
hello world
hw12 = '%s %s %d' % (hello, world, 12)  # форматирование строки в стиле sprintf
print(hw12)  # выведет "hello world 12"
hello world 12

Так строки формировались до версии 3.6, и в существующем коде этот стиль ещё встречается, однако новый код пишется с помощью f-строк: перед кавычками ставится буква f, а всё, что стоит внутри строки в фигурных скобках, вычисляется и подставляется на место.

n = 12
print(f'{hello} {world} {n}')  # выведет "hello world 12"
hello world 12

Строки обладают большим числом полезных методов.

s = "hello"
print(s.capitalize())  # Первая буква становится заглавной; выведет "Hello"
print(s.upper())       # Перевод строки в верхний регистр; выведет "HELLO"
print(s.rjust(7))      # Выравнивание по правому краю с дополнением пробелами; выведет "  hello"
print(s.center(7))     # Центрирование строки с дополнением пробелами; выведет " hello "
print(s.replace('l', '(ell)'))  # Замена всех вхождений одной подстроки на другую;
                               # выведет "he(ell)(ell)o"
print('  world '.strip())  # Удаление пробелов в начале и в конце; выведет "world"
Hello
HELLO
  hello
 hello 
he(ell)(ell)o
world

Список всех строковых методов приведён в документации.

Контейнеры

Встроенные контейнерные типы Python — списки, словари, множества и кортежи.

Списки

Список является аналогом массива, однако его размер можно менять, а элементы могут быть разных типов.

xs = [3, 1, 2]   # Создаём список
print(xs, xs[2])
print(xs[-1])     # Отрицательные индексы отсчитываются с конца списка; выведет "2"
[3, 1, 2] 2
2
xs[2] = 'foo'    # Списки могут содержать элементы разных типов
print(xs)
[3, 1, 'foo']
xs.append('bar') # Добавляем новый элемент в конец списка
print(xs)  
[3, 1, 'foo', 'bar']
x = xs.pop()     # Удаляем и возвращаем последний элемент списка
print(x, xs) 
bar [3, 1, 'foo']

Подробности о списках приведены в документации.

Срезы

Помимо доступа к отдельным элементам, в Python имеется синтаксис для работы с подсписками — срезы (slicing).

nums = list(range(5))  # range не создаёт список, поэтому оборачиваем в list
print(nums)         # Выведет "[0, 1, 2, 3, 4]"
print(nums[2:4])    # Срез с индекса 2 до 4 (не включая); выведет "[2, 3]"
print(nums[2:])     # Срез с индекса 2 до конца; выведет "[2, 3, 4]"
print(nums[:2])     # Срез с начала до индекса 2 (не включая); выведет "[0, 1]"
print(nums[:])      # Срез всего списка; выведет "[0, 1, 2, 3, 4]"
print(nums[:-1])    # Индексы среза могут быть отрицательными; выведет "[0, 1, 2, 3]"
[0, 1, 2, 3, 4]
[2, 3]
[2, 3, 4]
[0, 1]
[0, 1, 2, 3, 4]
[0, 1, 2, 3]

Циклы

Цикл по элементам списка:

animals = ['cat', 'dog', 'monkey']
for animal in animals:
    print(animal)
cat
dog
monkey

Если в теле цикла требуется также индекс элемента, применяется встроенная функция enumerate.

animals = ['cat', 'dog', 'monkey']
for idx, animal in enumerate(animals):
    print(f'#{idx + 1}: {animal}')
#1: cat
#2: dog
#3: monkey

Списковые включения (list comprehensions)

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

nums = [0, 1, 2, 3, 4]
squares = []
for x in nums:
    squares.append(x ** 2)
print(squares)
[0, 1, 4, 9, 16]

То же самое записывается списковым включением (list comprehension).

nums = [0, 1, 2, 3, 4]
squares = [x ** 2 for x in nums]
print(squares)
[0, 1, 4, 9, 16]

List comprehensions могут содержать и условия.

nums = [0, 1, 2, 3, 4]
even_squares = [x ** 2 for x in nums if x % 2 == 0]
print(even_squares)
[0, 4, 16]

Словари

Словарь хранит пары (ключ, значение), подобно Map в Java или объекту в Javascript.

d = {'cat': 'cute', 'dog': 'furry'}  # Создаём новый словарь с данными
print(d['cat'])       # Получаем запись из словаря; выведет "cute"
print('cat' in d)     # Проверяем, есть ли в словаре ключ; выведет "True"
cute
True
d['fish'] = 'wet'    # Добавляем запись в словарь
print(d['fish'])      # Выведет "wet"
wet
print(d.get('monkey', 'N/A'))  # Получаем элемент со значением по умолчанию; выведет "N/A"
print(d.get('fish', 'N/A'))   # Получаем элемент со значением по умолчанию; выведет "wet"
N/A
wet
del d['fish']        # Удаляем элемент из словаря
print(d.get('fish', 'N/A')) # ключа "fish" больше нет; выведет "N/A"
N/A

Подробности о словарях приведены в документации. Итерация по ключам словаря:

d = {'person': 2, 'cat': 4, 'spider': 8}
for animal in d:
    legs = d[animal]
    print(f'A {animal} has {legs} legs')
A person has 2 legs
A cat has 4 legs
A spider has 8 legs

Словарные включения (dictionary comprehensions) аналогичны списковым, но строят словари.

nums = [0, 1, 2, 3, 4]
even_num_to_square = {x: x ** 2 for x in nums if x % 2 == 0}
print(even_num_to_square)
{0: 0, 2: 4, 4: 16}

Множества

Множество представляет собой неупорядоченную коллекцию различных элементов.

animals = {'cat', 'dog'}
print('cat' in animals)   # Проверяем, есть ли элемент в множестве; выведет "True"
print('fish' in animals)  # выведет "False"
True
False
animals.add('fish')      # Добавляем элемент в множество
print('fish' in animals)
print(len(animals))       # Число элементов в множестве;
True
3
animals.add('cat')       # Добавление элемента, который уже есть в множестве, ничего не делает
print(len(animals))       
animals.remove('cat')    # Удаляем элемент из множества
print(len(animals))       
3
2

Циклы. Итерация по множеству синтаксически не отличается от итерации по списку; однако множества неупорядочены, и на порядок обхода полагаться нельзя.

animals = {'cat', 'dog', 'fish'}
for idx, animal in enumerate(animals):
    print(f'#{idx + 1}: {animal}')
# Порядок зависит от хеш-сида процесса и от запуска к запуску меняется

Один из возможных порядков:

#1: fish
#2: dog
#3: cat

Множественные включения: как списки и словари, множества строятся с помощью set comprehensions.

from math import sqrt
print({int(sqrt(x)) for x in range(30)})
{0, 1, 2, 3, 4, 5}

Кортежи

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

d = {(x, x + 1): x for x in range(10)}  # Создаём словарь с ключами-кортежами
t = (5, 6)       # Создаём кортеж
print(type(t))
print(d[t])       
print(d[(1, 2)])
<class 'tuple'>
5
1

Функции

Функции определяются ключевым словом def.

def sign(x: float) -> str:
    """Function sign.""" # строка документации
    
    if x > 0:
        return 'positive'
    elif x < 0:
        return 'negative'
    else:
        return 'zero'

for x in [-1, 0, 1]:
    print(sign(x))
negative
zero
positive
help(sign)
Help on function sign in module __main__:

sign(x: float) -> str
    Function sign.

Функция с необязательными именованными аргументами:

def hello(name: str, loud: bool = False) -> None:
    """Function hello.
    
    If loud is True, 
    then the name is printed in capital letters.
    """
    
    if loud:
        print(f'HELLO, {name.upper()}')
    else:
        print(f'Hello, {name}!')

hello('Bob')
hello('Fred', loud=True)
Hello, Bob!
HELLO, FRED
help(hello)
Help on function hello in module __main__:

hello(name: str, loud: bool = False) -> None
    Function hello.
    
    If loud is True, 
    then the name is printed in capital letters.

Классы

Определение класса:

class Greeter:
    """Class Greeter.
    
    method greet:
    If loud is True, 
    then the name is printed in capital letters.
    """

    # Конструктор
    def __init__(self, name):
        self.name = name  # Создаём переменную экземпляра

    # Метод экземпляра
    def greet(self, loud: bool = False) -> None:
        if loud:
            print(f'HELLO, {self.name.upper()}!')
        else:
            print(f'Hello, {self.name}')

g = Greeter('Fred')  # Создаём экземпляр класса Greeter
g.greet()            # Вызываем метод экземпляра; выведет "Hello, Fred"
g.greet(loud=True)   # Вызываем метод экземпляра; выведет "HELLO, FRED!"
Hello, Fred
HELLO, FRED!
help(Greeter)
Help on class Greeter in module __main__:

class Greeter(builtins.object)
 |  Greeter(name)
 |  
 |  Class Greeter.
 |  
 |  method greet:
 |  If loud is True, 
 |  then the name is printed in capital letters.
 |  
 |  Methods defined here:
 |  
 |  __init__(self, name)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  greet(self, loud: bool = False) -> None
 |      # Метод экземпляра

Внутреннее устройство объектов и стоимость каждой операции рассматриваются в основных главах — «Объекты и память» и «Классы».

Массивы NumPy

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

NumPy является основной библиотекой для научных вычислений на Python: она предоставляет высокопроизводительный объект многомерного массива и инструменты для работы с такими массивами.

Прежде всего необходимо импортировать пакет numpy.

import numpy as np

Массивы

Массив NumPy представляет собой сетку значений одного типа, индексируемую кортежем неотрицательных целых чисел. Число измерений массива хранится в поле ndim, а форма (shape) представляет собой кортеж целых чисел с размерами вдоль каждого измерения.

Массивы NumPy могут быть созданы из вложенных списков Python, а обращение к элементам осуществляется через квадратные скобки:

a = np.array([1, 2, 3])  # Создаём одномерный массив
print(type(a), a.shape, a[0], a[1], a[2])
a[0] = 5                 # Меняем элемент массива
print(a)
<class 'numpy.ndarray'> (3,) 1 2 3
[5 2 3]
b = np.array([[1,2,3],[4,5,6]])   # Создаём двумерный массив
print(b)
[[1 2 3]
 [4 5 6]]
print(b.shape)
print(b[0, 0], b[0, 1], b[1, 0])
(2, 3)
1 2 4

NumPy также предоставляет большое число функций для создания массивов:

a = np.zeros((2,2))  # Создаём массив из одних нулей
print(a)
[[0. 0.]
 [0. 0.]]
b = np.ones((1,2))   # Создаём массив из одних единиц
print(b)
[[1. 1.]]
c = np.full((2,2), 7) # Создаём массив, заполненный константой
print(c)
[[7 7]
 [7 7]]
d = np.eye(2)        # Создаём единичную матрицу 2x2
print(d)
[[1. 0.]
 [0. 1.]]
e = np.random.random((2,2)) # Создаём массив, заполненный случайными значениями
print(e)
[[0.57584699 0.0757792 ]
 [0.18793454 0.78004389]]

Индексация массивов

NumPy предоставляет несколько способов индексации массивов.

Срезы. Как и к спискам Python, к массивам NumPy применимы срезы; поскольку массивы многомерны, срез указывается для каждого измерения.

import numpy as np

# Создаём следующий двумерный массив с формой (3, 4)
# [[ 1  2  3  4]
#  [ 5  6  7  8]
#  [ 9 10 11 12]]
a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])

# С помощью среза вытаскиваем подмассив из первых двух строк
# и столбцов 1 и 2; b — следующий массив с формой (2, 2):
# [[2 3]
#  [6 7]]
b = a[:2, 1:3]
print(b)
[[2 3]
 [6 7]]

Срез массива является представлением (view) тех же самых данных, поэтому изменение среза приводит к изменению исходного массива.

print(a[0, 1])
b[0, 0] = 77    # b[0, 0] — те же самые данные, что и a[0, 1]
print(a[0, 1])
2
77

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

# Создаём следующий двумерный массив с формой (3, 4)
a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])
print(a)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]

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

row_r1 = a[1, :]    # Одномерное представление второй строки a
row_r2 = a[1:2, :]  # Двумерное представление второй строки a
row_r3 = a[[1], :]  # Двумерное представление второй строки a
print(row_r1, row_r1.shape)
print(row_r2, row_r2.shape)
print(row_r3, row_r3.shape)
[5 6 7 8] (4,)
[[5 6 7 8]] (1, 4)
[[5 6 7 8]] (1, 4)
# То же различие видно и при обращении к столбцам массива:
col_r1 = a[:, 1]
col_r2 = a[:, 1:2]
print(col_r1, col_r1.shape)
print(col_r2, col_r2.shape)
[ 2  6 10] (3,)
[[ 2]
 [ 6]
 [10]] (3, 1)

Индексация целочисленными массивами. Срез всегда является подмассивом исходного массива, тогда как индексация целочисленными массивами позволяет формировать произвольные массивы из данных другого массива.

a = np.array([[1,2], [3, 4], [5, 6]])

# Пример индексации целочисленными массивами.
# Возвращаемый массив будет иметь форму (3,)
print(a[[0, 1, 2], [0, 1, 0]])

# Пример выше эквивалентен вот этому:
print(np.array([a[0, 0], a[1, 1], a[2, 0]]))
[1 4 5]
[1 4 5]
# При индексации целочисленными массивами можно повторно
# использовать один и тот же элемент исходного массива:
print(a[[0, 0], [1, 1]])

# Эквивалентно предыдущему примеру с целочисленной индексацией
print(np.array([a[0, 1], a[0, 1]]))
[2 2]
[2 2]

Индексация целочисленными массивами позволяет выбрать или изменить один элемент в каждой строке матрицы.

# Создаём новый массив, из которого будем выбирать элементы
a = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
print(a)
[[ 1  2  3]
 [ 4  5  6]
 [ 7  8  9]
 [10 11 12]]
# Создаём массив индексов
b = np.array([0, 2, 0, 1])

# Выбираем по одному элементу из каждой строки a, используя индексы из b
print(a[np.arange(4), b])  # Напечатает "[ 1  6  7 11]"
[ 1  6  7 11]
# Изменяем по одному элементу в каждой строке a, используя индексы из b
a[np.arange(4), b] += 10
print(a)
[[11  2  3]
 [ 4  5 16]
 [17  8  9]
 [10 21 12]]

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

import numpy as np

a = np.array([[1,2], [3, 4], [5, 6]])

bool_idx = (a > 2)  # Находим элементы a, которые больше 2;
                    # получаем numpy-массив булевых значений той же
                    # формы, что и a, где каждая ячейка bool_idx
                    # сообщает, выполняется ли для элемента a условие > 2.

print(bool_idx)
[[False False]
 [ True  True]
 [ True  True]]
# Используем булеву индексацию, чтобы построить одномерный массив,
# состоящий из элементов a, которым соответствуют значения True
# в bool_idx
print(a[bool_idx])

# Всё это можно записать одним коротким выражением:
print(a[a > 2])
[3 4 5 6]
[3 4 5 6]

Многие детали индексации здесь опущены; полное описание приведено в документации.

Типы данных

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

x = np.array([1, 2])  # Пусть numpy сам выберет тип данных
y = np.array([1.0, 2.0])  # Пусть numpy сам выберет тип данных
z = np.array([1, 2], dtype=np.int64)  # Явно задаём конкретный тип данных

print(x.dtype, y.dtype, z.dtype)
int64 float64 int64

Полное описание типов данных NumPy приведено в документации.

Математические операции над массивами

Базовые математические функции применяются к массивам поэлементно и доступны как в виде перегруженных операторов, так и в виде функций модуля numpy:

x = np.array([[1,2],[3,4]], dtype=np.float64)
y = np.array([[5,6],[7,8]], dtype=np.float64)

# Поэлементная сумма; оба способа дают массив
print(x + y)
print(np.add(x, y))
[[ 6.  8.]
 [10. 12.]]
[[ 6.  8.]
 [10. 12.]]
# Поэлементная разность; оба способа дают массив
print(x - y)
print(np.subtract(x, y))
[[-4. -4.]
 [-4. -4.]]
[[-4. -4.]
 [-4. -4.]]
# Поэлементное произведение; оба способа дают массив
print(x * y)
print(np.multiply(x, y))
[[ 5. 12.]
 [21. 32.]]
[[ 5. 12.]
 [21. 32.]]
# Поэлементное деление; оба способа дают массив
# [[ 0.2         0.33333333]
#  [ 0.42857143  0.5       ]]
print(x / y)
print(np.divide(x, y))
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
# Поэлементный квадратный корень; получается массив
# [[ 1.          1.41421356]
#  [ 1.73205081  2.        ]]
print(np.sqrt(x))
[[1.         1.41421356]
 [1.73205081 2.        ]]

В отличие от MATLAB, оператор * выполняет поэлементное, а не матричное умножение. Для скалярного произведения векторов, умножения вектора на матрицу и перемножения матриц служит функция dot, доступная как в виде функции модуля numpy, так и в виде метода объекта-массива.

x = np.array([[1,2],[3,4]])
y = np.array([[5,6],[7,8]])

v = np.array([9,10])
w = np.array([11, 12])

# Скалярное произведение векторов; оба способа дают 219
print(v.dot(w))
print(np.dot(v, w))
219
219
# Произведение матрицы на вектор; оба способа дают одномерный массив [29 67]
print(x.dot(v))
print(np.dot(x, v))
[29 67]
[29 67]
# Произведение матриц; оба способа дают двумерный массив
# [[19 22]
#  [43 50]]
print(x.dot(y))
print(np.dot(x, y))
[[19 22]
 [43 50]]
[[19 22]
 [43 50]]

Одной из наиболее востребованных функций для вычислений над массивами является sum.

x = np.array([[1,2],[3,4]])

print(np.sum(x))  # Считаем сумму всех элементов; напечатает "10"
print(np.sum(x, axis=0))  # Считаем сумму каждого столбца; напечатает "[4 6]"
print(np.sum(x, axis=1))  # Считаем сумму каждой строки; напечатает "[3 7]"
10
[4 6]
[3 7]

Полный список математических функций NumPy приведён в документации.

Помимо вычислений часто требуется изменять форму данных в массивах. Простейшим примером является транспонирование матрицы с помощью атрибута T объекта-массива.

print(x)
print(x.T)
[[1 2]
 [3 4]]
[[1 3]
 [2 4]]
v = np.array([[1,2,3]])
print(v)
print(v.T)
[[1 2 3]]
[[1]
 [2]
 [3]]

Broadcasting

Broadcasting (транслирование) представляет собой механизм, позволяющий NumPy выполнять арифметические операции над массивами разной формы. Типичный случай: имеются малый и большой массивы, и малый необходимо многократно использовать в операции над большим.

Предположим, что требуется прибавить постоянный вектор к каждой строке матрицы:

# Прибавим вектор v к каждой строке матрицы x,
# результат запишем в матрицу y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
v = np.array([1, 0, 1])
y = np.empty_like(x)   # Создаём пустую матрицу той же формы, что и x

# Прибавляем вектор v к каждой строке матрицы x явным циклом
for i in range(4):
    y[i, :] = x[i, :] + v

print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Такой подход работает, однако при большой матрице x явный цикл на Python выполняется медленно. Прибавление вектора v к каждой строке x равносильно формированию матрицы vv из вертикально уложенных копий v с последующим поэлементным сложением x и vv.

vv = np.tile(v, (4, 1))  # Укладываем 4 копии v друг на друга
print(vv)                 # Напечатает "[[1 0 1]
                         #          [1 0 1]
                         #          [1 0 1]
                         #          [1 0 1]]"
[[1 0 1]
 [1 0 1]
 [1 0 1]
 [1 0 1]]
y = x + vv  # Поэлементно складываем x и vv
print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Broadcasting позволяет выполнить это вычисление, не создавая копий v.

# Прибавим вектор v к каждой строке матрицы x,
# результат запишем в матрицу y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
v = np.array([1, 0, 1])
y = x + v  # Прибавляем v к каждой строке x с помощью broadcasting
print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Строка y = x + v выполняется корректно, хотя x имеет форму (4, 3), а v — форму (3,): благодаря broadcasting она выполняется так, как если бы v имел форму (4, 3), каждая его строка являлась копией v, а сложение осуществлялось поэлементно.

Broadcasting двух массивов подчиняется следующим правилам:

  1. Если массивы имеют разное число измерений, форма массива меньшей размерности дополняется единицами слева, пока обе формы не станут одной длины.
  2. Два массива называются совместимыми по измерению, если их размеры в этом измерении совпадают или если размер одного из массивов в этом измерении равен 1.
  3. Broadcasting массивов возможен, если они совместимы по всем измерениям.
  4. После broadcasting каждый массив ведёт себя так, как будто его форма равна поэлементному максимуму форм двух входных массивов.
  5. В каждом измерении, где размер одного массива равен 1, а размер другого больше 1, первый массив ведёт себя так, как будто его скопировали вдоль этого измерения.

Иное изложение тех же правил приведено в документации.

Функции, поддерживающие broadcasting, называются универсальными; полный список приведён в документации.

Ниже приведены несколько применений broadcasting:

# Вычисляем внешнее произведение векторов
v = np.array([1,2,3])  # v имеет форму (3,)
w = np.array([4,5])    # w имеет форму (2,)
# Чтобы вычислить внешнее произведение, сначала превращаем v в вектор-столбец
# формы (3, 1); затем с помощью broadcasting совмещаем его с w и получаем
# результат формы (3, 2) — внешнее произведение v и w:

print(np.reshape(v, (3, 1)) * w)
[[ 4  5]
 [ 8 10]
 [12 15]]
# Прибавляем вектор к каждой строке матрицы
x = np.array([[1,2,3], [4,5,6]])
# x имеет форму (2, 3), а v — форму (3,), поэтому broadcasting сводит их
# к форме (2, 3) и даёт следующую матрицу:

print(x + v)
[[2 4 6]
 [5 7 9]]
# Прибавляем вектор к каждому столбцу матрицы
# x имеет форму (2, 3), а w — форму (2,).
# Если транспонировать x, он получит форму (3, 2), и его можно совместить
# с w через broadcasting, получив результат формы (3, 2);
# транспонировав этот результат, получаем итог формы (2, 3) — матрицу x,
# к каждому столбцу которой прибавлен вектор w. Получается следующая матрица:

print((x.T + w).T)
[[ 5  6  7]
 [ 9 10 11]]
# Другое решение — преобразовать w в вектор формы (2, 1);
# тогда его можно совместить с x через broadcasting напрямую
# и получить тот же результат.
print(x + np.reshape(w, (2, 1)))
[[ 5  6  7]
 [ 9 10 11]]
# Умножаем матрицу на константу:
# x имеет форму (2, 3). NumPy рассматривает скаляры как массивы формы ();
# broadcasting сводит их к форме (2, 3), и получается
# следующий массив:
print(x * 2)
[[ 2  4  6]
 [ 8 10 12]]

Broadcasting, как правило, делает код более лаконичным и быстрым; его целесообразно использовать везде, где это возможно.

Настоящий обзор далеко не полон; остальное приведено в справочнике NumPy.

Графики Matplotlib

Беглый справочник для читателя, не знакомого с Python. Полностью тема рассматривается в основных главах книги; в печатное издание приложение не вошло.

Matplotlib является библиотекой для построения графиков. Ниже рассматривается минимум по модулю matplotlib.pyplot, системе построения графиков, аналогичной MATLAB. Подробное рассмотрение визуализации на Python (Matplotlib, Plotly, HoloViews) приведено в главе «Визуализация на Python».

import numpy as np
import matplotlib.pyplot as plt

Команда IPython, включающая отображение графиков в блокноте:

%matplotlib inline

Построение графиков

Основной функцией matplotlib является plot, строящая графики двумерных данных:

# Вычисляем координаты x и y точек на синусоиде
x = np.arange(0, 3 * np.pi, 0.1)
y = np.sin(x)

# Строим график с помощью matplotlib
plt.plot(x, y)
[<matplotlib.lines.Line2D at 0x1142b94d0>]

png

Несколько кривых одновременно, с заголовком, легендой и подписями осей:

y_sin = np.sin(x)
y_cos = np.cos(x)

# Строим оба графика с помощью matplotlib
plt.plot(x, y_sin)
plt.plot(x, y_cos)
plt.xlabel('x axis label')
plt.ylabel('y axis label')
plt.title('Sine and Cosine')
plt.legend(['Sine', 'Cosine'])
<matplotlib.legend.Legend at 0x114390a50>

png

В физике график без подписанных осей графиком не считается. На осях всегда должны быть указаны величина и её единицы измерения («t, с», «U, мВ»); за это отвечают plt.xlabel и plt.ylabel. Заголовок задаётся функцией plt.title, а plt.legend выводит легенду со списком кривых и их названий.

Имя кривой удобнее задавать непосредственно в plot через аргумент label: в этом случае plt.legend() вызывается без аргументов, и соответствие кривых и подписей не нарушается. Координатная сетка plt.grid(True) облегчает считывание значений с графика.

plt.plot(x, y_sin, label='sin(x)')
plt.plot(x, y_cos, '--', label='cos(x)')  # '--' — штриховая линия
plt.xlabel('x, рад')
plt.ylabel('f(x)')
plt.title('Тригонометрические функции')
plt.legend()    # имена кривых возьмутся из label
plt.grid(True)  # координатная сетка

Subplots

Функция subplot размещает несколько графиков на одном рисунке:

# Вычисляем координаты x и y точек на синусоиде и косинусоиде
x = np.arange(0, 3 * np.pi, 0.1)
y_sin = np.sin(x)
y_cos = np.cos(x)

# Задаём сетку subplot'ов высотой 2 и шириной 1
# и делаем активным первый subplot.
plt.subplot(2, 1, 1)

# Строим первый график
plt.plot(x, y_sin)
plt.title('Sine')

# Делаем активным второй subplot и строим второй график.
plt.subplot(2, 1, 2)
plt.plot(x, y_cos)
plt.title('Cosine')

# Показываем рисунок.
plt.show()

png

Подробнее о функции subplot — в документации.

Гистограммы

Гистограмма строится первой, когда имеется набор повторных измерений: она показывает распределение значений. Сгенерируем 10 000 «измерений», рассеянных вокруг истинного значения по закону Гаусса, и рассмотрим их с помощью plt.hist.

# 10000 «измерений»: истинное значение 5.0, случайный шум 0.5
values = np.random.normal(loc=5.0, scale=0.5, size=10000)

plt.hist(values, bins=50, density=True, alpha=0.7)
plt.xlabel('Измеренное значение')
plt.ylabel('Плотность вероятности')
plt.grid(True)

Аргумент bins задаёт число корзин: при слишком малом значении распределение теряет детали, при слишком большом гистограмма становится шумной. Флаг density=True нормирует гистограмму на единичную площадь, и в этих осях её можно сравнивать с теоретической плотностью вероятности, например с гауссианой. Полупрозрачность alpha необходима, когда несколько распределений накладываются друг на друга.

Точки с погрешностями (errorbar)

Основным графиком в экспериментальной физике являются точки с погрешностями. Его строит plt.errorbar: кроме координат точек она принимает величины ошибок по осям (yerr и xerr) и рисует усы.

# «Эксперимент»: период маятника в зависимости от длины подвеса
L = np.array([0.2, 0.4, 0.6, 0.8, 1.0])        # длина, м
T = 2 * np.pi * np.sqrt(L / 9.81)              # теоретический период, с
T_exp = T + np.random.normal(0, 0.03, T.size)  # «измеренный» период, с
T_err = np.full_like(T, 0.05)                  # погрешность измерения, с

plt.errorbar(L, T_exp, yerr=T_err, fmt='o', capsize=3, label='эксперимент')
plt.plot(L, T, '--', label='теория')
plt.xlabel('L, м')
plt.ylabel('T, с')
plt.legend()
plt.grid(True)

Ключ fmt='o' необходим, поскольку экспериментальные точки изображаются маркерами и не соединяются ломаной; рядом проводится теоретическая кривая или аппроксимация. Аргумент capsize задаёт размер шляпок на концах усов, а xerr добавляет горизонтальные усы, если погрешность имеется и по оси x.

Сохранение рисунка в файл

Графики существуют внутри блокнота. Для вставки рисунка в отчёт или статью его сохраняют в файл с помощью plt.savefig; формат определяется расширением имени файла.

plt.plot(x, np.sin(x))
plt.savefig('sine.png', dpi=300, bbox_inches='tight')  # растровый формат
plt.savefig('sine.pdf')                                # векторный формат

PNG является растровым форматом, поэтому для печати необходимо задавать разрешение dpi=300 или выше. PDF и SVG — векторные форматы, не теряющие чёткости при любом увеличении; для статей и дипломов (особенно в LaTeX) предпочтителен pdf. Аргумент bbox_inches='tight' обрезает лишние белые поля вокруг рисунка. В скриптах savefig необходимо вызывать до plt.show(): после показа фигура очищается, и в файл записывается пустой лист.

Полезные ссылки

Заключение

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

В пособии были рассмотрены инструменты и помощники, устройство Linux и Python, алгоритмы и структуры данных, практики разработки, обработка данных и обучение моделей, ускорение кода и чтение чужих проектов. Ни одна из тем не закрыта полностью: каждая глава могла бы стать отдельной книгой, а некоторые уже стали; соответствующие издания приведены в списке литературы.

Дальнейшие шаги, которые следует предпринять:

  1. Писать код каждый день. Навык разработки, как и владение математическим аппаратом, поддерживается только упражнением. Целесообразно автоматизировать рутину, ежедневно отнимающую время: обработку логов, построение графиков к отчётам, запуск расчётов.
  2. Читать чужой код. Исходники NumPy, SciPy или любого другого используемого инструмента являются лучшей школой стиля.
  3. Отдавать код на ревью и проводить ревью самостоятельно. Взгляд коллеги экономит недели отладки.
  4. Публиковать проекты в открытом доступе. Публичный репозиторий с оформленным README и тестами становится резюме и вкладом в научное сообщество.
  5. Возвращаться к этому пособию. Многие главы (профилирование, базы данных, CUDA) становятся востребованными только при появлении задачи, в которой без них не обойтись.

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

Вопросы, опечатки и предложения можно направлять в issues на GitHub.

Литература и статьи

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

Инструменты и рабочая среда

  1. Чакон С. Pro Git / С. Чакон, Б. Штрауб. – 2-е изд. – Apress, 2014. – Режим доступа: https://git-scm.com/book/ru/v2. Дата обращения: 31.08.2026.
  2. Merkel D. Docker: lightweight Linux containers for consistent development and deployment / D. Merkel // Linux Journal. – 2014. – Vol. 2014, № 239.
  3. Уорд Б. Внутреннее устройство Linux / Б. Уорд. – 3-е изд. – СПб.: Питер, 2022.
  4. Керриск М. Linux API. Исчерпывающее руководство / М. Керриск. – СПб.: Питер, 2018.
  5. Лав Р. Ядро Linux. Описание процесса разработки / Р. Лав. – 3-е изд. – М.: Вильямс, 2013.

Алгоритмы и структуры данных

  1. Кормен Т. Алгоритмы: построение и анализ / Т. Кормен, Ч. Лейзерсон, Р. Ривест, К. Штайн. – 3-е изд. – М.: Вильямс, 2013.
  2. Седжвик Р. Алгоритмы на Python / Р. Седжвик, К. Уэйн, Р. Дондеро. – М.: Вильямс, 2017.
  3. Кнут Д. Искусство программирования. Т. 1: Основные алгоритмы / Д. Кнут. – 3-е изд. – М.: Вильямс, 2018.

Язык Python

  1. Лутц М. Изучаем Python / М. Лутц. – 5-е изд. – М.: Диалектика, 2020.
  2. Рамальо Л. Python. К вершинам мастерства / Л. Рамальо. – 2-е изд. – М.: ДМК Пресс, 2023.
  3. Слаткин Б. Секреты Python: 90 рекомендаций по написанию эффективного кода / Б. Слаткин. – 2-е изд. – М.: Диалектика, 2020.
  4. Бизли Д. Python. Книга рецептов / Д. Бизли, Б. Джонс. – М.: ДМК Пресс, 2019.

Инженерные практики

  1. Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения / Р. Мартин. – СПб.: Питер, 2018.
  2. Мейер Б. Объектно-ориентированное конструирование программных систем / Б. Мейер. – М.: Русская редакция, 2005.
  3. Шэллоуэй А. Шаблоны проектирования. Новый подход к объектно-ориентированному анализу и проектированию / А. Шэллоуэй, Дж. Тротт. – М.: Вильямс, 2002.
  4. Клеппман М. Высоконагруженные приложения. Программирование, масштабирование, поддержка / М. Клеппман. – СПб.: Питер, 2018.
  5. Momjian B. PostgreSQL: introduction and concepts / B. Momjian. – Addison-Wesley, 2001.
  6. Bradshaw S. MongoDB: the definitive guide / S. Bradshaw, E. Brazil, K. Chodorow. – 3rd ed. – O'Reilly Media, 2019.
  7. Carlson J. Redis in action / J. Carlson. – Manning, 2013.
  8. Videla A. RabbitMQ in action: distributed messaging for everyone / A. Videla, J. J. W. Williams. – Manning, 2012.
  9. Harenslak B. P. Data pipelines with Apache Airflow / B. P. Harenslak, J. de Ruiter. – Manning, 2021.
  10. Chhajed S. Learning ELK stack / S. Chhajed. – Packt Publishing, 2015.

Данные и машинное обучение

  1. Virtanen P. SciPy 1.0: fundamental algorithms for scientific computing in Python / P. Virtanen, R. Gommers, T. E. Oliphant [и др.] // Nature Methods. – 2020. – Vol. 17. – P. 261–272.
  2. McKinney W. pandas: a foundational Python library for data analysis and statistics / W. McKinney // Python for High Performance and Scientific Computing. – 2011.
  3. Kluyver T. Jupyter Notebooks — a publishing format for reproducible computational workflows / T. Kluyver, B. Ragan-Kelley, F. Pérez [и др.] // Positioning and Power in Academic Publishing. – IOS Press, 2016. – P. 87–90.
  4. Hastie T. The elements of statistical learning / T. Hastie, R. Tibshirani, J. Friedman. – 2nd ed. – Springer, 2009.
  5. Bishop C. M. Pattern recognition and machine learning / C. M. Bishop. – Springer, 2006.
  6. Гудфеллоу Я. Глубокое обучение / Я. Гудфеллоу, И. Бенджио, А. Курвилль. – М.: ДМК Пресс, 2018.
  7. Жерон О. Прикладное машинное обучение с помощью Scikit-Learn, Keras и TensorFlow / О. Жерон. – 2-е изд. – М.: Диалектика, 2020.

Производительность

  1. Gorelick M. High performance Python / M. Gorelick, I. Ozsvald. – 2nd ed. – O'Reilly Media, 2020.
  2. Press W. H. Numerical Recipes: The Art of Scientific Computing / W. H. Press, S. A. Teukolsky, W. T. Vetterling, B. P. Flannery. – 3rd ed. – Cambridge University Press, 2007.

Физика пучков и вычислительные коды

  1. Lawson J. D. The physics of charged-particle beams / J. D. Lawson. – Oxford: Clarendon Press, 1977.
  2. Reiser M. Theory and design of charged particle beams / M. Reiser. – Wiley, 1994.
  3. Birdsall C. K. Plasma physics via computer simulation / C. K. Birdsall, A. B. Langdon. – CRC Press, 2004.
  4. Ivanov A. V. ULTRASAM-2D code for simulation of electron guns with ultra high precision / A. V. Ivanov, M. A. Tiunov // Proceedings of EPAC-2002. – Paris, 2002. – P. 1634–1636.
  5. Borland M. Simple method for particle tracking with coherent synchrotron radiation / M. Borland // Physical Review Special Topics — Accelerators and Beams. – 2001. – Vol. 4, № 7.
  6. Dalesio L. R. EPICS architecture / L. R. Dalesio, A. J. Kozubal, M. R. Kraimer // Proceedings of ICALEPCS'91. – 1991. – P. 278–282.

Работы, на которых построены примеры книги

  1. Никифоров Д. А. Транспортировка сильноточного электронного пучка в линейном индукционном ускорителе ЛИУ-5 / Д. А. Никифоров, М. Ф. Блинов, В. В. Федоров [и др.] // Письма в журнал «Физика элементарных частиц и атомного ядра». – 2020. – Т. 17, № 2(227). – С. 158–167.
  2. Nikiforov D. A. Investigation of high current electron beam dynamics in linear induction accelerator for creation of a high-power THz radiation source / D. A. Nikiforov, A. V. Petrenko, S. L. Sinitsky [и др.] // Journal of Instrumentation. – 2021. – Vol. 16, № 11. – P. P11024.
  3. Никифоров Д. А. Исследование динамики пучка электронов в мощном линейном индукционном ускорителе с фокусировкой на сосредоточенных элементах : дис. … канд. физ.-мат. наук / Д. А. Никифоров. – Новосибирск: ИЯФ СО РАН, 2023. – 94 с.
  4. De Rainville F.-M. DEAP: a Python framework for evolutionary algorithms / F.-M. De Rainville, F.-A. Fortin, M.-A. Gardner [и др.] // Proceedings of the 14th Annual Conference Companion on Genetic and Evolutionary Computation. – 2012. – P. 85–92.
  5. Ragonneau T. M. Model-based derivative-free optimization methods and software : PhD thesis / T. M. Ragonneau. – The Hong Kong Polytechnic University, 2022.
  6. Lu C. The AI Scientist: towards fully automated open-ended scientific discovery / C. Lu, C. Lu, R. T. Lange [и др.]. – 2024. – Режим доступа: https://arxiv.org/abs/2408.06292. Дата обращения: 31.08.2026.

Свидетельства о государственной регистрации программ для ЭВМ

  1. Федоров В. В. REDPIC : свидетельство о государственной регистрации программы для ЭВМ № 2023688768 от 25.12.2023 / В. В. Федоров, Д. А. Никифоров. – 2023.
  2. Федоров В. В. KENV : свидетельство о государственной регистрации программы для ЭВМ № 2024611244 от 18.01.2024 / В. В. Федоров, Д. А. Никифоров, А. В. Петренко. – 2024.
  3. Федоров В. В. SCAUT : свидетельство о государственной регистрации программы для ЭВМ № 2025619977 от 21.04.2025 / В. В. Федоров, Д. А. Никифоров. – 2025.
  4. Федоров В. В. ACCUMULATOR : свидетельство о государственной регистрации программы для ЭВМ № 2026668488 от 07.07.2026 / В. В. Федоров. – 2026.