Text
                    В. Ф. ОЧКОВ,
А. И. ТИХОНОВ

16 ЗАНЯТИЙ МИТ
НА PYTHON
НАУЧНО-ПОПУЛЯРНОЕ ИЗДАНИЕ

САНКТ-ПЕТЕРБУРГ • МОСКВА • КРАСНОДАР
2026


12+ УДК 004.43 ББК 32.973.2я73 О 95 Очков В. Ф. 16 занятий МИТ на Python : научно-популярное издание / В. Ф. Очков, А. И. Тихонов. — Санкт-Петербург : Лань, 2026. — 368 с. — Текст : непосредственный. ISBN 978-5-507-54618-3 В издании рассматриваются вопросы, связанные с использованием технологий STEM (Science — наука, Technology — технологии, Engineering — инженерное дело, Mathematics — математика) в современном среднем и высшем образовании на основе решения задач из различных предметных областей, изучаемых в школе, колледже и техническом университете. В русскоязычной литературе все чаще используется сокращение МИТ (Математика, Информатика, Техника). МИТ — это вот что: студенты приходят в класс, оснащенный вычислительной техникой, или удалённо подключаются из дома и целый день решают интересные естественно-научные и инженерные задачи. Для решения задач нужны знания по математике, физике, информатике и другим дисциплинам. В другие дни студенты идут на занятия по традиционным предметам и углубляют знания и навыки в этих областях, осознавая при этом, для чего они нужны. Для решения задач используются свободно распространяемые средства экосистемы Python, работать с которой можно как на Windows, так и на Linux. Все необходимые справочные материалы имеются в приложениях книги. Для широкого круга читателей, связанных со средним и высшим образованием. УДК 004.43 ББК 32.973.2я73 Обложка П. И. ПОЛЯКОВА © Издательство «Лань», 2026 © В. Ф. Очков, А. И. Тихонов, 2026 © Издательство «Лань», художественное оформление, 2026
ОГЛАВЛЕНИЕ Введение ....................................................................................................................... 5 Глава 1. Занятия 1, 2 и 3. Математический анализ трамвайных путей ............... 16 1.1. Переходная кривая ..................................................................................... 16 1.2. Катаемся по кругу ....................................................................................... 27 1.3. Кинематографическое отступление .......................................................... 35 1.4. Овал Толстого ............................................................................................. 37 Глава 2. Занятие 4. Вычислительный ренессанс: метод Ньютона ....................... 43 2.1. Простой метод Ньютона ............................................................................ 44 2.2. Что делать с системами уравнений? ......................................................... 55 2.3. Сердце, пронзённое стрелой. Области притяжения корней ................. 61 Глава 3. Занятия 5 и 6. В этом мире нет совершенства ......................................... 74 3.1. Виды погрешностей ................................................................................... 74 3.2. Погрешности при вычислениях с плавающей точкой ............................ 77 3.3. Бухгалтерские вычисления ........................................................................ 79 3.4. Потеря точности ......................................................................................... 81 3.5. Интервальная арифметика ......................................................................... 82 3.6. Работаем с библиотекой uncertainties ....................................................... 89 Глава 4. Занятия 7 и 8. Криптография и головоломки .......................................... 97 4.1. Стеганография. Встраиваем цитату в заставку главы ............................ 98 4.2. Простейшие шифры ................................................................................. 106 4.3. Как это делается в экосистеме Python .................................................... 113 4.4. Римские и арабские .................................................................................. 122 4.5. Буквенная головоломка ............................................................................ 125 Глава 5. Занятия 9 и 10. Наш старый знакомый маятник.................................... 130 5.1. Просто маятник ......................................................................................... 130 5.2. Аналитическое решение задачи о маятнике .......................................... 131 5.3. Численное решение системы уравнений маятника............................... 135 5.4. Дивертисмент о метре .............................................................................. 141 5.5. Дивертисмент об эластичности ............................................................... 143 5.6. Дивертисмент о логистической функции .............................................. 144 5.7. Дивертисмент о потенциальной энергии пружины .............................. 145 5.8. Гравитационный поезд............................................................................. 145 5.9. Волки зайчиков жуют… .......................................................................... 151 5.10. Электромагнитный маятник .................................................................. 155 5.11. Детерминированный хаос двойного маятника .................................... 162 Глава 6. Занятия 11 и 12. Мистер X или Немного статистики ........................... 169 6.1. Статистическая выборка студентов ........................................................ 170 6.2. Статистические характеристики ............................................................. 171 6.3. Представление и визуализация данных ................................................. 174 6.4. Регрессия ................................................................................................... 178 6.5. Выбор модели регрессии ......................................................................... 182 6.6. Нелинейная аппроксимация .................................................................... 184 6.7. Гистограммы, или раскладываем всё по полочкам............................... 188 3
6.8. Интерполяция и экстраполяция .............................................................. 196 6.9. От таблицы к функции ............................................................................. 199 Глава 7. Занятия 13, 14 и 15. Проверить алгеброй геометрию, а заодно и гармонию ............................................................................................... 206 7.1. Рисуем и заполняем заглавную букву Г ................................................. 206 7.2. Аффинные преобразования плоскости................................................... 208 7.3. Кривые Безье ............................................................................................. 213 7.4. Кодирование, рисование и преобразование векторных изображений в matplotlib ............................................................. 216 7.5. Вспомогательные функции для рисования мозаик ............................... 219 7.6. Преобразование и компоновка фигур .................................................... 222 7.7. Сборка Квадратного предела................................................................... 226 7.7.1. Центральная часть Квадратного предела ................................. 228 7.7.2. Компонуем основную часть Квадратного предела ................. 229 7.7.3. Сборка Квадратного предела ..................................................... 231 7.8. А теперь нечто совсем иное…................................................................. 233 7.8.1. Круглый предел ........................................................................... 233 7.8.2. Натягиваем Квадратный и Круглый пределы на цилиндр, тор и ленту Мёбиуса ....................................................... 236 Глава 8. Занятие 16. Путешествие в мир науки и искусства на стопоходящей машине Чебышёва ............................................... 244 Приложения ............................................................................................................. 261 Приложение 1. Cоздаём рабочую среду с помощью менеджера пакетов uv ................................................................. 261 Приложение 2. Marimo — не только украшение аквариума....................... 264 П.2.1. Пользовательский интерфейс блокнота marimo ..................... 268 П.2.2. Начинаем работать с блокнотом marimo ................................. 275 П.2.3. Типы ячеек блокнотов marimo .................................................. 277 П.2.4. Расширения Markdown .............................................................. 280 П.2.5. Визуализация в marimo .............................................................. 287 П.2.6. Реактивность переменных marimo ........................................... 290 П.2.7. Элементы пользовательского интерфейса marimo ................. 294 П.2.8. Разные разности.......................................................................... 308 П.2.9. Интеграция блокнотов marimo ................................................. 312 Приложение 3. Краткий обзор языка разметки Мarkdown ......................... 322 Приложение 4. Формулы в блокнотах Jupyter и marimo ............................. 329 Приложение 5. NumPy: массивы и изображения ......................................... 333 Приложение 6. Анимация ............................................................................... 357 Литература ............................................................................................................... 363 4
ВВЕДЕНИЕ Уже более двадцати лет в учебном процессе используются технологии с англоязычными аббревиатурами STEM, STEAM и STREAM (Science, Technology, Engineering, Art, Religion или Reading, Mathematics). В немецкоязычной литературе принято сокращение MINT (Mathematik, Informatik, Naturwissenschaft, Technik). В русском языке все чаще используется термин МИТ (Математика, Информатика, Технологии). Суть применения технологий STEM в образовании заключается в решении задач, которые мы в дальнейшем называем сюжетами или занятиями. Сюжеты должны быть интересными и понятными для учащихся, наглядными. Сюжет должен быть ограничен по времени, предельная длительность сюжета — академический час, иначе теряется внимание обучаемых, основной ресурс, за который преподавателям приходится бороться. Очень желательно, чтобы длинный сюжет был структурирован и разбит на запоминающиеся фрагменты длительностью не более 10–15 минут. Наглядность повышается, если сюжеты и их фрагменты предваряются изображениями. Последний год мы используем изображения, сгенерированные нейросетями. Выше приводится такое изображение для промпта к ИИ: «STEM это не просто слово из четырёх букв1». Если фрагмент сюжета невозможно представить за 10–15 минут, то его приходится оформлять в виде импортируемых на занятиях модулей, не забывая при этом документировать и объяснять во время занятий, зачем нужна функция, что и как она делает. Именно так оформляются модули, отвечающие за визуализацию и создание анимаций. Сюжет должен быть понятным. К сожалению, не приходится рассчитывать на то, что учащиеся вспомнят изученное год назад в университете или в школе. Попытки вынести активизацию необходимых знаний на предварительную подготовку к занятиям удовлетворительных результатов не приносят, поэтому на занятиях даются краткое резюме всех сведений, используемых при решении задач, и ссылка на справочную информацию. Сюжет должен быть наглядным: лучше один раз увидеть, чем сто раз услышать. От преподавателей и от учащихся совсем не требуется обладать художественными способностями, практически все можно сделать встроенными средствами научной визуализации, а заставки, привлекающие внимание к фрагментам (модулям), легко изготовить с помощью бесплатного ИИ. Желательно повторное использование сюжетов. STEM предполагает междисциплинарность, часто в сюжет приходится вставлять дополнительные фрагменты, например, для напоминания студентам того, что они изучали ранее. Именно такие модули можно и нужно повторно использовать, в своей практике мы широко используем модули, посвящённые вычислительным методам. 1 Отсылка к знаменитой песне Б. Дилана, которую исполняла Джоан Баез: «Love is Just a Four-Letter Word». 5
Сюжеты должны быть легко доступными для учащихся, желательно до начала занятия, на которых они используются. В частности, это является обязательным для повторно используемых фрагментов, когда у преподавателя есть возможность либо обратиться к ним, либо дать студентам ссылки. Именно поэтому мы используем фреймворк marimo (см. Приложение 2. «Marimo — не только украшение аквариума»), который позволяет достаточно просто во время подготовки к занятиям подготавливать учебные материалы — фактически интерактивные электронные учебники, включающие в себя форматированный текст, формулы, исполняемые программы, изображения, видео и интерактивные приложения. На наш взгляд, каждый сюжет должен заканчиваться наглядным результатом, с которым можно взаимодействовать, изменяя условия задачи и входные данные. Основной применяемый нами методический приём — это совместное решение задач. Результатом является вычислительный документ (в этой книге — блокнот marimo), который содержит все необходимое, связанное с решением задачи. Мы настаиваем на применении вычислительных документов при использовании технологий STEM по следующим причинам: они фактически являются интерактивным электронным учебником, могут быть использованы при выполнении домашних заданий, облегчают подготовку к экзаменам. В сюжетах широко используется визуализация, начиная с завязки — объяснения причин, приведших к постановке задачи, тщательно и в наглядной форме объясняется, что имеется, что требуется получить, различные варианты постановки и решения задачи. Для экономии времени можно использовать заранее подготовленные фрагменты сюжетов, которые учащиеся вставляют в собственные вычислительные документы или делают на них ссылки. Важной частью процесса решения задачи-сюжета является обсуждение возможных подходов к её решению, декомпозиция на подзадачи, выбор инструментов и составление плана решения. Это является наиболее сложной и интересной частью процесса решения, к ней приходится возвращаться, оформлять в виде списка действий или диаграммы2 план, который всегда должен быть доступен. При решении подзадач нежелательно отвлекаться на необязательные подробности, их решение нужно сводить к нескольким строкам исходного кода, чаще всего вызову библиотечных или предварительно написанных функций. Решив подзадачи и собрав решение в единое целое, необходимо проверить, что же получилось. Это также приходится обсуждать и нарабатывать навыки тестирования с тем, чтобы решения были физически реализуемыми, например, в них не было температур ниже 0 K. На практике нужно знать, что будет с решением задачи при изменении входных параметров, как это повлияет на результаты решения задачи, работает ли наша реализация при изменённых условиях. 2 В упомянутом Приложении 2 показано, как подготавливать диаграммы в блокнотах marimo. 6
В инженерной практике нужно знать, как будет меняться решение задачи при изменении входных параметров, корректно ли её решение, работает ли наша реализация при изменённых условиях. Все этапы решения задач делаются наглядными за счёт средств научной визуализации применяемых математических систем, а также небольших апплетов для визуализации. Широко используется анимация, которую студенты создают при выполнении домашних работ. Основной целью практических занятий является решение максимально возможного числа задач с тем, чтобы хотя бы частично закрепить приёмы их решения у студентов. Студент должен выполнить восемь обязательных заданий и два мини-проекта (типовых расчёта), представляющих собой комплексные задачи. Мы поощряем совместное выполнение мини-проекта несколькими студентами, но контролируем роль каждого участника в полученном результате. Темы и технические задания на мини-проекты согласуются на первых занятиях. На консультациях студенты защищают свои мини-проекты. Если студент не может решить задачу домашнего задания, то на консультации проводится анализ и решение задачи, а студенту выдаётся новое задание. В качестве сюжетов в основном решаются так называемые прямые задачи: по заданным входным параметрам требуется вычислить выходные параметры. Для домашних заданий и мини-проектов используются ещё три типа задач: • проведение многовариантных расчётов, для чего необходимо разработать план вычислительного эксперимента, подготовить входные данные, провести эксперимент, обработать и интерпретировать выходные данные; • статистическое моделирование. Этот тип задач используется в минипроектах, для чего разработано специальное интерактивное приложения. Студенту достаточно написать функцию для решения прямой задачи с заданным интерфейсом, задать число реализаций, вероятностные распределения входных параметров. Все это можно сделать в пользовательском интерфейсе интерактивного приложения. Результатом являются таблицы значений входных и выходных параметров. Задачей учащегося является анализ и интерпретация полученных результатов. Этот тип задач удобно использовать для определения процента выхода годных при моделировании технологических процессов и оптимизации; • обратные задачи. В отличие от прямых задач в обратных задаётся часть входных и выходных параметров или даже допустимые интервалы их значений. Решение обратных задач в основном осуществляется с помощью многократного решения прямых задач. В решение обратных задач легко привнести игровой элемент и даже устраивать соревнования по скорости и качеству решения. С точки зрения МИТ важно, что решение обратных задач при числе входных параметров более трёх не может быть осуществлено с помощью перебора за разумное время. Для решения обратной задачи необходимо иметь представление о связях входных и выходных параметров между собой, чтобы сократить количество решений прямой задачи. Студенту предоставляется интерактивное веб-приложение, реализующее решение прямой задачи и проверку допустимости решения обратной задачи, а 7
также сохранение сочетаний входных и выходных параметров. Исходный текст решения прямой задачи недоступен. Необходимо представить решение, удовлетворяющее всем ограничениям как на входные, так и на выходные параметры. При решении перечисленных выше типов задач используются стандартные приёмы: аналогия, декомпозиция задач на решаемые подзадачи, поиск инструментов, реализующих решение подзадач, тестирование решения подзадач, «склеивание» подзадач по данным, тестирование решения задачи, проведение вычислительного эксперимента путём решения задачи с различными входными данными, визуализация и интерпретация решения задачи. Ещё пять лет назад можно было считать, что перечисленные основные приёмы решения задач студенты освоили в средней школе и на первых курсах университета, могут их свободно использовать при решении задач. В настоящее время часть студентов не имеют навыков решения задач, их приходится нарабатывать на практических занятиях. Решение сложной задачи уподобляется пазлу, подзадачи представляют собой элементы пазла. Если мы знаем, как решать часть пазла, полученную в результате декомпозиции, то ищем инструмент, позволяющий реализовать решение подзадачи. В экосистеме Python, используемой для решения задач книги, благодаря обширному и активному сообществу таких инструментов может быть несколько, так что приходится осуществлять информационный поиск, читать документацию и выбирать инструменты, наилучшим образом подходящие для решения данной задачи. На практических занятиях это не делается, преподаватель даёт краткую характеристику каждого инструмента, условия его применения и, если возможно, историю его создания. После этого проводится пробное применение инструмента на простом примере. Это позволяет выяснить, правильно ли студенты понимают, как нужно его применять. Кроме того, если позволяет время, приводятся примеры, как и в каких случаях нельзя применять данный инструмент, проводится тестирование на реальных данных. Кстати, необходимость тестирования также приходится объяснять на примерах. После решения всех подзадач осуществляется их «склеивание», т. е. преобразование и передача данных от одной подзадачи к другой. Данный процесс достаточно тривиален для математических систем, например, SMath, а вот для экосистемы Python приходится писать дополнительный код для передачи данных из-за неоднородности библиотек экосистемы. Заканчивается реализация решения задачи её комплексным тестированием. Здесь нам необходима вся доступная информация о частных случаях, экспериментальные данные, с помощью которых можно проверить корректность реализации. Нет никаких гарантий, что все сделано правильно, мы устраняем те ошибки, которые можем обнаружить. Погрешности реализации обнаруживаются и при проведении вычислительного эксперимента, обработке, визуализации и интерпретации полученных результатов. Кроме всего прочего, нашей целью является снять у студентов страх перед ошибками. Ошибки — это рутина, нужно приобретать навыки для их локализации и устранения. 8
Может оказаться, что мы пошли по неправильному пути, выбрав простой, привлекательный, но неправильный путь. Ничего страшного не случилось, возвращаемся к началу и ищем другой подход к решению задачи. Эти простые и очевидные навыки пытаемся с помощью многократного повторения закрепить на уровне подсознания студентов, т. к. они используются при решении практических задач. Преподаватель, использующий технологии МИТ, постоянно сталкивается с дилеммой «чёрного ящика» и «изобретения велосипеда». Здесь под «чёрным ящиком» понимается использование библиотек для научно-технических и инженерных расчётов, когда достаточно найти библиотеку, установить её, импортировать, разобраться в интерфейсах вызываемых функций, вызвать их, передав корректные данные, и получить результат. При этом можно не знать, как они работают, какие численные или символьные методы реализуют. В экосистеме Python «чёрные ящики» в большинстве своём серые, т. к. библиотечные функции, написанные на Python, доступны в исходных текстах, их не только можно, но и нужно изучать, осваивая подходы, используемые профессиональными разработчиками. Самостоятельная реализация библиотечных функций может быть хорошим упражнением для закрепления знаний, но не может быть использована на регулярной основе. В своей практике мы используем оба подхода, но при решении практических задач рекомендуем использовать библиотеки, т. к. они разработаны профессионалами, проверены при решении большого числа практических задач. На практических занятиях мы всегда акцентируем внимание студентов на отличиях учебных задач от тех задач, с которыми приходится иметь дело в практической деятельности. Для первых характерна «жёсткость» и детерминизм: кроме исходных данных и требуемого результата часто в условиях задаётся и метод её решения, учебная задача всегда имеет решение. На практике задача может и не иметь решения, а её исходные данные и результаты меняться в зависимости от обстоятельств и имеющихся ресурсов. Поэтому мы стараемся на занятиях показать, как изменение условий задачи влияет на получаемые результаты. При проверке домашних заданий мы просим студентов получить решение при изменённых условиях задачи. Для мини-проектов обязательным является разработка и защита подходов, применяемых при решении задач, в большинстве случаев требуется провести анализ чувствительности полученных результатов к случайным изменениям входных параметров с помощью статистического моделирования. Мы стараемся укрупнять темы мини-проектов с тем, чтобы их разрабатывала команда, при этом обязательным условием является четкое разделение работ, например, например, разработка пользовательских интерфейсов, тестирование, подготовка исходных данных, обработка результатов вычислительного эксперимента. Основным результатом такой деятельности является получение студентами навыков решения практических задач, а также устранение синдрома «у меня ничего не получится», а также наработка навыков взаимодействия в коллективе. 9
Основным аргументом противников технологии МИТ является высокая трудоёмкость применения. Действительно, нужно придумывать нестандартные постановки задач, но если мы не хотим потерять контакт со студентами и чему-то научить их, это делать необходимо. На эту работу не жалко тратить силы и время. Вторая существенная часть трудоёмкости связана с необходимостью обновления набора домашних заданий и тем мини-проектов, однако эта часть трудоёмкости не связана с технологиями МИТ и является составной частью нормального учебного процесса. Третья часть трудоёмкости — консультации по дисциплине, но они также являются обязательной частью учебного процесса, и наиболее полезны, т. к. позволяют индивидуально работать со студентами. На наш взгляд, снижение трудоёмкости в основном определяется правильным выбором технологий поддержки МИТ. Время доски, мела и тряпки прошло, так или иначе при подготовке к занятиям преподавателю приходится делать не заметки на листках бумаги, а электронные документы. Делать это в виде вычислительных документов не сложнее, чем в текстовом процессоре, в то же время на выходе получается фактически всё, что необходимо для проведения занятия, начиная от формулировки условий задач, заканчивая обсуждением полученных результатов. Более того, вычислительный документ, создаваемый в процессе подготовки к занятиям, может служить разделом интерактивного электронного учебника и использоваться как презентация на лекциях и практических занятиях3. Необходимым условием применения МИТ является наглядность, в настоящее время практически все изображения, необходимые для проведения занятий, создаются встроенными средствами научной визуализации, и вопрос о добавлении дополнительного изображения просто не стоит. Не представляет трудности создание анимаций, встраиваемых в вычислительные документы. Заодно студенты учатся оформлению научных публикаций. Немаловажным средством снижения трудоёмкости является публикация вычислительных документов сюжетов. В облаке или корпоративной сети университета публикуются вычислительные документы (блокноты) сюжетов и их фрагменты, видеозаписи проведённых занятий, веб-приложения для решения задач перечисленных выше типов, вычислительные документы студентов. Основными требованиями к процедуре публикации являются её простота и возможности внесения изменений и структурирования публикуемой информации. Мы делаем доступными вычислительные документы SMath и экосистемы Python. Ограничения по вычислительным ресурсам приводят к необходимости обеспечивать доступность только тех приложений, которые используются на занятиях или выполнении домашних работ в данный промежуток времени. Практически для всех видов публикаций разработаны приложения с графическим пользовательским интерфейсом, автоматизирующие публикацию документов и приложений всех рассмотренных типов, их достаточно легко интегрировать с помощью простого 3 Для превращения блокнота marimo в презентацию достаточно выбрать в меню приложения Present as | Slides (Представить как | Слайды). Вернуться в режим редактирования можно, выбрав Present as | Toggle app view (Представить как | Переключить представление приложения). 10
веб-приложения, как показано в Приложении 2. В него же можно встраивать электронные учебники в виде документов Markdown, HTML и PDF. Как показывает опыт, основная трудоёмкость применения технологий МИТ сосредоточена на этапах освоения технологий и подготовки учебных материалов. Уже на втором году проведения занятий трудоёмкость резко снижается. При этом основное время уходит на коррекцию учебных материалов с учётом реакции студентов. Кроме того, необходимо нарабатывать интерактивные веб-приложения, которые студенты используют для решения обратных задач и статистического моделирования. Подготовке учебных материалов в виде интерактивных приложений помогает привлечение студентов во время выполнения мини-проектов, а иногда и выпускных квалификационных работ. Как показывает опыт, основными факторами, снижающими трудоёмкость, являются разумный выбор технологической платформы для поддержки МИТ, возможность оперативного создания интерактивных веб-приложений, оперативной публикации учебных материалов, включая веб-приложения. В предлагаемой читателю книге в качестве технологической платформы используется экосистема Python. Выбор платформы обусловлен тем, что она «с батарейками», т. е. бесплатна, открыта, в ней можно найти практически все необходимое для решения практических задач, а если это не так, что бывает очень редко, есть с кем посоветоваться и получить консультацию, какие инструменты и как использовать, чтобы решить задачу. От читателя требуются элементарные навыки работы с Python, включая работу с переменными, функциями, основными типами данных, про что можно узнать практически в любом руководстве по языку программирования. Более сложные вопросы, относящиеся к работе с массивами NumPy, которые постоянно используются при решении задач, подробно изложены в Приложении 5. NumPy — массивы и изображения. Это приложение может служить справочным пособием. Среду, в которой можно работать с примерами книги, легко создать, если установить переносимый дистрибутив Python WinPython4 для операционной системы Windows или Anaconda5 для Windows, Linux и MacOs. Оба дистрибутива занимают около 7 Гб пространства на жёстком диске. Мы рекомендуем пойти другим путём, установив менеджер пакетов uv. Сделать это можно в Linux и MacOs, выполнив в терминале команду: curl -LsSf https://astral.sh/uv/install.sh | sh В Windows необходимо открыть меню Пуск, выбрать Windows PowerShell, а затем нажать кнопку Открыть. В окне командной строки нужно выполнить команду: Set-ExecutionPolicy RemoteSigned -scope CurrentUser На вопрос системы нужно ответить Y. Это позволит второй командой установить подписанное приложение uv: irm https://astral.sh/uv/install.ps1 |iex 4 WinPython. [Электронный ресурс]. — URL: https://sourceforge.net/projects/winpython/postdownload (дата обращения 24.04.2026). 5 Anaconda. Distribution. [Электронный ресурс]. — URL: https://www.anaconda.com/download (дата обращения 24.04.2026). 11
После установки может потребоваться добавить путь к uv в переменные окружения (система сообщит об этом): $env:Path="path_to_uv;env:Path" Здесь path_to_uv — путь к исполняемому файлу uv, в случае авторов: PS C:\Users\ai\.local\bin Проверить установку можно, введя команду: uv version В нашем случае было выведено: uv 0.5.29 (ca73c4754 2025-02-05) После этого необходимо создать папку для работы с примерами книги, например, 16_etudes, перейти в эту папку и в ней ввести команду для создания виртуального окружения — среды для работы с примерами книги: uv venv --python 3.12 Практически мгновенно виртуальное окружение будет создано в подпапке .venv этой папки. Мы будем работать с версией Python 3.12. Работать с Python и устанавливать библиотеки можно только после активации виртуального окружения, делается это в Windows с помощью команды: .venv\scripts\activate В Linux и MacOs активацию необходимо будет сделать с помощью другой команды: source.venv/bin/activate После этого в начале командной строки появится имя виртуального окружения в круглых скобках, в нашем случае (!6_etudes). Следующим шагом является создание менеджером пакетов uv проекта в текущей папке: uv init Последним шагом является установка библиотек, необходимых для работы с книгой, командой: uv add black cryptography fastapi gif jinja2 marimo mo[sql] matplotlib numpy pandas plotly pooch ruff schemdraw scipy seaborn stats sympy toml typing uncertainties Это самая ресурсоёмкая часть установки рабочей среды, так как библиотеки придётся скачивать через Интернет. Более подробно об установке рабочей среды и работе с менеджером пакетов uv говорится в Приложении 1. Создаём рабочую среду с помощью менеджера пакетов uv. Для работы с примерами мы используем блокноты marimo. Более подробно о причинах выбора этой среды разработки говорится в Приложении 2. Marimo — не только украшение аквариума. Мы его сделали достаточно подробным из-за того, что проект достаточно новый, интенсивно развивается, а документация по нему на русском языке на момент написания книги отсутствовала. Для проверки работоспособности запустите блокнот marimo командой: marimo edit tmp.py Если в браузере, запускаемом по умолчанию появится вкладка с блокнотом, то всё в порядке, в противном случае удалите папку проекта, прочитайте начальную часть Приложения 1 и выполните действия по созданию рабочей среды. 12
Книга предназначена в основном для студентов младших курсов и школьников, однако она будет полезна и для студентов старших курсов, преподавателей, аспирантов, использующих МИТ в своей практической деятельности. Для работы с примерами предлагаемого издания читателям необходимы, как уже говорилось выше, только элементарные навыки программирования на Python. Ключевой особенностью книги является её доступность. Все необходимые сведения, связанные с предметной областью, излагаются в процессе решения задач (занятий-сюжетов книги6). Мы стараемся рассматривать задачи книги с различных сторон, подробно разбирая преимущества и недостатки различных подходов к их решению, анализу и интерпретации полученных результатов. В вводной главе рассматриваются основные методические приёмы МИТ для решения задач, создание рабочей среды, которая используется при работе с примерами книги. В книге 8 глав, охватывающих 16 занятий-сюжетов, и шесть приложений справочного характера. Число 16 — это не только 24, но и обычное количество вузовских учебных недель в семестре. В первой главе «Занятия 1, 2 и 3. Математический анализ трамвайных путей» говорится о геометрических построениях, которые связаны с механикой, гладким сопряжением кривых и научной визуализацией. Вторая глава «Занятие 4. Вычислительный ренессанс: метод Ньютона и не только» мы «изобретаем велосипед», реализуя численные и аналитические методы решения систем алгебраических уравнений. Заодно показываем, как визуализация областей притяжения корней уравнений приводит к построению «мистических» изображений и фракталов и как эти изображения зависят от используемого метода. В главе 3 «Занятия 5 и 6. В этом мире нет совершенства» рассказывается, как погрешности исходных данных и конечная точность вычислений влияет на результаты решения задач. Приводятся примеры, иллюстрирующие, как ошибки могут накапливаться и приводить к неверным и неожиданным, а иногда и абсурдным результатам. Заодно рассказываем об интервальном анализе, средствах учёта неопределённостей и проведения расчётов с произвольной точностью в экосистеме Python. В главе 4 «Занятия 7 и 8. Криптография и головоломки» обсуждаются вопросы скрытия и шифрования информации. В этой главе мы рассмотрим простые приёмы в основном с исторической точки зрения, но при этом затронем и современные методы, имеющиеся в библиотеке cryptography экосистемы Python. В главе 5 «Занятия 9 и 10. Наш старый знакомый маятник» мы возвращаемся к школьной задаче по физике и показываем, что с ней не всё так просто. Действительно, небольшое изменение условий задачи приводит к детерминированному хаосу. В главе 6 «Занятия 11 и 12. Мистер Х, или Немного статистики» мы окунаемся в мир случайных явлений, пытаемся генерировать и обрабатывать экспериментальные данные в экосистеме Python. 6 Ряд сведений, носящих справочный характер и общих для нескольких сюжетов, находятся в приложе- ниях. 13
Глава 7 озаглавлена «Занятие 13, 14 и 15. Проверить алгеброй геометрию, а заодно и гармонию». Здесь мы рассказываем, как с помощью простых алгебраических формул преобразовывать геометрические фигуры, а заодно — как на их основе строить сложные мозаики для замощения квадрата и круга. Эти же мозаики мы наносим на цилиндр, тор и ленту Мёбиуса. Глава 8 «Занятие 16. Путешествие в мир науки и искусства на стопоходящей машине Чебышёва» посвящена проектированию механизмов с заданной траекторией перемещения, а также генерации узоров на комплексной плоскости. Приложения носят вспомогательный и справочный характер, обращаться к ним нужно по мере необходимости. В Приложении 1 «Создаём рабочую среду с помощью менеджера пакетов uv» подробно и по шагам рассматривается менеджер пакетов uv, приёмы работы с ним, оперативное создание виртуальных окружений и рабочих сред. Собственно uv — это многофункциональный, очень быстрый и удобный менеджер пакетов, написанный на Rust, в значительной степени упрощающий работу в экосистеме Python. Приложение 2 «Marimo не только украшение аквариума» посвящено новой среде разработки для работы с вычислительными документами — блокнотами, которая автоматизирует вычисления, позволяет просто и оперативно создавать интерактивные веб-приложения, интегрировать их с текстом, формулами и научной визуализацией, создавать интерактивные электронные учебники, публиковать их, щелчком мыши превращать в презентации, что очень полезно в учебном процессе. Приложение 3 «Краткий обзор языка разметки Мarkdown», в отличие от подробного описания marimo, представляет собой шпаргалку по Markdown, который в marimo является «клеем», позволяющим естественным образом объединять форматированный текст, изображения, анимацию, значения переменных и элементы пользовательского интерфейса. Приложение 4 «Формулы в блокнотах Jupyter и marimo». В нём мы рассказываем, как в вычислительном документе работать с математическими формулами не хуже, чем в Word или другом полнофункциональном текстовом процессоре. В Приложении 5 «NumPy: массивы и изображения» мы попытались в наглядной форме показать, как удобно и быстро можно в Python работать с массивами, а заодно и с изображениями. Приложение 6 «Анимация». Здесь показано, как с помощью простой библиотеки gif создавать и встраивать в электронные учебники анимационные ролики. В результате активной работы с примерами книги читатель получит навыки решения научно-технических задач с использованием технологий МИТ, работы с такими библиотеками экосистемы, как NumPy и SciPy, научной визуализации, построения анимации и графических пользовательских интерфейсов в блокнотах marimo. Все сюжеты содержат обязательный раздел «Задания читателям», позволяющие закрепить пройденное, а также развивающие сюжеты глав. Читатели научатся: • свободному обращению с библиотеками экосистемы Python; 14
• приёмам научной визуализации, включая оперативное создание анимации; • быстрому построению интерактивных приложений с графическим пользовательским интерфейсом с минимумом написания исходного кода. Также читатели смогут освоить навыки создания изолированных рабочих сред для проведения научно-технических и инженерных задач на Python. После прочтения книги и решения задач, приведённых в конце глав, читатели получат: • глубокое понимание методов научно-технических расчётов; • умения и навыки для анализа и решения расчётных задач на Python; • снятие синдрома «у меня ничего не получится…»; • портфолио работ, демонстрирующее полученные навыки; • уверенность в себе при решении собственных научно-технических и инженерных задач. Решения задач, рассмотренных в книге, представлены в виде блокнотов marimo7 (Приложение 2), которые с одной стороны представляют собой скрипты на «чистом» Python, а с другой содержат форматированный текст с постановкой задачи и обсуждением полученных результатов, математические формулы, изображения, видео, исходные тексты программ, которые легко при необходимости скрывается от пользователя, и полнофункциональным пользовательским интерфейсом. Книга является третьей в серии книг «16 занятий МИТ». Первая книга опиралась на физико-математический пакет SMath Studio, а вторая на среду динамического программирования SimInTech. Авторы учли такой недостаток предыдущих двух книг — занятия были разными по объёму, но они помещались в одну главу. В данной книге отдельные главы содержат разное число занятий с учётом их объёма. Все примеры книги находятся в открытом доступе по адресу https://github.com/tai-py/SixteenMITPython. О создании рабочей среды для работы с примерами говорится в Приложении 1. Создаётся рабочая среда с помощью менеджера пакетов uv. 7 The future of Python notebooks. [Электронный ресурс]. — URL: https://marimo.io/ (дата обращения 24.04.2026). 15
ГЛАВА 1 ЗАНЯТИЯ 1, 2 И 3. МАТЕМАТИЧЕСКИЙ АНАЛИЗ ТРАМВАЙНЫХ ПУТЕЙ Весна, весна на улице, Весенние деньки! Как птицы, заливаются Трамвайные звонки. Агния Барто Водитель автомобиля при повороте на перекрёстке сначала плавно поворачивает рулевое колесо («баранку») на определённый угол, затем некоторое время выдерживает этот угол, заставляя машину двигаться по дуге окружности8, а потом опять же плавно возвращает руль в исходное положение для прямолинейного движения, но уже в другом направлении, перпендикулярном прежнему. А как поворачивает за угол трамвай? У вагоновожатого нет рулевого колеса — трамвай едет по жёстким рельсам. Если на повороте их сделать в виде четверти окружности, это приведёт к тому, что трамвай в начале поворота и в его конце получит довольно сильные боковые толчки, сила которых зависит от скорости трамвая и его массы. Представьте себе, что водитель автомобиля предельно резко повернул руль на повороте. Так и машину опрокинуть недолго! Такие толчки трамвая приводят к преждевременному износу и рельсов, и самого транспортного средства, его колёс. Да и пассажирам это не понравится. Причиной таких толчков будет резкое изменение кривизны пути с нулевого значения до значения, равного обратной величине радиуса поворота. Можно, конечно, при проектировании трамвайных путей просто скопировать след автомобиля на повороте. Но можно поступить иначе и вспомнить о царице наук — о математике, в частности, о математическом анализе функции одного аргумента. 1.1. Переходная кривая Поворотный круг на конечной остановке трамвая представляет собой круг, от которого отходят кривые, соединяющие его с прямолинейными трамвайными путями. На них кривизна меняется от нуля (прямолинейный участок) до значения величины, обратной радиусу поворотного круга. Желательно, чтобы в точке стыковки поворотного круга касательные к поворотному кругу и к переходному 8 Есть такая важная техническая характеристика транспортного средства — минимальный радиус поворота, когда рулевое колесо поворачивают вправо или влево до предела. У легковых автомобилей этот показатель небольшой, у грузовых побольше. Всё это, конечно, делается на малой скорости. 16
участку совпадали. Такие кривые называют переходными. Есть много кривых, обладающих таким набором свойств. Простейшая из них — это кубическая парабола. Мы определим кривизну кубической параболы с помощью библиотеки символьных вычислений SymPy Python. Построение будем проводить в вычислительном документе — блокноте marimo e01_01.py9. В нулевой ячейке (нумерация в Python начинается с нуля) импортируются необходимые библиотеки и с помощью Markdown выводится заголовок блокнота (см. Приложение 3): # ячейка 0 import marimo as mo import numpy as np import matplotlib.pyplot as plt import sympy as smp mo.md("# Кривизна кубической параболы. Стилизованный символ М") Нам требуется записать формулу для кубической параболы и вычислить две её первые производные: 𝑦(𝑥) = 𝑥 3 , 𝑑𝑦(𝑥) (1.1) 𝑦𝑠 = 𝑦 ′ (𝑥) = = 3 ∙ 𝑥 2, 𝑑𝑥 2 𝑑 𝑦(𝑥) 𝑦𝑠2 = 𝑦 ′′ (𝑥) = = 6 ∙ 𝑥. 𝑑𝑥 2 Воспроизведём вычисления по формулам (1.1) с помощью библиотеки SymPy. Работу с нею в первой ячейке начинаем с инициализации вывода формул. Это позволяет представлять формулы в блокноте именно так, как мы записываем их в школьной тетради: # ячейка 1 smp.init_printing() # вывод формул с помощью LaTeX x, y = smp.symbols('x y') # создаем символьные переменные y = x**3 # кубическая парабола ys = smp.diff(y, x) ys2 = smp.diff(y, (x, 2)) mo.hstack([x, y, ys, ys2]) Далее мы инициализируем символьные переменные 𝑥, 𝑦 и записываем формулы для кубической параболы и её двух первых производных. По умолчанию, результаты вычислений в последней строке ячейки выводятся в marimo над ячейкой и в столбик. Поэтому мы используем функцию hstack для вывода значений переменных по горизонтали: 𝑥 𝑥 3 3𝑥 2 6𝑥. Кривизна вычисляется по формуле: |𝑦 ′′ (𝑥)| 𝐾(𝑥) = (1.2) 3. (√1 + 𝑦 ′ (𝑥)2 ) Проведём вычисления кривизны в символьном виде во второй ячейке: # ячейка 2 K = smp.Abs(ys2)/(smp.sqrt(1 + ys**2))**3 9 Имена блокнотов мы будем начинать с латинского символа e (от etude), далее следует номер главы и номер блокнота в главе. Мы отказались от того, чтобы начинать имена блокнотов с цифры, следуя правилам именования Python. Имена блокнотов в приложениях начинаются с a (appendix). 17
K Формулу для кривизны над ячейкой получим практически в том же виде, что и в (1.2): 6|𝑥| 3. (9𝑥 4 + 1)2 Можно было бы вывести график 𝐾(𝑥) в символьном виде с помощью smp.plot, но мы пойдём другим путём и преобразуем символьное выражение для 𝐾 в универсальную функцию NumPy, а её график нарисуем стандартными средствами matplotlib: Kl = smp.lambdify(x, K, 'numpy') xmax, n = 1.5, 1000 xx = np.linspace(-xmax, xmax, n) yy = Kl(xx) _fig = plt.figure(figsize=(3,3)) plt.plot(xx, yy, 'b-', lw=10) plt.axis('off') _fig Именно график кривизны кубической параболы был использован в заставке главы. Примечание График кривизны кубической параболы представляет собой стилизованный символ М. Его можно использовать в качестве логотипа (эмблемы) какого-нибудь метрополитена или всех метрополитенов мира. Кубическая парабола с её кривизной используется для профилирования рельсового пути на поворотах не только трамваев, но и поездов метро. Такой необычный (стилизованный) символ М на входе в подземку будет лишний раз напоминать о математике рельсового пути — о теме этого занятия! Один из авторов зарегистрировал этот товарный знак — см. https://www.fips.ru/ registers-doc-view/fips_servlet?DB=RUTM&DocNumber=1017112&TypeFile= html. Тем самым в наше занятие по дисциплине МИТ (Математика — Информатика — Техника) в ключе междисциплинарных связей вклинились и юриспруденция, и патентное дело. Формулы для кубической параболы и её кривизны можно легко найти в Интернете, но вот совмещение их над одном графике заставляет задуматься. Дело в том, что кубическая парабола имеет размерность расстояния (метр), а у её кривизны — обратная величина от расстояния (единица, делённая на метр или метр в минус первой степени). Библиотека matplotlib позволяет изящно решить эту задачу: мы рисуем два рисунка, а затем их совмещаем, отображая оси координат и оцифровку слева и справа (рис. 1.1). _fig = plt.figure(figsize=(5,3)) # рисунок для кубической параболы ax = plt.subplot(111) line1 = ax.plot(xx, xx**3, 'r-', lw=3, label='$y(x)$') # рисуем y(x) ax.set_ylim(-.2, 3.) # граничные значения по y # надписи на осях для первого графика 18
ax.set_xlabel("$x, м$") ax.set_ylabel("$y(x), м$") ax.grid() # сетка # график для кривизны ax2 = ax.twinx() # сдваиваем рисунки line2 = ax2.plot(xx, yy, 'b-', lw=3, label="$K(x)$") ax2.set_ylabel("$К(x), м^{-1}$") # выводим легенду lines = line1 + line2 # линии labels = [l.get_label() for l in lines] ax.legend(lines, labels, loc='best') # управление наложением рисунков plt.tight_layout() _fig В исходном тексте мы прокомментировали практически каждое действие. Отметим, что, заключая надписи в долларовые скобки, мы используем LaTeX для вывода надписей, а легенда формируется совместно для двух рисунков. Рис. 1.1 Вывод графиков кубической параболы и её кривизны на один рисунок На заметку Можно ругать библиотеку matplotlib за её старомодность, но она позволяет подготавливать рисунки полиграфического качества для публикаций в журналах и книгах. Кстати, самый известный логотип, в котором скрыто некое техническое решение, — логотип фирмы «Ситроен». Там изображены две перевёрнутые галочки, расположенные друг над другом и напоминающие о том, что первое шевронное зубчатое колесо было применено на автомобилях именно этой фирмы. До этого были шестерёнки либо с прямыми, либо с косыми зубьями. Первые сильно шумели, а вторые создавали большое осевое усилие. Шевронное зубчатое колесо не имеет таких недостатков. На рисунке 1.1 кривизна кубической параболы показана в зависимости от значения абсциссы, что не совсем верно — трамвай едет не по оси x, а по самой 19
кривой, по кубической параболе. На рисунке 1.2 показаны две кривые кривизны — в зависимости от значения x (синяя кривая) и в зависимости от длины кубической параболы с отсчётом от нулевой точки (зелёная кривая). На отрезке между точками максимумов эти две кривые кривизны практически совпадают. Дальше (слева и справа) они расходятся и стремятся к нулю. Рис. 1.2 Кубическая парабола (красная линия) и её кривизна в зависимости от горизонтальной координаты x (синяя линия) и длины кубической параболы (зелёная линия) Получилась ещё более стильная (двойная) буква М для логотипа (товарного знака). Кроме того, мы в формуле для вычисления длины кривой познакомились с интегралом, который наряду с производной является неким базисом матанализа. Теорема Ньютона — Лейбница связывает производную с интегралом. Вычисление длины кубической параболы осуществляется средствами SymPy: l, t = smp.symbols('l t') l = smp.integrate(smp.sqrt(1+ys**2), (t, 0, x)) l Вычисленный неопределённый интеграл имеет вид: 𝑙(𝑥) = 𝑥 √1 + 9𝑥 4 . (1.3) Теперь настала очередь сконструировать из кубической параболы переходной участок, начинающийся в точке абсцисс (𝑦 = 0, 𝑥 = 0). Нам необходимо найти параметры переходного круга: координаты его центра (𝑥0 , 𝑦0 ) и его радиус 𝑅. Для этого в точке пересечения 𝑥𝑐 необходимо выполнение трёх условий: 𝑦𝑐 (𝑥𝑐 ) = 𝑦𝑟 (𝑥𝑐 ), 𝑦𝑐′ (𝑥𝑐 ) = 𝑦𝑟′ (𝑥𝑐 ), (1.4) 𝐾𝑐 (𝑥𝑐 ) = 𝐾𝑟 (𝑥𝑐 ). Здесь 𝑦𝑐 (𝑥) — переходной участок, 𝑦𝑟 (𝑥) — окружность, 𝐾𝑐 (𝑥), 𝐾𝑟 (𝑥) — кривизна переходного участка и окружности. На конечных остановках трамваю необходимо развернуться, для чего он переходит с прямолинейного пути, параллельного оси абсцисс (𝑦 = 0, 𝑥 < 0) на переходную кривую, а затем на дугу окружности с радиусом 𝑅. К поворотному кругу может подходить несколько путей, построенных так, как говорилось выше, переход на новый маршрут управляется положением стрелок. 20
Сразу заметим, что условия (1.4) обеспечивают достаточно плавный переход, если кривизна переходного участка будет монотонно и плавно изменяться от нуля до кривизны поворотного круга. Нам необходимо решить систему уравнений (1.4), для чего используем функцию root из библиотеки scipy.optimize. Библиотеке передаются следующие параметры: rh — функция, вычисляющая кортеж невязок (отклонений от нуля) решаемой системы уравнений; t — кортеж начальных значений решения уравнения и кортеж args — значения необязательных параметров функции rh. Вот это требует пояснения. Дело в том, что rh должен передаваться один обязательный параметр — кортеж параметров системы уравнений, но иногда нужно передать большее число параметров: в нашем случае у нас три неизвестных (𝑡 = (𝑥𝑐 , 𝑥0 , 𝑦0 )), но есть ещё и 𝑅 — радиус поворотного круга, который должен быть задан до начала решения системы уравнений. Именно его мы передаём в именованном параметре 𝑎𝑟𝑔𝑠 = (𝑅, ) функции root. В качестве результата root возвращает достаточно сложную структуру данных, которую мы обсудим ниже, а сначала напишем функцию rh (блокнот e01_02.py): def rh(t, R=1): """ Вычисление кортежа правых частей системы уравнений для определения параметров переходного участка с кубической параболой Параметры: t - кортеж параметров, включающий в себя: xс - горизонтальная координата точки пересечения переходного участка и поворотного круга, x0, y0 - координаты центра поворотного круга, R - радиус поворотного круга. Функция возвращает кортеж невязок в точке пересечения для: 1. координат переходного участка и поворотного круга, 2. касательных, 3. кривизны. """ xc, x0, y0 = t # распаковка кортежа yc = xc**3 # вертикальная координата точки пересечения # невязка координат eq1 = (xc - x0)**2 + (yc -y0)**2 - R**2 # невязка производных eq2 = 3*xc**2*(yc - y0) + (xc - x0) # невязка кривизны eq3 = 6*xc/np.sqrt(1 + 9*xc**4)**3 - 1/R return eq1, eq2, eq3 Мы снабдили функцию rh подробной документацией, необходимо только отметить, что для вычисления невязки производных для поворотного круга нужно использовать формулу для производной неявной функции. Вызовем функцию root: _t, _R = (0.1, 0.1, 1), 1 root(rh, _t, args=(_R,)) 21
Результатом её работы будет: message: The solution converged. success: True status: 1 fun: [-5.708e-12 -3.674e-12 -2.621e-12] x: [ 1.685e-01 8.363e-02 1.001e+00] method: hybr nfev: 13 fjac: [[ 6.056e-02 2.935e-02 9.977e-01] [-1.501e-01 -9.879e-01 3.817e-02] [-9.868e-01 1.521e-01 5.543e-02]] r: [ 5.827e+00 -3.313e-02 1.172e-01 1.005e+00 -2.142e-01 -1.975e+00] qtf: [-2.052e-10 2.936e-10 3.282e-10] В возвращаемой структуре данных для нас важны следующие поля: success — признак наличия результата; только в случае успеха имеет смысл рассматривать поле x — решение системы уравнений (массив NumPy) и массив невязок — поле fun (это то, что возвратит функция rh, если в неё подставить решение, возвращенное root). Если же мы зададим нулевое начальное приближение: _t, _R = (0., 0., 0.), 1 root(rh, _t, args=(_R,)) то решения не получим: message: The iteration is not making good progress, as measured by the improvement from the last ten iterations. success: False status: 5 fun: [-1.000e+00 0.000e+00 -1.000e+00] x: [ 0.000e+00 0.000e+00 0.000e+00] method: hybr nfev: 19 fjac: [[ 1.000e+00 -6.261e-10 4.097e-16] [-6.258e-10 -9.997e-01 -2.635e-02] [-1.650e-11 -2.635e-02 9.997e-01]] r: [ 2.045e+16 5.527e+14 1.494e+07 6.155e+00 4.131e-02 1.089e-03] qtf: [-1.000e+00 2.635e-02 -9.997e-01] Начальное приближение играет важную роль. Желательно выяснить, сколько всего решений можно получить. Для этого написаны две функции — solve и scan_initials — в блокноте e01_02.py. Первая функция предназначена для решения системы уравнений для заданного начального приближения: def solve(xc=0, x0=0, y0=0, R=1.): ''' Ищем решение при заданном начальном приближении ''' result = root(rh, (xc, x0, y0), args=(R,)) if result.success: return result.x else: return None 22
Вторая функция сканирует трёхмерную область 0 ≤ 𝑥𝑐 ≤ 1, 0 ≤ 𝑥0 ≤ 1, 0 ≤ 𝑦0 ≤ 1 и возвращает список найденных решений. Для 𝑅 = 1 таких решений четыре, но нужно учитывать, что для некоторых начальных приближений решение отсутствует, при этом функция solve возвращает None: 𝑥𝑐 𝑥0 𝑦0 0.16848299, 0.25333541, -0.99161088 0.63136339, 1.39849492, -0.38981594 0.63136339, -0.13576814, 0.89316375 0.16848299, 0.08363056, 1.00117617 Корни упорядочиваются по значениям 𝑦0 , чтобы нумерация была одинаковой для различных 𝑅. Для наглядности была написана функция root_traction, визуализирующая области притяжения корней системы уравнений: точкам в пространстве начальных приближений сопоставляется цвет (темно-синий — решение отсутствует, корень 1 — голубой, корень 2 — зелёный, корень 3 — оранжевый, корень 4 — красный). Практически невозможно закрасить цветом трёхмерную область, поэтому на рисунке 1.3 визуализировались двумерные распределения (𝑥с по горизонтали, 𝑥0 по вертикали) для различных 𝑦0 . Области притяжения показывают, как задавать начальные приближения, чтобы получить какое-либо решение. Гораздо интереснее посмотреть, а как выглядят эти решения. Сделаем мы это с помощью функции root_traction. Рис. 1.3 Области притяжения корней системы уравнений для кубической параболы def visualize_solutions(m=20, R=.7, n=1000, figsize=(6,6), lw=2, ms=10, equal=True, fs=8): ''' Визуализируем список полученных решений m — число разбиений диапазонов начальных значений параметров, R — радиус поворотного круга, n — число разбиений кривых на графиках, figsize — размер рисунка, lw — толщина линий, ms — размер маркера, equal — флажок задания равных размеров по осям, fs — размер шрифта легенды Функция возвращает объект рисунка и список решений 23
''' # получаем список решений r = scan_initials(m=m, xcs=(0.,1.), x0s=(0.,1.), y0s=(0.,1.), R=R, eps=1e-6) t = np.linspace(-R, R, n) yc = np.where(t>=0, t**3, 0) # кубическая парабола fig = plt.figure(figsize=figsize) colors = 'blue', 'red', 'brown', 'orange', 'lime' for i, rr in enumerate(r): # рисую окружности xc, x0, y0 = rr x, y = R*np.cos(2*np.pi*t) + x0, \ R*np.sin(2*np.pi*t) + y0 plt.plot(x, y, color=colors[i+1], lw=lw, label=f"круг №{i+1}") plt.plot(x0, y0, marker='+', color=colors[i+1], ms=ms) plt.plot(xc, xc**3, marker='+', color=colors[i+1], ms=ms) plt.plot(t, yc, color=colors[0], lw=lw, label='парабола') plt.grid() plt.legend(fontsize=fs) if equal: plt.axis('equal') plt.xlabel('x') plt.ylabel('y') return fig, r Функции передаётся радиус поворотного круга, для которого необходимо построить решения. Она осуществляет сканирование области притяжения решений и на основе их строит все четыре поворотных круга для данного радиуса R. Их взаимное расположение, центры и точки пересечения с кубической параболой помечены цветом и маркерами на рисунке 1.4. Можно провести исследование зависимости числа решений системы от радиуса поворотного круга. Оказывается, что при уменьшении 𝑅 до 0.5675 число решений сокращается до двух (рис. 1.5), при меньших радиусах поворотного круга решение отсутствует. Выбор конкретного решения определяется практическими соображениями, например, если выбрать третье или четвёртое решение (оранжевый и зелёный круги), то придётся сооружать дополнительные стрелки. Теперь посмотрим, как при этом будет меняться кривизна переходного участка, для чего была написана функция visualize_parabola (блокнот e01_02.py). На рисунке 1.6 приведены графики переходного участка, дуги поворотного круга, зависимости кривизны переходного участка от горизонтальной координаты 𝐾(𝑥) и от длины кривой переходного участка 𝐾(𝑙(𝑥)) для минимального радиуса поворотного круга 𝑅 = 0,5675. Отметим, что кривизна практически линейно зависит от горизонтальной координаты, а кривые 𝐾(𝑥) и 𝐾(𝑙(𝑥)) практически совпадают между собой. 24
Рис. 1.4 Визуализация решений системы уравнений для R=0.6, 0.8, 1.0, 1.5 Рис. 1.5 Четыре решения системы вырождаются в два 25
Рис. 1.6 Переходной участок, дуга поворотного круга для R=0.5675 и кривизна Ситуация существенно меняется при увеличении радиуса поворотного круга (рис 1.7). Рис. 1.7 Переходной участок, дуга поворотного круга для R=1 и кривизна: а — xc=0.168; б — xс=0.631. Все, как оказалось, зависит от положения точки пересечения переходного участка и поворотного круга 𝑥𝑐 . Для небольших 𝑥𝑐 кривизна меняется монотонно 1 и практически линейно от 0 до (рис. 1.7а), а вот для решений, для которых 𝑅 26
𝑥𝑐 > 0.3 (рис. 1.7б), кривизна резко возрастает, а затем спадает до значения, определяемого кривизной поворотного круга. Старые трамваи при поворотах разгоняли зазевавшихся пешеходов не только звонками, но и визгом колёс. Это происходило из-за того, что у этих трамваев были не просто колёса, а колёсные пары, как у поездов. Современные же низкопольные трамваи имеют отдельные колёса. В колёсной паре угловая скорость вращения левого и правого колеса всегда одинакова, а должна быть разной при поворотах. Отсюда и визг пробуксовывающих колёс. У поездов метро с колёсными парами такого визга почти не слышно, потому что у них радиусы поворотов намного больше, чем у трамвая. Именно поэтому не следует использовать решение, пример которого представлен на рисунке 1.7б, т. к. это обуславливает увеличение нагрузок на колёсные пары и их износ. 1.2. Катаемся по кругу На рисунке 1.8 показано фото испытательного полигона железнодорожного транспорта в посёлке Щербинка под Москвой. Это овал длиною примерно 6 км с двумя прямолинейными участками. Вот мы добрались и до овала, а то были всё окружности да параболы. Рис. 1.8 Полигон железнодорожного транспорта Замкнутая овалоподобная кривая, состоящая из двух равных параллельных отрезков и двух соединяющих их полуокружностей, на Западе называется Stadium. Её нельзя назвать овалом не только потому, что она не полностью гладкая, — у неё изломы в четырёх местах. Кроме того, овал — это строго выпуклая замкнутая гладкая кривая, которую любая прямая может пересечь не более чем в двух точках. А замкнутая кривая Stadium условию строгой выпуклости не отвечает — её бока имеют с прямой бесконечное число общих точек. Заметим, что Stadium ограничивает выпуклую область (стадион) и лежит по одну сторону от любой касательной к ней, поэтому является выпуклой кривой. Кривая Stadium станет гладкой, если в неё вставить четыре отрезка переходной кривой — кубической параболы, например; её расчёт мы описали выше. Но замкнутая кривая 27
для железнодорожного полигона может иметь более интересную (инновационную!) форму. Поговорим о ней подробнее. Для железнодорожного транспорта, особенно для высокоскоростных магистралей, где опасность схода с рельсов подвижного состава намного выше, чем у трамвая или поездов метро, в качестве переходной кривой используют не кубическую параболу, простую в расчётах, а клотоиду (спираль Эйлера; см. рис. 1.9), у которой кривизна меняется линейно в зависимости от длины спирали (параметра t), но нелинейно в зависимости от значения (параметр x). Рис. 1.9 Клотоида Кривая Эйлера описывается параметрически с помощью специальных функций, называемых интегралами Френеля: 𝑥 𝑥 2 𝑆(𝑥) = ∫ sin 𝑡 𝑑𝑡, 𝐶(𝑥) = ∫ cos 𝑡 2 𝑑𝑡. 0 (1.5) 0 Иногда, в частности в библиотеке Python scipy (scipy.special.fresnel), используется другое определение интегралов Френеля: 𝑥 π 𝑆𝑚 (𝑥) = ∫ sin 𝑡 2 𝑑𝑡, 2 0 𝑥 π 𝐶𝑚 (𝑥) = ∫ cos 𝑡 2 𝑑𝑡. 2 (1.6) 0 Для вычисления интегралов Френеля по формуле (1.5) напишем функцию: def clotoid(t, a=1): """ Параметрическое задание клотоиды Параметры: t - параметр. Функция возвращает значения интегралов Френеля, соответствующие значению параметра t """ 28
ss, cc = fresnel(t / np.sqrt(np.pi / 2)) scaled_ss = a * np.sqrt(np.pi / 2) * ss scaled_cc = a * np.sqrt(np.pi / 2) * cc return scaled_cc, scaled_ss На заметку Интегралы Френеля можно вычислить непосредственно с помощью библиотечной функции scipy.integrate.quad. Функции передаются подынтегральная функция и пределы интегрирования, возвращает она значение интеграла и погрешность. В примере e01_03.py показано, что результат получается один и тот же. Наша функция может вычислить значение интегралов Френели даже на бесконечности (np.inf). На рисунке 1.9 точка, соответствующая бесконечности помечена крестом, а спираль клотоиды наматывает витки и стремится к этой точке. Кстати, кривизна клотоиды на бесконечности тоже бесконечна. У одного из авторов есть коллега в Германии, который в настоящее время работает на предприятии, изготавливающем стрелочные переводы для сверхскоростных поездов в Китае. Так вот, эти стрелки встраиваются в переходные участки в виде спиралей Эйлера (клотоид). Попробуем повторить манипуляции, которые мы проделали с кубической параболой. Координаты клотоиды мы вычислим по формуле (1.5), как функции параметра 𝑡𝑐 . xc, yc = clotoid(tc) # клотоида Производная по горизонтальной координате определяется как 𝑡𝑔( 𝑡𝑐 ). Кривизна клотоиды равна 2𝑡𝑐 . Это можно установить, с помощью формулы для кривизны кривой, заданной параметрически: |𝑥 ′′ (𝑡) ∙ 𝑦 ′ (𝑡) − 𝑦 ′′ (𝑡) ∙ 𝑥 ′ (𝑡)| 𝐾(𝑡) = . (1.7) 3 (√𝑥 ′ (𝑡)2 + 𝑦 ′ (𝑡)2 ) В примере e01_03.py эти выкладки выполнены в символической форме с помощью библиотеки SymPy. Поворотный круг зададим также параметрически, как функцию параметра 𝑡𝑟 : 𝑥𝑟 = 𝑥0 + 𝑅 sin 𝑡𝑟 , 𝑦𝑟 = 𝑦0 − 𝑅 cos 𝑡𝑟 , где 𝑥0 , 𝑦0 — координаты центра поворотного круга. Для такой параметризации производная по горизонтальной координате будет равна tg( 𝑡𝑟 ). Для данной задачи при заданном радиусе поворотного круга нам требуется определить точку пересечения клотоиды и поворотного круга, т. е. 𝑡𝑐 , 𝑡𝑟 , а также его центр 𝑥0 , 𝑦0 , Нам нужна система из четырёх алгебраических уравнений: равенство горизонтальных и вертикальных координат двух кривых в точке пересечения, равенство производных и кривизны. Как и ранее, напишем функцию rh, вычисляющую невязки системы уравнений: def rh(t, R=0.5): ''' Функция, передаваемая библиотечной функции root для решения системы алгебраических уравнений t - кортеж параметров, включающий в себя: 29
tc - значение параметра клотоиды в точке пересечения, tr - значение параметра окружности в точке пересечения, x0, y0 - координаты центра круга ''' tc, tr, x0, y0 = t # распаковываем параметры # координаты кривых xc, yc = clotoid(tc) # клотоида xr, yr = x0 + R*np.sin(tr), y0 - R*np.cos(tr) # окружность # производные xcs, ycs = np.cos(tc**2), np.sin(tc**2) xrs, yrs = R*np.cos(tr), R*np.sin(tr) dyc_dxc = ycs/xcs dyr_dxr = yrs/xrs # кривизна kc = 2*tc kr = 1/R # уравнения return [ xc - xr, # горизонтальная координата yc - yr, # вертикальная координата dyc_dxc - dyr_dxr, # производные kc - kr # кривизна ] Мы совместим решение и визуализацию решения системы в функции draw_clotoid_circle: def draw_clotoid_circle(R=1., t0 = (0.1, 0.1, 0., 0.), c_color='b', r_color='r', tmax= 2*np.pi, ms=10, figsize = (4,4), n=1000, axis=True, lims=(-0.5, 1.5) ): ''' Вычисление и визуализация переходного участка с клотоиды на круг Параметры: t0 - кортеж начальных значений параметров, включающий в себя: tc - значение параметра клотоиды в точке пересечения, tr - значение параметра окружности в точке пересечения, x0, y0 - координаты центра круга, c_color - цвет для отображения клотоиды, r_color - цвет для отображения окружности, tmax - максимальное значение параметра t, ms - размер маркеров, figsize - размер картинки, n - число разбиений кривых на участки, axis - признак отображения осей координат и оцифровки Функция возвращает объект картинки ''' slv = root(rh, t0, args=(R,)) x0, y0 = 0., 0. 30
tc, t = xc, xr, tr, x0, y0 = slv.x np.linspace(0, tmax, n) yc = clotoid(t) yr = circle(t, R=R, x0=x0, y0=y0) # рисуем fig = plt.figure(figsize=figsize) # круг и центр plt.plot(xr, yr, r_color+'-') plt.plot(x0, y0, r_color+'+', ms=ms) if slv.success: # клотоида plt.plot(xc, yc, c_color+'-') xi, yi = clotoid(np.inf) plt.plot(xi, yi, c_color+'+', ms=ms) # точка пересечения xrc, yrc = circle(tr, R=R, x0=x0, y0=y0) plt.plot(xrc, yrc, r_color+'+', ms=ms) else: # рисуем только клотоиду plt.plot(xc, yc, c_color+'-') xi, yi = clotoid(np.inf) plt.plot(xi, yi, c_color+'+', ms=ms) if lims: # задаем границы по осям координат plt.xlim(*lims) plt.ylim(*lims) if not axis: plt.axis('off') else: plt.grid() plt.title(f'R={R:5.3f}') # заголовок return fig Отметим, что мы зафиксировали масштабы по осям координат, чтобы подавить поведение matplotlib, которая по умолчанию задаёт масштабы самостоятельно. На рисунке 1.10 представлены переходные участки для четырёх значений радиуса поворотного круга. Достаточно просто на основе уже сделанного собрать анимацию (см. Приложение 6. Анимация). Сборку и отображение мы проведём в трёх ячейках блокнота. В первой ячейке необходимо импортировать библиотеку gif и написать функцию для отображения кадра анимации: import gif @gif.frame def frame(i): rmin, rmax, step = 0.05, 1., 0.01 rr = rmax - rmin n = rr // step +1 R = rmin + i * rr/n return draw_clotoid_circle(R=R) 31
Рис. 1.10 Переходные участки с клотоидой для различных радиусов поворотного круга 𝑅 Обязательно необходимо выполнить декорирование функции, отображающей кадр. Во второй ячейке осуществляется сборка и сохранение анимации: frames = [frame(i) for i in range(145)] gif.save(frames, 'clotoid.gif', duration=200) Наконец, в третьей ячейке мы с помощью единственной строки кода встраиваем анимацию в блокнот marimo: mo.image('clotoid.gif', width='80%').center() До сих пор мы работали с поворотным кругом. Попытаемся построить переходную кривую для овального железнодорожного полигона. Самый известный овал — это эллипс. Именно его, а не просто овал рисует, к примеру, графический редактор Paint (см. главу 1 в [2]). Давайте построим в эллипс, опираясь на несколько изменённое его определение. Эллипс — это геометрическое место точек плоскости, у которых среднее арифметическое расстояний до двух фокусов постоянно и равно заданному значению. Если в этом определении словосочетание «среднее арифметическое» 32
заменить на слово «сумма» (удвоенное среднее арифметическое), то мы вернёмся к старому классическому определению эллипса. Если же среднее арифметическое заменить на среднее геометрическое (квадратный корень из произведения двух чисел), то мы получим уже не эллипс, а так называемый овал Кассини. На Python функции, отражающие эти правила, приведены ниже. def mean(a,b): '''Среднее арифметическое a, b''' return (a + b) / 2. def gmean(a, b): '''Среднее геометрическое a, b''' return np.sqrt(a * b) def ellipse(x, y, a, c): '''Эллипс как геометрическое место точек''' return mean(np.sqrt((x - c)**2 + y**2), np.sqrt((x + c)**2 + y**2)) — a def cassini(x, y, a, c): '''Эллипс как геометрическое место точек''' return gmean(np.sqrt((x - c)**2 + y**2), np.sqrt((x + c)**2 + y**2)) - a Функциям ellipse и cassini передаётся одинаковый набор параметров: 𝑥, 𝑦 — координаты точки и 𝑎 > 0, 𝑐, последний параметр представляет собой положение одного из фокусов, расположенных на горизонтальной оси координат. Для фиксированных 𝑎, 𝑐 нам требуется определить множество точек, для которых функции возвращают 0. Мы могли бы, как и ранее, фиксировать значение 𝑥 или 𝑦 и решать уравнение относительной другой переменной, но мы воспользуемся графическим способом решения уравнений, рисуя на контурной карте нулевую линию уровня. Сделать это нам позволяет функция contour библиотеки matplotlib. Этот метод не отличается точностью, но позволяет просто получить наглядные результаты. Реализован он функцией draw_ellipse_oval_cassini. В примере e_01_04.py приводится её исходный текст с пояснениями, а на рисунке 1.11 изображены варианты кривых для различных значений параметра 𝑎 и с = 1. При одинаковых значениях параметра c (расстояния от начала координат до фокусов) и при разных значениях параметра a (значение среднего арифметического или среднего геометрического) эллипс (если отображается, то зелёной линией) всегда находится внутри овала Кассини (розовая линия). При уменьшении 𝑎 кривые сжимаются по вертикали, при 𝑎 = √2 овал Кассини превращается в овал Толстого, при дальнейшем уменьшении 𝑎 у овала Кассини появляется «талия», при 𝑎 = 1 эллипс вырождается в прямую, а овал Кассини превращается в две отдельные фигуры, имеющие общую точку в начале координат. Здесь наш инструмент для рисования контурных карт, используемый не по назначению, для решения уравнений, даёт сбой — прямая линия, представляющая собой вырожденный эллипс, не отображается, не видно и общей точки двух частей овала Кассини. При 𝑎 < 1 эллипс не отображается, т. к. отсутствует решение в действительных числах, а овал Кассини разделяется на две отдельные замкнутые кривые. 33
Рис. 1.11 Эллипсы (зелёный цвет) и овалы Кассини (розовый цвет) при различных значениях параметра 𝑎 34
Эллипс и овал Кассини объединяет следующее. Когда-то учёные-астрономы спорили о том, по каким траекториям движутся спутники планет — по эллипсу или по овалу Кассини (см. рис. 1.11). Окружность — это частный случай и эллипса (c = 0), и овала Кассини. Но в конце концов было доказано, что небесные тела движутся по эллипсу. Забегая вперёд, скажем, что спутник не может лететь с нулевой кривизной орбиты в двух точках (см. рис. 1.16). 1.3. Кинематографическое отступление Приведём в качестве отвлеченного примера сцену из советского художественного фильма «Сердца четырёх» (рис. 1.12), когда два героя фильма (два сердца из четырёх) обсуждают среднее арифметическое и среднее геометрическое. Рис. 1.12 Кадр из фильма «Сердца четырёх» Героиня фильма Галина Мурашова (доцент математики МГУ, её играет Валентина Серова — звезда довоенного советского кинематографа) полагает, что она среднее арифметическое, а её младшая сестра Шурочка (легкомысленная студентка филфака — актриса Людмила Целиковская, ещё одна звезда) — это среднее геометрическое. Одна сцена из фильма (рис. 1.12) посвящена доказательству того, что среднее арифметическое (Галина) больше среднего геометрического (Шурочки). Отступление от отступления На рисунке 1.13 отображена авторская модель (численное решение системы восьми обыкновенных дифференциальных уравнений) движения двух планет по эллиптическим орбитам, слегка искривлённым в момент приближения спутников к планетам. Планеты со своими спутниками сближаются (тут их эллиптические траектории могут существенно исказиться), обмениваются спутниками и летят дальше. Вот вам «космическое» изложение сюжета фильма «Сердца четырёх»! 35
Рис. 1.13 Траектории полёта двух планет со спутниками На рисунке 1.14 приведён заключительный скриншот, как сейчас говорят, сцены из фильма «Сердца четырёх», а под ним одно из правильных (квадрат числа не меньше самого числа) доказательств с отличными от наших буквенными обозначениями. Наши обозначения более правильны потому, что здесь оперируют не целыми (m, n), а нецелыми положительными числами. Рис. 1.14 Кадр из фильма «Сердца четырёх» 36
Справедливости ради, отметим, что и решение мелом на доске, показанное на рисунке 1.14, и наши утверждения, приведённые только что, не совсем верны: нужно писать и говорить «больше или равно», а не просто «больше». На рисунке 1.11 замкнутые кривые в двух местах сходятся: две наши средние величины равны между собой, когда два сравниваемых числа равны. 1.4. Овал Толстого Среди овалов Кассини есть два именных. Это лемниската Бернулли в виде символа бесконечности и овал Толстого — овал Кассини при a= √2. Первая замкнутая кривая хорошо известна, её иногда также используют в качестве переходной кривой. Вторая же кривая только-только пытается занять своё место в «пантеоне именных объектов математики» [3, 4]. У овала Толстого нет так называемой талии. Кривизна овала Кассини при x = 0 будет нулевой (см. рис. 1.16), если используется явная функция, полученная в результате решения уравнения овала Кассини (неявная функция) относительно неизвестной величины y. Аналитическое решение находится с помощью библиотеки символьных вычислений Python SymPy (пример e01_05.py): import import import import marimo as mo numpy as np matplotlib.pyplot as plt sympy as smp # инициализируем "красивый" вывод формул smp.init_printing() # объявляем символьные переменные x, y, a, c = smp.symbols('x, y, a, c') def cassini(x, y, a, c): ''' Овал Кассини''' f = smp.sqrt(smp.sqrt((x-c)**2 + y**2)*\ smp.sqrt((x+c)**2 + y**2)) - a eq = smp.Eq(f, 0) return smp.solve(eq, y) cassini(x, y, a, c) # вызов функции SymPy предлагает нам список из четырёх решений: 0: − √−𝑥 2 − 𝑐 2 − √𝑎4 + 4𝑐 2 𝑥 2 1: √−𝑥 2 − 𝑐 2 − √𝑎4 + 4𝑐 2 𝑥 2 2: − √−𝑥 2 − 𝑐 2 + √𝑎4 + 4𝑐 2 𝑥 2 3: − √−𝑥 2 − 𝑐 2 + √𝑎4 + 4𝑐 2 𝑥 2 37
Первые два решения комплексные, а вот с двумя последними можно работать, но перед этим подставим в функцию численные значения, чтобы в дальнейшем работать с лемнискатой Бернулли и овалом Толстого. lemniscata = cassini(x, y, 1, 1) tolstoi = cassini(x, y, smp.sqrt(2), 1) Нам также понадобятся формулы кривизны, поэтому напишем функцию для её вычисления: def curvature(curve): l = len(curve) result = [] for c in curve: ys = c.diff(x,1) ys2 = c.diff(x, 2) result.append(smp.Abs(ys2)/smp.sqrt(1 + ys**2)**3) return result Теперь можно средствами той же библиотеки SymPy нарисовать лемнискату Бернулли и овал Толстого (синим цветом), разместив на том же графике график кривизны (красная линия). def curve_plot(curve=tolstoi, curvature=tolstoi_curvature, fn=''): p1 =smp.plot(curve[2], (x, -5, 5), line_color='b', show=False,aspect_ratio=(1,1)) p2 =smp.plot(curve[3], (x, -5, 5), line_color='b', show=False,aspect_ratio=(1,1)) p3 = smp.plot(curvature[2], (x, -1.5, 1.5), line_color='r', show=False,aspect_ratio=(1,1)) p1.extend(p2) p1.extend(p3) p1.show() Это позволит нам исправить погрешность при рисовании лемнискаты Бернулли с помощью контурной карты (рис. 1.15). Рис. 1.15 Лемниската Бернулли и её кривизна Овал Толстого и его кривизна приведены на рисунке 1.16. 38
Рис. 1.16 Овал Толстого и его кривизна, нарисованные средствами библиотеки символьных вычислений SymPy Кривизна овала Толстого (один из овалов Кассини; см. рис. 1.16), в отличие от кривизны кубической параболы (рис. 1.1) или спирали Эйлера (клотоиды; см. рис. 1.11), плавно возрастает от нулевой точки. Производная кривизны овала Толстого в этой точке равна нулю, и такой расчёт при желании можно сделать. Следовательно, при въезде на такой «пандус» не будет наблюдаться излома (скачка производной кривизны по пространственной координате. Изображение, показанное на рисунке 1.16, тоже может стать логотипом, но не метрополитена (см. рис. в заголовке главы), а, например, завода, выпускающего подвижной состав для железных дорог. Похожую эмблему имеет автомобиль УАЗ, но там изображён не овал, а окружность и «галочка» в виде чайки (вернее, в виде стилизованной буквы «У» — Ульяновск). У нас же на рисунке 1.16 получился некий овал Толстого. Почему тут упомянут Лев Николаевич Толстой? Читаем у Толстого в «Анне Карениной»: «Скачки должны были происходить на большом четырёхвёрстном эллиптической формы кругу». И ещё: «Над креслом висел овальный, в золотой раме, прекрасно сделанный знаменитым художником портрет Анны». Толстой был артиллерийским офицером, он должен был знать, что такое эллипс, а что такое овал. Но почему же он назвал одну замкнутую кривую эллипсом, а вторую — овалом? Скорее, всё должно быть наоборот: рама картины — это эллипс, а ипподром — это овал, вернее, стадиум, и не простой, а стадиум Толстого. Если овал Толстого разрезать по вертикали пополам, переместить половинки овала влево и вправо, а концы дуг соединить отрезками прямых, то мы получим этот самый стадиум Толстого (рис. 1.17) — самую подходящую замкнутую кривую и для ипподрома, и для стадиона, а главное, для испытательного железнодорожного полигона. Кривизна такого овала меняется плавно (см. рис. 1.16). 39
Рис. 1.17 Стадиум Толстого для различных значений полудлины прямолинейного участка 𝑙 и полувысоты ℎ Рисовать стадиум будем в три приёма. Сначала напишем универсальную функцию NumPy для рисования четвертушки овала Толстого. def tolstoi_numpy(x, h=1): ''' Четвертушка овала Толстого Параметры: x - значение горизонтальной координаты h - полуширина овала Функция возвращает вертикальную координату овала ''' return h*np.sqrt(-x**2 + 2*np.sqrt(x**2+1) - 1) Затем напишем функцию для вычисления верхней половины стадиума. def stadium(x, l=1, h=1): ''' Верхняя половина стадиума, составленного из прямолинейных участков длиной 2*l и полуовалов Кассини высотой 2*h. Параметры x - горизонтальная координата, l - длина половины прямолинейного участка, h - полувысота стадиума. Функция возвращает вертикальную координату стадиума. ''' xx = np.abs(x) return np.where(np.abs(xx)<l, h, tolstoi_numpy(xx-l, h=h)) return y Обратите внимание, что этим функциям в качестве горизонтальной координаты можно передавать как число, так и массив NumPy, что мы и используем при рисовании стадиума. def draw_stadium(l=1, h=1, lw=1, color='b', n=10000, xmax=5): ''' Рисуем стадиум l, h - полудлина и полуширина стадиума, lw - толщина линии, color - цвет линии, 40
n - число разбиений стадиума на участки для рисования, xmax - отрезок, на котором осуществляется рисование ''' x = np.linspace(-xmax, xmax, n) y = stadium(x, l=l, h=h) plt.plot(x, y,ls='-', lw=lw, color=color, label=f"{l=}, {h=}") plt.plot(x, -y,ls='-', lw=lw, color=color) _fig = plt.figure(figsize=(4,3)) draw_stadium(lw=3) draw_stadium(l=0.,lw=3, color='red') draw_stadium(l=0., h=0.5, lw=3, color='lime') plt.legend() _fig Замечание по терминологии Ранее мы отметили, что овал — это выпуклая замкнутая гладкая кривая, которую любая прямая может пересечь не более чем в двух точках. Кривая, удовлетворяющая такому условию, называется строго выпуклой. Выпуклой называется кривая, лежащая по одну сторону от любой касательной к ней, или (эквивалентно) ограничивающая выпуклую область, т. е. такую область, где вместе с любыми двумя точками области в ней лежит отрезок, их соединяющий. Например, эллипс и овал — строго выпуклые кривые, а Stadium — выпуклая, но не строго выпуклая кривая. Кстати, можно напридумывать огромное множество овалов, используя понятие среднего Колмогорова [6, 7]. В 2028 г. будет отмечаться 200-летие со дня рождения Льва Николаевича Толстого10. К этой дате, говоря бюрократическим языком, будет приурочено много юбилейных мероприятий. Пофантазируем, какие можно было бы предложить проекты. Например, было бы здорово к этому юбилею построить ипподром или стадион имени Льва Толстого с беговой дорожкой в виде стадиума Толстого. Или аналогичный железнодорожный полигон. Или на улице Льва Толстого в каком-нибудь городе можно было бы построить новую трамвайную линию с поворотами в виде дуги не кубической параболы или спирали Эйлера, а овала Толстого. И всё это сопроводить соответствующим информационным полем о Льве Толстом и математике. Если же говорить о конкретном занятии МИТ11 (Математика — Информатика — Техника), то школьники или студенты могут на нем правильно рассчитать профиль игрушечной железной дороги, затем распечатать на 3D-принтере рельсы со шпалами и пустить по нему заводной паровозик, который будет без боковых толчков ездить по стадиуму Толстого. 10 Есть такой полушутливый тест, связанный с делением людей на технарей и гуманитариев, на физиков и лириков. Технари помнят год рождения Толстого (1828 г.) через число е (2.718281828), а гуманитарии помнят число е через год рождения Толстого. 11 Или МИИТ (Математика — Информатика — Искусство — Техника). 41
При горизонтальном положении мы имеем дело с трамвайными путями или с железной дорогой. При вертикальном положении — это аттракцион «Американские горки»12 с мёртвой петлёй, где также необходимо профилировать рельсы для кабинок (вагончиков) так, чтобы не было сильных толчков. Петлю таких горок можно сделать с профилем овала или стадиума Толстого. На таком участке этих горок, во-первых, при горизонтальном нижнем заезде не будет резких толчков, а во-вторых, кабинки этих горок на верхнем участке стадиума Толстого будут двигаться прямолинейно, замедляя свой ход. Это получится гибрид американских и ныряющих горок. Нужно будет только определить оптимальную длину прямолинейных вставок в овал Толстого, делающих его одноимённым стадиумом. Первый шаг — создать модель мертвой петли. А вообще-то говоря, задача о проектировании железной дороги с её переходными кривыми — это очень сложная инженерная задача. Мы в этом занятии только-только приступили к её решению. В этом можно убедиться, посмотрев дополнительную литературу в конце книги. Задания читателям 1. Рассчитайте координаты одной из двух точек максимума кривизны кубической параболы. 2. Создайте анимацию изменения формы овала Кассини от двух точек-фокусов, через два овала, лемнискату Бернулли, овал с «талией», овал Толстого и далее к окружности (рис. 1.11). 4. Определите габариты овала Толстого (рис. 1.16), если длина этой замкнутой кривой составляет 4 версты (ипподром Толстого). Расчёт длины овала Кассини — это отдельная сложная математическая задача. Попытайтесь определить площадь такого овала. 5. Нарисуйте стадиум средствами SymPy. 6. Рассчитайте и постройте на графике профиль рельсового пути (монорельса) на повороте, состоящего из таких участков: прямая, дуга кубической параболы, дуга окружности, дуга овала Толстого и венчающая поворот ещё одна прямая. 8. Постройте лемнискату Бернулли и её кривизну с помощью численных методов. Попытайтесь запатентовать такой товарный знак. 9. Если вместо среднего арифметического или среднего геометрического использовать среднее гармоническое, то получится овал Кэли (Cayley). Проанализируйте его на предмет использования в качестве переходной кривой железнодорожного пути. 12 В некоторых странах Европы их называют русскими горками. 42
ГЛАВА 2 ЗАНЯТИЕ 4. ВЫЧИСЛИТЕЛЬНЫЙ РЕНЕССАНС: МЕТОД НЬЮТОНА Я <...> решал на черной доске какоето длинное алгебраическое уравнение. В одной руке я держал изорванную мягкую «Алгебру» Франкера, в другой — маленький кусок мела, которым испачкал уже обе руки, лицо и локти... Лев Толстой, «Юность» Основной упрёк, бросаемый обществом в адрес школы — средней и высшей, — это её оторванность от жизни. Мол, в школе и вузе учат одному, а в жизни приходится сталкиваться совсем с другим. Если говорить о математике, то этот упрёк конкретизируется так. В вузе учат аналитическим (точным) методам решениям надуманных уравнений и их систем, а в жизни для решения реальных, а не надуманных уравнений, отображающих реальные физические законы и процессы, приходится в основном применять численные (приближённые) методы решений, о которых в аудиториях если и упоминают, то очень кратко. В культовом итальянском фильме «Амаркорд» (1973 г.; рис. 2.1) учительница объясняет ученику, как нужно решать алгебраическое уравнение с неизвестной величиной x, преобразовав его к виду ax2 + bx + c = 0. Школьнику следует найти численные значения коэффициентов a, b и с, подставить их в известную формулу корней квадратного уравнения с дискриминантом под квадратным корнем и получить ответ. И никак иначе! Вывод формулы решения квадратного уравнения — это отдельная тема на уроках математики. Где-то пытаются вывести подобные формулы для кубического уравнения (формула Кардано) и даже для уравнения четвёртой степени, декларируя при этом, что пятая степень — это за пределами возможностей, некий тупик. Раньше численные методы решения алгебраических уравнений не рассматривались, потому что они были очень трудоёмкими даже при работе с электронным калькулятором. Но в настоящее время в учебных заведениях стала доступна вычислительная техника с современными математическими программами, которые можно использовать не только на уроках информатики, но и на уроках математики для численного решения уравнений и их систем. Более того, преподаватель в аудитории может писать на электронной доске не мелом, а специальным пером, вводя в расчёт живые формулы, по которым можно считать, строить графики, делать анимацию. Мы пока затрагиваем только алгебраические уравнения. При этом наблюдается некий вычислительный ренессанс. Пользователи современных быстро43
действующих компьютеров возвращаются к простым и понятным (старым добрым) алгоритмам, которые раньше применялись редко из-за недостаточных вычислительных возможностей. Рис. 2.1 Кадр из фильма «Амаркорд»: решение квадратного уравнения 2.1. Простой метод Ньютона Как обычно, мы сначала изобретём велосипед, написав простые функции для нахождения нулей функции или решения систем уравнений (нахождения её корней) простейшим и наглядным методом Ньютона, а потом используем для этого индустриальные средства экосистемы Python. Напомним, что ноль функции — это такое значение аргумента, при котором функция равна нулю, а её график пересекает ось абсцисс. Корень же системы уравнений — это значение неизвестных, превращающих уравнения в тождества. Если же оперировать двумя уравнениями, преобразовав их в функции переносом правых частей в левые, то корень такой системы — это координаты точки пересечения графиков функций. При решении уравнений и систем всегда необходимо иметь в виду два вопроса: во-первых, имеет ли уравнение (система уравнений) решение, а во-вторых, сколько решений имеется. Поясним это на простом примере, рассмотрев уравнениям 𝑥 2 + 1 = 0. Данное уравнение не имеет решения для действительных чисел, в то же время для комплексных чисел имеет два решения: 𝑥1 = 1𝑗 и 𝑥2 = −1𝑗. Здесь и далее для обозначения мнимой единицы будем использовать символы 1j, как это принято в экосистеме Python. При использовании вычислительных методов, кроме этого, может оказаться, что корень-то есть, а процедура поиска корня может не дать результата т. е. процедура решения в лучшем случае выдаёт сообщение о невозможности его найти, а в худшем — результат, не являющийся нулем функции. Поэтому при решении уравнений желательно предварительно получить информацию о нали44
чие и числе корней, а только потом получить их точные значения. Если же вычислительная процедура не даёт удовлетворительных результатов, то необходимо изменить либо её параметры (начальное приближённое значение корня), либо использовать другой вычислительный метод. После общих рассуждений вернёмся к методу Ньютона. Пусть имеется некоторое начальное приближение для значения корня 𝑥0 , такое, что 𝑓(𝑥0 ) близко к нулю. Разложим функцию 𝑓(𝑥) в ряд Тейлора вблизи точки 𝑥0 , удерживая только первый член разложения, и будем искать следующее приближение из условия, что в этой точке 𝑥1 функция будет равна нулю: 𝑑𝑓(𝑥0 ) (𝑥1 − 𝑥0 ). 𝑓(𝑥1 ) = 𝑓(𝑥0 ) + (2.1) 𝑑𝑥 Из (2.1) легко получить выражение для 𝑥1 : 𝑓(𝑥0 ) 𝑥1 = 𝑥0 − . (2.2) 𝑑𝑓(𝑥0 ) 𝑑𝑥 Это и есть формула метода Ньютона для вычисления корня уравнения 𝑓(𝑥) = 0. Для применения метода Ньютона необходимы два условия: функция 𝑓(𝑥) должна быть дифференцируема по 𝑥 и её производная не равна нулю. Мы получим точное решение уравнения на первой итерации только в том случае, если функция линейная, но нам никто не мешает получить по формуле (2.2) второе и последующие приближения. Этот итерационный процесс продолжается до тех пор, пока либо абсолютное значение функции, либо изменение 𝑥 на итерации станет меньше наперед заданного небольшого числа. А вот число правильных цифр в значении 𝑥 на каждой итерации удваивается [8], если, конечно, процесс уточнения корня сходится (работает). Проиллюстрируем процесс применения метода Ньютона на примере поиска корня для пробной функции 𝑓(𝑥) = 𝑥 3 − 5𝑥 2 − 4,5, её производная равна 𝑓 ′ (𝑥) = 3𝑥 2 − 10𝑥. Программная реализация метода Ньютона представляет собой последовательное применение формулы (2.2) сначала к 𝑥0 , затем к 𝑥1 , 𝑥2 , … и так до тех пор, пока либо абсолютное значение |𝑓(𝑥𝑖 )| не станет меньше наперед заданного числа, либо приращение |𝑥𝑖 − 𝑥𝑖−1 | станет таким, что продолжение процесса станет бессмысленным. Ниже мы приводим исходный текст функции newton (блокнот e02_01.py), в которой дополнительно предусмотрели защиту от почти нулевых значений производной. Мы предусмотрели формирование списка истории процесса поиска корня. def newton(f, fs, x0, args=(), max_iter=200, tol=1e-10): ''' Реализация метода Ньютона для функций одной переменной f - функция вида f(x, *args) fs - производная функции f(x) по x: fs(x, *args) x0 - начальное приближение args - дополнительные переменные, передаваемые функциям f, fs 45
max_iter - максимальное число итераций, после которого процесс останавливается tol - число, используемое для остановки итерационного процесса Функция возвращает: x1 - значение корня функции iter - число итераций f1 - значение функции f(x1, *args) success - признак успешного завершения процесса history - последовательность приближенных значений корня, функции и производной на каждой итерации ''' # значения функции и производной для начального приближения f0, df = f(x0, *args), fs(x0, *args) # инициализируем историю history = [(0, x0, f0, df)] success = False # признак успешного завершения for iter in range(1, max_iter): # невозможность продолжения процесса if abs(df)<tol: raise ValueError(f"Метод Ньютона. Абсолютное значения производной меньше {tol:6.3e}") x1 = x0 - f0/df # новые значения функции и производной f1, df1 = f(x1, *args), fs(x1, *args) # дополняем историю history.append((iter, x1, f1, df1)) # проверка условий успешного завершения процесса if abs(x1 - x0) <= tol or abs(f1)<tol: success = True return x1, iter, f(x1, *args), success, history x0, f0, df = x1, f1, df1 # подготавливаем следующую итерацию return x1, iter, f(x1, *args), success, history В качестве пробной функции мы используем: # Пробная функция f = lambda x: x**3 - 5*x**2 - 4.5 # Производная пробной функции fs = lambda x: 3*x**2 - 10*x , заодно вычислив её производную. Обратите внимание на параметр args, передаваемый функции. Он позволяет передавать функции дополнительные параметры, избавляя от необходимости переписывать библиотечную функцию, если у функции, описывающей уравнение, не один, а несколько параметров. Это приём широко используется в экосистеме Python. 46
На рисунке 2.2. представлены графики этой функции и её производной. На рисунке пунктирными линиями отмечены нулевые уровни для функции и производной. Давайте посмотрим, как зависит результат поиска корня от выбора начального приближения. Если начать с начального приближения 𝑥0 = −2, то для получения результата с допустимой погрешностью 𝑡𝑜𝑙 = 1𝑒 − 10 достаточно 16 итераций: _r = newton(f, fs, -2.0) print(f"x={_r[0]}, f(x)={_r[2]}, iter={_r[1]}, success={_r[3]}") Результат: x=5.168457596010546, f(x)=2.842170943040401e-14, iter=16, success=True Если же задать более близкое к корню начальное приближение, то получим: _r = newton(f, fs, 5.0) print(f"x={_r[0]}, f(x)={_r[2]}, iter={_r[1]}, success={_r[3]}") Результат: x=5.168457596010545, f(x)=0.0, iter=4, success=True Число необходимых итераций сократилось с 16 до 4. Если же для этого начального приближения увеличить допустимую погрешность до 1. 𝑒 − 6, то число итераций уменьшится до трёх. _r = newton(f, fs, 5.0, tol=1e-6) print(f"x={_r[0]}, f(x)={_r[2]}, iter={_r[1]}, success={_r[3]}") Результат: x=5.168457596892583, f(x)=2.509776209080883e-08, iter=3, success=True Рис. 2.2 Графики пробной функции и её производной 47
Это показывает, что на заключительном этапе процесса метод Ньютона сходится быстро. Встроенная история процесса позволяет построить зависимость погрешностей по функции и аргументу (рис. 2.3). На этом рисунке видно, что, начиная с 11-й итерации, погрешности начинают резко уменьшаться даже при задании логарифмической шкалы на графике. Метод Ньютона достаточно наглядный. На рисунке 2.4 изображены начальные и заключительные итерации процесса. Достаточно для начального приближения (𝑥0 ) вычислить функцию (точка 0), провести касательную в этой точке до пересечения с горизонтальной пунктирной синей линией, это и будет следующее приближение (𝑥1 ), после чего последовательность действий повторяется. На рисунке 2.4а приведены первые четыре итерации процесса. На рисунке 2.4б приводятся заключительные итерации процесса. Обратите внимание на существенно различающиеся масштабы на рисунках. Мы специально выбрали в качестве пробной кубическую функцию с «горбом» (см. рис. 2.2) — с тем, чтобы показать, что последовательные приближения не сходятся к корню монотонно, их поведение достаточно сложно и зависит от вида функции. Рис. 2.3 Зависимости погрешностей от номера итерации Альтернативное название метода Ньютона — это метод касательных. Исаак Ньютон (1642–1727) вместе с Готфридом Лейбницем (1646–1716) стоял у истоков дифференциального исчисления13 с его производной, касательной. 13 Спор Ньютона и Лейбница о приоритете. [Электронный ресурс]. — URL: https://руни.рф/ Спор_Ньютона_и_Лейбница_о_приоритете (дата обращения 23.04.2026) . 48
Рис. 2.4 Геометрическая интерпретация метода Ньютона: а — первые четыре итерации; б — заключительные итерации процесса. На рисунке 2.5 можно увидеть график изменения числа итераций в зависимости от значения начального приближения. Мы ограничились диапазоном начальных приближений от –20 до +20, но его можно расширить или сузить. Выполнить эти действия можно с помощью простого скрипта: _xlim = -20, 20 _n = 2000 _x = np.linspace(_xlim[0], _xlim[1], _n) _miniters, _maxiters = 10000, -1 _fig = plt.figure(figsize=(12, 4)) for _ in range(_n): 49
try: _r = newton(f, fs, _x[_]) if _r[3]: # успешное завершение _iters = _r[1] _miniters, _maxiters = (min(_miniters, _iters), max(_maxiters, _iters)) plt.plot(_x[_], _iters, 'bo', ms=2) except ValueError: ... plt.yscale('log') plt.grid() plt.xlabel('Начальное приближение') plt.ylabel('Число итераций') print(f"{_miniters=}, {_maxiters=}") _fig Кстати, здесь нам понадобились защитные «приспособления», реализованные в функции newton: в тех случаях, когда корень не может быть вычислен, на графике осуществляется пропуск для данного значения начального приближения. Рис. 2.5 График изменения числа итераций (метод Ньютона) в зависимости от значения начального приближения Из графика на рисунке 2.5 видно, что «горб» на кубической параболе резко увеличивает число итераций. Максимальное число итераций равно 166, а минимальное трём при числе разбиений отрезка начальных приближений 2000. Около искомого корня число итераций резко уменьшается. Слева от значения корня поведение числа итераций хаотическое, а справа число итераций монотонно увеличивается, по крайней мере, если задавать значение начального приближения до 100. Функция, у которой ищется нуль, может быть такая сложная, что взятие её производной выльется в отдельную проблему. Поэтому можно пойти двумя путями, первый из которых сводится к вычислению производной численно. Опять разложим функцию 𝑓(𝑥) в ряд Тейлора: 𝑑𝑓(𝑥) 𝑑 2 𝑓(𝑥) (𝑥 + ℎ) + (𝑥 + ℎ)2 + ⋯, 𝑓(𝑥 + ℎ) = 𝑓(𝑥) + 𝑑𝑥 𝑑𝑥 2 (2.3) 𝑑𝑓(𝑥) 𝑑 2 𝑓(𝑥) 2 (𝑥 − ℎ) + (𝑥 − ℎ) + ⋯. 𝑓(𝑥 − ℎ) = 𝑓(𝑥) + 𝑑𝑥 𝑑𝑥 2 50
Здесь ℎ > 0 — небольшое число. Вычтем второе выражение в (2.3) из первого: 𝑑𝑓(𝑥) 𝑑 2 𝑓(𝑥) 2 𝑓(𝑥 + ℎ) − 𝑓(𝑥 − ℎ) = 2 ℎ+4 ℎ +⋯ (2.4) 𝑑𝑥 𝑑𝑥 2 Если вторая производная 𝑓(𝑥) существует и конечна, то можно вывести формулу для приближённого вычисления первой производной функции: 𝑑𝑓(𝑥) 𝑓(𝑥 + ℎ) − 𝑓(𝑥 − ℎ) ≈ + 𝑂(ℎ2 ), (2.5) 𝑑𝑥 2ℎ где 𝑂(ℎ2 ) — погрешность вычисления производной; если ℎ уменьшится в десять раз, то погрешность — в сто раз. Это позволяет переписать функцию newton, убрав из заголовка fs — производную: def newton2(f, x0, args=(), max_iter=200, tol=1e-10, h=1e-4): def fs(x, *args, h=h): '''Вычисление производной''' return (f(x+h, *args) - f(x-h, *args))/(2*h) … Отличается функция newton2 от newton только наличием внутренней функции, вычисляющей приближённое значение производной, и параметром h — шагом численного дифференцирования. Естественно, результат мы получим тот же: 16 итераций при начальном приближении 𝑥0 = −2. Данная функция позволяет проделать интересный эксперимент — попытаться найти корень слегка зашумленной функции: def fn(x, delta=1e-3): size = x.shape[0] if isinstance(x, np.ndarray) else 1 return f(x)+ np.random.normal(0,delta, size=size) К значению пробной функции добавляется нормально распределённая величина с нулевым математическим ожиданием и среднеквадратичным отклонением delta (рис. 2.6). Рис. 2.6 Зашумленная пробная функция 51
Интересно, что при ℎ = 1𝑒 − 4, 𝑡𝑜𝑙 = 1𝑒 − 6, среднеквадратичное отклонение delta практически не влияет на результат вплоть до уровня 5𝑒 − 4, а вот при возрастании шума число необходимых итераций резко возрастает, и для большинства случаев процесс перестаёт завершаться успехом. Мы провели статистическое моделирование процесса поиска корня для зашумленной функции fn, на рисунке 2.7 приводится две гистограммы распределения числа итераций (мы установили максимальное число итераций равное 500) для delta=1e-3 и delta=2.5e-3 (рис. 2.7). Рис. 2.7 Статистическое моделирование процесса поиска корня зашумленной функции методом Ньютона: а — delta=1e-3; б — delta=2.5e-3. Если на рисунке 2.7а большая часть экспериментов завершается успешно, то для второго случая только 20% экспериментов завершается успешно до 100-й итерации. Исходные тексты скриптов для статистического моделирования имеются в блокноте marimo e02_01.py. Другим подходом является замена касательной на секущую, когда для начального приближения задаётся не одна, а две точки 𝑥0 , 𝑥1 . Если искомый нуль находится между двумя этими точками, то поиск нуля будет в чем-то походить на ещё один широко используемый метод численного поиска нуля функции одного аргумента — на метод половинного деления. В методе секущих в формуле (2.2) вычислим производную функции через её значения в точках 𝑥0 , 𝑥1 , преобразовав (2.2) к следующему виду: 𝑥1 − 𝑥0 𝑥2 = 𝑥0 − 𝑓(𝑥0 ) ∙ . (2.6) 𝑓(𝑥1 ) − 𝑓(𝑥0 ) На рисунке 2.8 показано, как работает этот метод на примере функции 𝑦(𝑥) = 𝑥 2 − 1. Чёрными цифрами над кривой помечены номера итераций для точек 𝑥0 , синими цифрами под кривой — номера итераций для точки 𝑥1 , наконец, красными цифрами — точки пересечения секущей с линией 𝑓(𝑥) = 0. 52
Рис. 2.8 Поиск корня функции 𝒔𝒒𝒖𝒂𝒓𝒆(𝒙) = 𝒙𝟐 − 𝟏 методом секущих Метод секущих реализован функцией secant по аналогии с функцией newton: def secant(f, x0, x1, args=(), max_iter=500, tol=1e-10): # значения функции и производной для начального приближения f0, f1 = f(x0, *args), f(x1, *args) # инициализируем историю history = [(0, x0, x1,f0, f1)] # Проверяем, лежит ли 0 на отрезке [x0, x1] if f0*f1>0: raise ValueError('Функция secant. Некорректный выбор начального приближения') success = False # признак успешного завершения for iter in range(1, max_iter): # невозможность продолжения процесса if abs(f1 - f0) < tol: raise ValueError(f"Функция secant. функции на шаге {iter} меньше {tol:6.3e}") x2 = x1 - f1*(x1 - x0)/(f1 - f0) # новые значения функции и производной f2 = f(x2, *args) Приращение # дополняем историю history.append((iter, x1, x2, f1, f2)) # проверка условий успешного завершения процесса if abs(x2 - x1) <= tol or max(abs(f1), abs(f2))<tol: success = True return x2, iter, f2, success, history 53
# подготавливаем следующую итерацию x0, x1, f0, f1 = x1, x2, f1, f2 return x2, iter, f2, success, history Мы потребовали (и проверяем это), чтобы на начальном отрезке [𝑥0 , 𝑥1 ] имелся корень функции. Вернёмся к рассмотренному выше примеру пробной функции: # Пробная функция f = lambda x: x**3 - 5*x**2 - 4.5 x2, iter, f2, success, history = secant(f, -2, 5.5, tol=1e-10) print(f"{x2=}, {iter=}, {f2=}, {success=}") Результат: x2=5.168457596010546, iter=9, f2=2.842170943040401e-14, success=True Удачный выбор начального приближения для метода секущих сократил число необходимых итераций: 9 против 16 для метода Ньютона. На рисунке 2.9 приводится график зависимости числа итераций, необходимых для вычисления корня, от начального приближения. Рис. 2.9 Зависимость числа итераций вычисления корна пробной функции методом секущих от начального приближения 𝒙𝟎 Следует отметить, что графики для методов секущих и Ньютона существенно отличаются, а решения для 𝑥0 < 4 отсутствуют. Наша пробная функция — полином третьей степени, у него в действительности не одно, а три решения. Первое из них мы получили различными способами, а два остальных являются комплексными. Мы их можем вычислить с помощью библиотеки символьных вычислений SymPy (блокнот e02_02.py). Для этого необходимо объявить символьную переменную 𝑥 и вызвать функцию для решения алгебраических уравнений solve: import sympy as smp x = smp.symbols('x') smp.solve(x**3 - 5*x**2 - 4.5, x) Мы получили результат: 0: 5.16845759601054 1: −0.0842287980052725−0.929285451996836i 2: −0.0842287980052725+0.929285451996836i Как и ожидалось, корни являются комплексно сопряженными числами. 54
2.2. Что делать с системами уравнений? До сих пор мы искали корень одной функции методами Ньютона и секущих, которые мы реализовали своими руками. Естественно, можно распространить метод Ньютона на системы алгебраических уравнений, но для этого нам понадобятся линейная алгебра и частные производные. Пусть нам нужно решить систему из 𝑛 алгебраических уравнений c 𝑛 неизвестными: 𝑓1 (𝑥1 , 𝑥2 , … , 𝑥𝑛 ) = 0, 𝑓2 (𝑥1 , 𝑥2 , … , 𝑥𝑛 ) = 0, … (2.7) 𝑓𝑛 (𝑥1 , 𝑥2 , … , 𝑥𝑛 ) = 0. В векторном виде систему можно переписать как: 𝒇(𝒙) = 𝟎. (2.8) Полужирным мы будем выделять векторы и матрицы. В окрестности многомерной точки 𝒙𝟎 разложим вектор-функцию 𝒇(𝒙) в ряд Тейлора и приравняем её нулю. 𝒇(𝒙) = 𝒇(𝒙𝟎 ) + 𝑱(𝒙𝟎 )(𝒙 − 𝒙𝟎 ) = 𝟎. (2.9) 𝜕𝑓𝑖 (𝒙) Здесь 𝑱(𝒙𝟎 ) — квадратная матрица частных производных . 𝜕𝑥𝑗 Умножив левую и правую части (2.9) на матрицу 𝑱−𝟏 (𝒙𝟎 ) , обратную к 𝑱(𝒙𝟎 ) слева, получим многомерный аналог формулы (2.2). 𝒙𝒊+𝟏 = 𝒙𝒊 − 𝑱−𝟏 (𝒙𝟎 )𝒇(𝒙𝟎 ). (2.10) Можно было бы реализовать многомерный метод самостоятельно, но для этого потребуется обращать матрицы. Это несложно, но придётся воспользоваться библиотекой, поэтому вспомним, что решением уравнений люди занимаются не первую сотню лет. Так что не будем изобретать ещё один велосипед и обратимся к экосистеме Python, где имеются качественные инструменты для решения задач, поставленных в этих занятиях. В библиотеке SciPy (Scientific Python — научный Python) для этого имеется раздел, посвящённый оптимизации и вычислению корней уравнений. Из неё нам понадобятся две функции: newton — реализация метода Ньютона, перекрывающая возможности, рассмотренные нами выше в этом занятии, root — эта библиотека реализует целый набор методов поиска корней и будет в дальнейшем нашим основным инструментом для решения такого рода задач. Начнём с функции newton14. Функции можно передавать только два параметра: func — функция, для которой необходимо найти корень, и x0 — начальное приближение корня, дополнительно можно задать fprime — функцию, вычисляющую производную, args — кортеж дополнительных параметров и tol = 1.48e-8 — максимальная допустимая погрешность, maxiter = = 50 — максимальное число итераций. Если процесс поиска корня заканчивается не- 14 scipy.optimize.newton. [Электронный ресурс]. — URL: https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.newton.html (дата обращения 23.06.2026). 55
удачно, то по умолчанию возбуждается исключительная ситуация RuntimeError. Давайте попробуем поработать с этой функцией, начав с уравнения 𝑥 2 + 1 = 0, не имеющего решения в действительных числах (блокнот e02_03.py). from scipy.optimize import newton try: _r = newton(lambda x: x**2 +1, 1) print(f"{_r=}") except RuntimeError as _rt: print(f"{_rt}") Результат: Failed to converge after 50 iterations, value is 2.159514728084078. Эта функция прекрасно умеет работать с комплексными числами, поэтому, задав комплексные начальные приближения корня, мы получим оба решения уравнения: try: _r1 = newton(lambda x: x**2 +1, .1j) _r2 = newton(lambda x: x**2 +1, -1.1j) print(f"{_r1=}, {_r2=}") except RuntimeError as _rt: print(f"{_rt}") Результат: _r1=np.complex128(1.7205079890569317e-22+1j), _r2=np.complex128(-9.935183327686274e-20-1j) Вернёмся в нашей пробной функции. Здесь тоже всё в порядке, можно получить все три корня функции, подбирая начальные приближения. f = lambda x : x**3 - 5*x**2 - 4.5 (newton(f, -2, tol=1e-10), newton(f, -2j, tol=1e-10), newton(f, 1j, tol=1e-10) ) Результат: 5.168457596010545 (-0.08422879800527254-0.9292854519968364j) (-0.08422879800527253+0.9292854519968362j) В отличие от нашей реализации, newton из scipy не возвращает истории, но задав дополнительный параметр full_output=True, можно получить дополнительную информацию о поиске корня. В этом случае возвращается кортеж, первым элементом которого является корень уравнения, а далее следует информация о решении. Покажем это на примере нашей пробной функции, print(newton(f, -2, tol=1e-10, full_output=True)) На печать будет выдано: (np.float64(5.168457596010545), converged: True flag: converged function_calls: 39 iterations: 38 56
root: 5.168457596010545 method: secant) Кроме корня (атрибут root), возвращается число итераций (iterations), вызовов функции (function_calls), признак сходимости (converged) и даже использованный метод (method). Так как при вызове newton мы не указали функцию для вычисления производных, то ожидаемо был использован метод секущих. Перейдём теперь к нашей «рабочей лошадке» для поиска корней root15. Ей передаётся практически тот же набор параметров, что и newton: fun — функция, для которой необходимо найти корень, x0 — начальное приближение, args — дополнительные параметры функции, tol — допустимая погрешность, используемая для завершения процесса поиска корня, method — наименование метода из кортежа (‘hybr’, 'lm', 'broyden1', 'broyden2', 'anderson', 'linearmixing', 'diagbroyden', 'krylov', 'dfsane'). Это основные параметры, с остальными параметрами, частично зависящими от применяемого метода, можно познакомиться в документации. Функция возвращает объект OptimizeResult. Для нас будут важны следующие его атрибуты: x — корень функции, если процесс завершился успешно, success — признак успешного завершения процесса, message — сообщение о причине завершения процесса поиска корня. Набор полей возвращаемого объекта зависит от применяемого метода. По умолчанию используется method=‘hybr’ — модифицированный метод сопряжённых направлений16. Мы не будем раскрывать, как работают перечисленные методы, примеры поиска корня с помощью перечисленных методов для двух начальных приближений можно найти в блокноте e02_03.py. Результаты применения перечисленных выше методов для двух начальных приближений сведены в таблицу 2.1. Таблица 2.1 Результаты поиска корней пробной функции для двух начальных приближений с помощью функции root Начальное приближение 𝒙𝟎 = −𝟐 Начальное приближение 𝒙𝟎 = 𝟒 'hybr' − − 'lm' − − 'broyden1' − − 'broyden2' − − 'linearmixing' − − 'diagbroyden' − − 'krylov' − + 'df-sane' + + Метод 15 scipy.optimize.root. [Электронный ресурс]. — URL: https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.root.html (дата обращения 23.04.2026). 16 Метод Пауэлла. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Метод_Пауэлла (дата обращения 23.06.2026). 57
Результат нашего эксперимента показывает, что метод Ньютона не так уж и плох, а результаты существенно зависят от выбора начального приближения. До сих пор мы искали корень единственной функции. Попробуем решать системы уравнений, для чего нарисуем овал Толстого и лемнискату Бернулли. Овал Толстого представляет собой частный случай овала Кассини17, описанного на предыдущем занятии (рис. 1.11). Напишем функции для всех трёх кривых (блокнот 02_03.py). # Овал Кассини oval_cassini = lambda x, y, a, c : (x**2 + y**2)**2 - \ 2*c**2*(x**2 - y**2) - (a**4 - c**4) # Овал Толстого oval_tolstoi = lambda x, y : oval_cassini(x, y, np.sqrt(2), 1) Лемниската Бернулли lemniscata = lambda x, y : oval_cassini(x - .5, y - .5, 2, 2) Это функции двух переменных, их геометрическим представлением являются поверхности в трёхмерном пространстве. Чтобы получить кривые на плоскости, достаточно приравнять эти функции к нулю (или какому-нибудь другому числу), задавать значение одного из параметров и, решая соответствующее уравнение, определять значение другого параметра. Именно такую задачу мы неоднократно решали на этом занятии. Мы пойдём другим путём, вспомнив географию, а именно, контурные карты. Функции двух переменных представляют высоты и впадины рельефа, а на контурные карты наносятся линии уровня рельефа. Нас будет интересовать нулевой уровень, представляющий геометрическое место точек (сочетания координат x, y) корней функции. Если решения нет, то мы ничего не увидим, если же решение имеется, то увидим некоторую геометрическую фигуру. Если же у нас два уравнения и линии уровней для них пересекаются, то перед нами наглядное решение системы двух уравнений с двумя неизвестными. Другой вопрос, что решение получится не слишком точным, зато перед глазами предстанет множество решений системы, а вот точные решения можно получить с помощью рассмотренных выше методов. Начальные приближения берутся из графического решения — контурной карты. За черчение контурных карт в библиотеке matplotlib отвечает функция contour, которой достаточно передать массивы значений, в которых заданы координаты x, y, массив значений функции, набор уровней, для которых нужно нарисовать контурную карту, и набор цветов вычерчивания контуров. Для вычерчивания контурных карт необходимо сначала подготовить массивы данных: _n = 500 _x, _y = np.linspace(-3, 4, _n), np.linspace(-1.5, 2, _n) _X, _Y = np.meshgrid (_x, _y) _X, _Y — двумерные массивы с горизонтальными и вертикальными координатами соответственно. 17 Кассинийский овал [Электронный ресурс]. — URL: https://mathcurve.com/courbes2d.gb/cassini/cassini.shtml (дата обращения 13.08.2025). 58
Значения функций для овала Толстого и лемнискаты Бернулли на сетке вычислим с помощью написанных нами чуть выше универсальных функций: _tolstoi = oval_tolstoi(_X, _Y) _bernulli = lemniscata(_X, _Y) Теперь у нас всё готово, чтобы нарисовать контурную карту для овала Толстого (синим цветом) и лемнискаты Бернулли (красным цветом). Масштабы по осям координат мы задали равными. _fig, _ax = plt.subplots(figsize=(10, 10)) _ax.contour(_X, _Y, _tolstoi, levels=[0], colors=['blue'] ) _ax.contour(_X, _Y, _bernulli, levels=[0], colors=['red'] ) _ax.grid() _ax.set_aspect(1) _ax.set_xlabel('x') _ax.set_xlabel('y') _fig Результат приведён на рисунке 2.10. Рис. 2.10 Контурная карта для решения задачи о пересечении овала Толстого и лемнискаты Бернулли с помощью функции root и метода 'hybr' У нас четыре решения, помеченные крестиками. Получить значения корней с рисунка можно с точностью до двух значащих цифр. Попытаемся получить точные решения с помощью функции root. Можно начать уточнять решения системы двух уравнений (овала и лемнискаты), задавая различные начальные приближения. Начнём с задания функции, описывающей систему уравнений. def _eq(u): return [oval_tolstoi(*u), lemniscata(*u)] Функции передаётся единственный параметр (так предусмотрено библиотечной функцией root), который в функции овала Толстого и лемнискаты нужно распаковывать, кодируя перед параметром звёздочку. Сама функция возвращает 59
список невязок — отклонений результатов вычислений функций овала и лемнискаты от нуля. Мы хотим продемонстрировать, как результат решения зависит от начального приближения, поэтому оформим решение системы уравнений в виде функции solution: def _solution(x0, y0): r = root(_eq, (x0, y0)) plt.plot(x0, y0, 'go', ms=10) if r.success: plt.arrow(x0, y0, r.x[0] - x0, r.x[1] - y0, ls=':', lw=.5) plt.plot(*r.x, 'k+', ms=15) return r.x return None Начальные приближения помечены зелёными кружочками. Если решение найдено, то между начальным приближением и решением чертится тонкая линия. На рисунке 2.10 выполнен поиск корней для 10 начальных приближений. Для двух начальных приближений решение не найдено. Несколько другая картина возникает при использовании метода Ньютона (рис. 2.11). При тех же начальных приближениях сходимость осуществляется только к одному корню, кроме того, решения отсутствуют не для двух, а для пяти начальных приближений. Рис. 2.11 Контурная карта для решения задачи о пересечении овала Толстого и лемнискаты Бернулли с помощью метода Ньютона' Это говорит о том, что при решении систем уравнений необходимо использовать как можно больше косвенной информации для выбора начальных приближений и применять различные методы поиска корней. Рисунки 2.10 и 2.11 наводят на мысль, что у корней имеются области притяжения, которые зависят не только от метода решения, но и его параметров. 60
Построим для рассматриваемой задачи области притяжения [10]. У нас должно быть пять областей: свяжем каждую из них с цветом, чтобы получить наглядную картинку. Мы это сделали с помощью функции root_traction_areas (см. блокнот e02_03.py). Области притяжения корней для методов 'hybr' и 'lm' приведены на рисунке 2.12. Перечисленные методы обнаружили все четыре корня, фиолетовому цвету соответствует отсутствие корня. Рис. 2.12 Области притяжения корней: а — метод 'hybr'; б — метод 'lm. Никто не гарантирует обнаружения всех корней, например, метод 'linearmixing' обнаружил единственный корень. Поэтому работа с вычислительными методами предполагает экспериментирование, не ленитесь проводить вычислительные эксперименты. Области притяжения корней систем уравнений имеют сложную нерегулярную структуру, меняющуюся при увеличении числа разбиений. 2.3. Сердце, пронзённое стрелой. Области притяжения корней Давайте рассмотрим области притяжения корней для более сложного случая. Нарисуем сердце, пронзённое стрелой. Со стрелой всё просто — это отрезок прямой, описываемый уравнением 𝑦 = 𝑎 ∙ 𝑥 + 𝑏, а для сердца подойдёт кривая, называемая кардиоидой: (𝑥 2 + 𝑦 2 − 0,5)3 = 3𝑥 2 𝑦 3 . На рисунке 2.13 приводится изображения кардиоиды (синим) и прямой линии (красным). Функция для визуализации сердца, пронзённого стрелой, имеется в блокноте e02_04.py. В ней в символьном виде мы записываем символьные выражения для стрелы и сердца и, пользуясь линейностью выражения для стрелы, подставляем её в выражение для сердца с помощью метода subs, после чего раскрываем скобки с помощью функции expand. В результате получаем многочлен от одной 61
переменной x. Символьными методами можно найти все его корни, некоторые из которых будут комплексными. Делается это с помощью функции roots, которая возвращает словарь, его ключами являются корни, а значениями — кратности соответствующих корней. Нам предстоит использовать значения корней в численных процедурах, поэтому преобразуем их в массив NumPy x_roots. Далее мы вычисляем значения корней у_roots, используя выражение для стрелы. Функция возвращает массив NumPy c двумя строками и шестью столбцами, в первой строке — координаты корней по горизонтали, а во второй — по вертикали. Это точные значения корней, мы будем использовать этот массив для выяснения номера корня, полученного численными методами. Рис. 2.13 Сердце, пронзённое стрелой def symbolic_heart_arrow(a=-1, b=0.2): x, y, z = smp.symbols('x y z') # символические выражения для стрелы и сердца arrow = a*x + b heart = (x**2 + y**2 - 0.5)**3 - 3*x**2*y**3 # подставляем стрелу в сердце heart = heart.subs(y, arrow) # раскрываем скобки heart = smp.expand(heart) # решаем полиномиальное уравнение # с помощью функции roots heart_roots_dict = smp.roots(heart, x) 62
x_roots = [] for r in heart_roots_dict: # вычисляем значения корня c_root = complex(r.evalf()) miltiplicity = heart_roots_dict[r] for _ in range(miltiplicity): x_roots.append(c_root) x_roots = np.array(x_roots) y_roots = a*x_roots + b return np.vstack([x_roots, y_roots]) Если вызвать функцию с параметрами по умолчанию, что соответствует картинке на рисунке 2.13, мы получим шесть корней, только два из которых являются действительными: symbolic_heart_arrow() Результат: array([[-0.87307269+0.j , 0.45898755+0.j , -0.25816857-0.10689075j, -0.25816857+0.10689075j, 0.57771114-0.2347888j , 0.57771114+0.2347888j ], [ 1.07307269+0.j , -0.25898755+0.j , 0.45816857+0.10689075j, 0.45816857-0.10689075j, -0.37771114+0.2347888j , -0.37771114-0.2347888j ]]) Полужирным выделены действительные корни. Напишем функцию, которая сопоставляет с номером корня цвет, для чего сконструируем палитру matplotlib. При этом нам необходимо иметь в виду, что численная процедура по различным причинам не сможет возвратить решения системы уравнений. Этой ситуации мы сопоставим цвет с номером ноль, первому корню — первый цвет и т. д. from matplotlib.colors import ListedColormap # цвета палитры colors = ("black", "lightgray", "blue", "white", "red", "yellow", "lime", "purple", "cyan", "magenta") # создаем собственную палитру из последовательности цветов custom_cmap = ListedColormap(colors) def visialize_areas_of_attractions( root_numbers, figsize=(6, 6), cmap=custom_cmap ): fig = plt.figure(figsize=figsize) # отображаем номера цветов c = plt.imshow( root_numbers[::-1, :], vmin=0, vmax=len(cmap.colors), cmap=cmap) # соответствие между номером корня и цветом # отключение осей координат plt.axis("off") return fig 63
Здесь для отображения картинки мы используем средства библиотеки matplotlib, импортируя функцию для создания палитры и создав последовательность цветов. Отображение областей притяжения корней выполняет функция visialize_areas_of_attractions. Функции передаются: root_numbers — двумерный массив, содержащий номера корней, соответствующих точкам начальных приближений (x0, y0), figsize — размер рисунка, cmap — палитра matplotlib. Собственно изображение выводится функцией imshow, ей передаются двумерный массив номеров корней, минимальный (vmin) и максимальный (vmax) номера корней и палитра (cmap). В imshow вертикальная ось направлена вниз, поэтому мы меняем порядок отображения строк массива root_numbers, после чего отключаем вывод оцифровки на осях координат. Результат показан на рисунке 2.14. Рис. 2.14 Собственная палитра из десяти цветов Палитра сконструирована с избытком, наша система уравнений имеет не более четырёх действительных корней (см. рис. 2.13), и мы будем работать с цветами из нижнего ряда. 64
Функция, описывающая систему уравнений для сердца, пронзённого стрелой, очень проста: def heart_arrow(p, a=-1, b=0.2): xc, yc = p arrow = a * xc + b -yc heart = (xc**2 + yc**2 - 0.5) ** 3 - 3 * xc**2 * yc**3 return arrow, heart Немедленно подставляем её в функцию root для определения значения корня с заданным начальным приближением: _r = root(heart_arrow, (-.9,1), args=(-1, .2), tol=1e-10) _r.success, _r.x Мы получили ожидаемый результат, совпадающий с аналитическим решением: True, [-0.87307269 1.07307269] Функция get_heart_arrow_root предназначена для вычисления номера корня функции f (мы получили его ранее с помощью sympy) для заданного начального приближения (x0, y0) и метода поиска корня method. Полученный корень сравнивается с набором корней полинома croots. Функция возвращает номер корня полинома. Если решения нет, то номеру корня присваивается значение 0. Для проверки близости корней используется значение eps. def get_heart_arrow_root(croots, f=heart_arrow, x0=-.87, y0=1, args=(-1, 0.2), method=None, eps=1e-4): p = (x0, y0) # начальное приближение # выбор метода if method: r = root(f, p, args=args, method=method) else: r = root(f, p, args=args) # корень не найден if not r.success: return 0 x_root =r.x[0] y_root = r.x[1] croot = np.array([x_root, y_root], dtype=np.complex128) # номер корня err = np.max(np.abs(croot - croots.T), axis=1) iroot = np.where(err<eps)[0] iroot = 0 if iroot.size==0 else iroot[0] + 1 return iroot Решение системы уравнений осуществляется функцией root18, которой передаётся необязательный параметр method. Может оказаться, что решение не будет найдено, в этом случае мы считаем, что номер корня равен нулю. Если же решение системы завершилось успехом, мы сравниваем полученное решение с аналитическим, хранимом в массиве croots. Мы вычисляем расстояние между полученным решением и элементами массива croots и возвращаем номер корня. Так как 0 зарезервировано для случая, когда решение не найдено, то к номеру корня в croots добавляем 1. 18 Применение метода Ньютона для решения задачи мы оставляем читателям. 65
Теперь нам нужно собрать всё вместе в функции visualize_roots. def visualize_roots(n=100,f=heart_arrow, a=0.0, b=0.2, method='hybr', eps=1e-4, area=(-1.5,-1.5,1.5, 1.5), figsize=(6,5), fn=''): xmin, ymin, xmax, ymax = area xx = np.linspace(xmin, xmax, n) yy = np.linspace(ymin, ymax, n) field = np.zeros((n, n), dtype=np.uint8) croots = symbolic_heart_arrow(a=a, b=b) for iy in range(n): for ix in range(n): xs, ys = xx[ix], yy[iy] iroot = get_heart_arrow_root(croots, f=f, x0=xs, y0=ys, args=(a, b), method=method, eps=eps) field[iy, ix] = iroot return visialize_areas_of_attractions(field, figsize=fig- size, fn=fn) # тестирование visualize_roots(n=400, a=-1,b=.2, method='lm') Функции придаются следующие параметры: n — число разбиений области, в которой располагаются начальные приближения для поиска решений, по осям координат; f — функция, возвращающая невязки системы алгебраических уравнений, по умолчанию это рассмотренная ранее heart_arrow; a, b — параметры стрелы, method — наименование метода, используемого в функции root при поиске решения системы уравнений (см. описание функции root), eps — число, используемое при определении номеров корней, area — координаты вершин прямоугольника, из которого берутся начальные приближения для решения системы уравнений, figsize — размер рисунка, fn — имя файла для сохранения рисунка. Последовательность действий, выполняемых функцией, достаточно проста. Сначала производится распаковка координат угловых точек прямоугольника (xmin, ymin, xmax, ymax), содержащих начальные приближения решений системы уравнений, задаваемых функцией f. В массивах xx, yy подготавливаются координаты этих точек по осям координат. Далее подготавливается двумерный массив field, в который мы будем записывать номера корней. Номера корней вычисляются с помощью функции get_heart_arrow_root и записываются в массив field. Визуализация областей притяжения решений системы уравнений осуществляется с помощью рассмотренной ранее функции visialize_areas_of_ attractions. Результаты визуализации существенно зависят от применяемого метода. На рисунке 2.15 приводятся результаты визуализации для сердца, пронзённого горизонтальной стрелой, для различных положений стрелы. 66
Для всех рисунков использован метод 'lm'. Птичка, снёсшая яйцо, соответствует стреле на рисунке 2.13, остальные рисунки сделаны для горизонтальной стрелы, пронзающей сердце на разной высоте. Рисунок 2.15б с некоторой долей фантазии можно интерпретировать как клоуна, 2.15в — голову птицы, 2.15а, г — представляет собой нечто сюрреалистическое. Хорошо видно, что для случаев 2.15а–в имеются два решения, а для 2.15г — четыре решения. б а в г Рис. 2.15 Области притяжения корней для сердца, пронзённого стрелой, для различных параметров стрелы: а — a = –1, b= 0,2; б — a = 0, b = 0,25; в — a = 0, b = –0,7; г — a = 0, b = 1. Процедура визуализации при большом числе разбиений занимает достаточно много времени, поэтому построение анимации для горизонтальной стрелы, движущейся снизу вверх, разбито на два этапа. На первом этапе генерируются и сохраняются в папке anim рисунки, а на втором строится gif-анимация. 67
Вот реализация первого этапа: bs=(-0.7, -.65, -0.6,-.55, -.5,-.45, -.4,-.35, -.25, -.2,-.15, -0.1, -0.05, .01,0.05, 0.1,0.15,0.2, .25, 0.3, .35, 0.4,.45,.5,.55,.6,.65,.7,.75, .8,.85, .9, 0.95, 1., 1.05, 1.1, 1.14) def save_images(n=50, bs=bs, method='lm'): for i, b in enumerate(bs): fig = visualize_roots(n=n, b=b, method=method) plt.savefig(f"anim/{i}.png", dpi=300, color="white") print(f"Изображение: {i}, {b=}") face- save_images(n=400) В кортеже bs сохраняются значение b — положения стрелы по вертикальной оси, изображения формируются с помощью рассмотренной ранее функции visualize_roots, сохранение в папке anim осуществляется функцией savefig matplotlib. Анимация строится из 37 изображений на втором этапе. Для этого нам придётся импортировать библиотеку gif и пакет img, входящий в состав библиотеки matplotlib. import gif import matplotlib.image as img @gif.frame def frame(i): im = img.imread(f'anim/{i}.png') fig = plt.figure(figsize=(6,6)) im = im[200:1300, 350:1450] plt.imshow(im) # обрезаем поля изображения plt.title(f"b={bs[i]:6.2f}") plt.axis('off') return fig Выше приведена декорированная функция frame для формирования кадра изображения. В ней осуществляется загрузка очередной ранее сформированной картинки, обрезка её полей и отображение картинки с заголовком со значением b. Сборка и сохранение анимации осуществляется так: frames = [frame(i) for i in range(37)] gif.save(frames, 'anim/anim.gif', duration=100) Вставка анимации в блокнот marimo осуществляется так: mo.image("anim/anim.gif") Один из кадров анимации приводится на рисунке 2.16. В этот раз построение анимации оказалась длительным процессом из-за необходимости решения большого числа систем алгебраических уравнений. У нас это заняло более 20 минут. Имейте в виду, когда будете запускать блокнот. Вернёмся к методу Ньютона и попытаемся связать число итераций, необходимых для вычисления корня уравнения, с начальным приближением. Очевидно, что чем дальше начальное приближение от корня, тем больше итераций нам потребуется для достижения заданной точности. Сделаем это на комплексной плоскости, 68
связав число итераций, необходимых для выполнения условия |𝑥𝑖+1 − 𝑥𝑖 | < 𝑡𝑜𝑙, где 𝑥𝑖 — значение корня, вычисленное на итерации 𝑖, tol — наперед заданное небольшое число, с цветом. Положение начального приближения зададим на комплексной плоскости, а число итераций, необходимых для вычисления корня, свяжем с цветом. Мы немного сократим функцию newton, убрав из неё историю (блокнот e02_05.py): def newton(f, fs, x0, args=(), tol=1e-6, maxiters=100): for iters in range(maxiters+1): f0 = f(x0,*args) x = x0 - f0/fs(x0, *args) if abs(x - x0) < tol or abs(f0) < tol: break x0 = x if iters < maxiters: return x, iters else: return None, maxiters Рис. 2.16 Кадр анимации областей притяжения Теперь просканируем прямоугольную область на комплексной плоскости 𝑧 = 𝑥 + 1𝑗 ∗ 𝑦 с помощью функции iters_distribution: def iters_distribution(f=lambda z:z**3-1, fs=lambda z:3*z**2, n= 400, m=5, args=(), rect=(-1., -1., 1., 1), 69
iters=200, tol=1e-10, cmap='rainbow', figsize=(8,6)): xmin, ymin, xmax, ymax = rect x = np.linspace(xmin, xmax, n) y = np.linspace(ymin, ymax, n) X, Y = np.meshgrid(x, y) Z = X +1j*Y I = np.zeros((n,n), dtype=np.int32) for i in range(n): for j in range(n): root, it = newton(f, fs, x0=Z[i,j], args=args, maxiters=iters, tol=tol) if not (root is None): I[i,j] = it fig = plt.figure(figsize=figsize) cb = plt.imshow(I, cmap=cmap) plt.colorbar(cb) plt.xticks( np.linspace(0, n, m+1), ['{0:4.3f}'.format(xmin+(xmax-xmin)*i/m) for i in range(m+1)]) plt.yticks( np.linspace(0, n, m+1), ['{0:4.3f}'.format(ymin+(ymax-ymin)*i/m) for i in range(m+1)]) plt.xlabel('x') plt.ylabel('y') return fig Ей передаются функция f, её производная fs, n — число разбиений по осям координат прямоугольника на комплексной плоскости с начальными приближениями, m — число разбиений для оцифровки осей координат, args — кортеж дополнительных параметров для передачи функциям f, fs, iters — максимальное число итераций, rect — кортеж с координатами вершин прямоугольника с начальными приближениями на комплексной плоскости, tol — изменение значение корня за итерацию, которое служит критерием остановки итерационного процесса уточнения корня, cmap — палитра matplotlib, используемая для визуализации областей притяжения, figsize — размер рисунка. Первое, что делается в функции, — это распаковка параметра rect для определения координат вершин прямоугольника с начальными приближениями на комплексной плоскости, далее формируется массив начальных приближений корней на комплексной плоскости. В цикле по начальным приближениям решаем уравнение f(z) = 0 методом Ньютона, сохраняем в массиве I число итераций, необходимое для решения уравнения. После этого осуществляется визуализация распределения числа итераций. Соответствие между числом итераций и цветом демонстрируется с помощью цветовой полосы справа от рисунка. На рисунке 2.17 приводится распределения для нескольких уравнений. На рис. 2.17а–2.17г показано, что распределение итераций образуют самоподобные структуры, т. е. в разных масштабах они похожи друг на друга. Кроме того, они отражают структуру уравнения: так, рисунок 2.17а имеет три «лепестка», 2.17г — одиннадцать. 70
а б г Рис. 2.17 Визуализация числа итераций, необходимых для решения степенных уравнений: а — уравнение 𝒛𝟑 − 𝟏 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟏. , −𝟏. , 𝟏. , 𝟏); б — уравнение 𝒛𝟑 − 𝟏 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟏, −𝟎. 𝟐𝟓, −𝟎. 𝟔, 𝟎. 𝟐𝟓); в — уравнение 𝒛𝟑 − 𝟏 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟎. 𝟖𝟒𝟎, 𝟎. 𝟎𝟐𝟐, −𝟎. 𝟖𝟑𝟎, 𝟎. 𝟎𝟑𝟐); г — уравнение 𝒛𝟏𝟏 − 𝟏 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟏. , −𝟏. , 𝟏. , 𝟏). в На рисунке 2.18 приводятся ещё четыре распределения итераций, показывающие, что простые алгебраические уравнения могут порождать красивые узоры. Обратите внимание, что цвета на приведённых здесь рисунках соответствуют разному числу итераций, что определяется решаемым уравнением. При построении рисунков 2.17 и 2.18 мы столкнулись с экзотическим явлением: наш «велосипед» — реализация метода Ньютона — работает существенно быстрее, чем одноименная функция из библиотеки scipy.optimize. Видимо, это связано с тем, что мы решали уравнения на комплексной плоскости. Остается вслед за Мюллером из «Семнадцать мгновений весны» повторить: «Верить в наше время нельзя никому, порой даже самому себе. Мне — можно…» 71
б а в г Рис. 2.18 Узоры, порождаемые уравнениями: а — уравнение 𝒛𝟓 + 𝒛𝟑 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟎. 𝟓, −𝟏. 𝟓, 𝟐. 𝟓, 𝟏. 𝟓), ); б — уравнение 𝒛𝟓+𝟓𝒋 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟏, −𝟏, 𝟏, 𝟏); в — уравнение 𝒛𝟑 − 𝒛 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟏, −𝟏, 𝟏, 𝟏); г — уравнение 𝒛𝟑 − 𝟐𝒛 + 𝟐 = 𝟎, 𝒓𝒆𝒄𝒕 = (−𝟐, −𝟐, 𝟐, 𝟐. ). Подводя итоги, можно сказать, что решение задач — это проведение исследования. Перед тем как, не оглядываясь вокруг, бросаться применять инструменты (аналитические или численные методы), желательно выяснить, если ли решение у задачи, а если есть, сколько этих решений. Только после этого можно «изобретать велосипед» — писать собственную программу или применять «чёрный ящик» — сторонние инструменты [10]. Таких инструментов скорее всего несколько, так что нужно выяснять, когда и какой инструмент лучше подойдёт для данной задачи. Не стоит забывать, что полученные результаты нужно проверить, например, все ли корни уравнения найдены. Иногда «велосипед» может оказаться эффективнее, чем профессиональный «чёрный ящик», для решения конкретной задачи… 72
Задания читателям 1. Напишите программу поиска нуля функции одного аргумента методом половинного деления. 2. Напишите программу поиска корней системы уравнений методом секущих. 3. Поэкспериментируйте с палитрами matplotlib для представления областей притяжения корней. 4. Подберите системы двух алгебраических уравнений, чтобы портрет их корней был занимателен. Задействуйте анимацию. Примеры здесь: [12]. 73
ГЛАВА 3 ЗАНЯТИЯ 5 И 6. В ЭТОМ МИРЕ НЕТ СОВЕРШЕНСТВА Когда я чувствую себя совершенно неспособной читать книгу продуктивно, я начинаю решать алгебраические и тригонометрические задачи, поскольку они не терпят погрешностей внимания и возвращают уму прямой путь. Мария Склодовская-Кюри 3.1. Виды погрешностей При проведении расчётов нам приходится переходить из реального мира в мир нашего воображения, строя математические модели. Скорее всего, такая модель будет не одна, они будут по-разному отражать наш объект, но все они будут давать результаты, которые отличаются от реальных, внося погрешности. Это только первый источник погрешностей. Второй тип погрешностей связан с исходными данными. Всё, что мы подаём на вход нашей модели (моделей), известно с конечной точностью. Если неопределённость входных данных велика, то её необходимо учитывать при проведении расчётов, как об этом писал почти сто лет назад А. Н. Крылов в своих воспоминаниях [14]. Наконец, все расчёты мы делаем с конечной точностью примерно с семью значащими десятичными цифрами при работе с числами с плавающей точкой одинарной точности и пятнадцатью значащими цифрами для удвоенной точности. Это третий источник погрешностей. По-английски погрешность — error, поэтому в заставке занятия приведён рисунок, выполненный ИИ, отражающий процесс распространения погрешностей при выполнении расчётов. В экосистеме Python присутствуют инструменты, позволяющие достаточно просто производить расчёты не только с точностью, заложенной в аппаратное обеспечение компьютера, но с любой наперёд заданной точностью, а также несколькими различными способами отслеживать распространение погрешностей при проведении расчётов. Погрешности первого типа специфичны для решаемых задач и выбранных методов решения. Мы остановимся только на простой задаче о распределении температуры в стержне [15]. Пусть имеется стержень единичной длины. На концах стержня поддерживается температура окружающей среды 𝑢0 , такую же температуру имеет стержень в начальный момент времени 𝑡 = 0. Распределение температуры в стержне описывается уравнением: 𝜕𝑢(𝑡, 𝑥) 𝜕 2 𝑢(𝑡, 𝑥) 𝐶 =𝐷 − 𝛼(𝑢(𝑡, 𝑥) − 𝑢0 ) + 𝑝(𝑡, 𝑥), (3.1) 𝜕𝑡 𝜕𝑥 2 74
где 𝑢(𝑡, 𝑥) — температура в стержне в момент 𝑡, в точке 𝑥, 𝐶 — теплоёмкость единицы длины стержня, 𝐷 — теплопроводность, 𝛼 — теплоотдача с единицы длины стержня, 𝑝(𝑡, 𝑥) — мощность, выделяющаяся в единице длины стержня. На концах стержня и в начальный момент времени заданы граничные и начальные условия: 𝑢(𝑡, 0) = 𝑢0 , 𝑢(𝑡, 1) = 𝑢0 , 𝑢(𝑥, 0) = 𝑢0 , 𝑥 ∈ [0,1]. (3.2) Эта задача имеет точное аналитическое решение, но стоит только чуть-чуть изменить условия, считая, что теплоотдача с поверхности стержня зависит от температуры, то эта возможность теряется, поэтому будем решать задачу (3.1–3.2) конечно-разностным методом, используя для простоты явную разностную схему. Выберем максимальное время интегрирования t𝑚𝑎𝑥 и разобьём прямоугольник [0, 𝑡max] × [0,1] сеткой с шагом 𝑑𝑡 по времени и шагом 𝑑х по пространственной координате. Соответственно, число разбиений по времени равно 𝑛𝑡 = 𝑡max//𝑑𝑡 + 1, а по пространственной координате 𝑛𝑡 = 1//𝑑х + 1. 𝑢[𝑖 + 1, 𝑗] − 𝑢[𝑖, 𝑗] С = 𝑑𝑡 𝑢[𝑖, 𝑗 + 1] − 2𝑢[𝑖, 𝑗] + 𝑢[𝑖, 𝑗 − 1] =𝐷 − 𝛼(𝑢[𝑖, 𝑗] − 𝑢0 ) + 𝑝[𝑖, 𝑗]. (3.3) (𝑑𝑥)2 Здесь индекс 𝑖 относится ко времени, а 𝑗 — к пространственной координате. Нам известно распределение температуры при 𝑖 = 0, а также температура на концах стержня для любого момента времени. Это позволяет рассчитывать распределение температуры для следующего момента времени через значения температуры в предыдущий момент и значения температуры на концах стержня с помощью единственного выражения NumPy (см. блокнот e03_01.py): 𝑢[𝑖 + 1, 1: −1] = 𝐴 ∗ 𝑢[𝑖, 2: ] ∗ 𝐵 ∗ 𝑢[𝑖, 1: −1] +\ 𝐴 ∗ 𝑢[𝑖, ∶ −2] + 𝑃[𝑖, 1: −1], где 𝐴, 𝐵 — скалярные коэффициенты, P — массив NumPy. Функция bar для заданных шагов по времени 𝑑𝑡 и пространственной координате dx вычисляет распределение температуры в стержне. Мы зафиксировали 𝑑𝑡 = 10−6 и изменяли шаг по пространственной координате, как это показано на рисунке 3.1. Рис. 3.1 Распределение температуры в середине стержня для различных шагов по времени 75
Для нас важны несколько фактов. Мы не знаем точного решения 𝑢𝑟 (чаще всего на практике именно так и бывает), а только приближённые решения 𝑢𝑎 , которые зависят от выбранного метода решения, а также шагов по времени и пространственной координате. На рисунке 3.1 слева видно, что при фиксированном шаге разбиения по времени и уменьшении шага по пространственной координате решение меняется, но уже при 𝑑𝑥 ≤ 0,1 изменения незаметны и визуально решения сливаются. Мы будем исходить из того, что при уменьшении шагов приближённое решение практически не будет отличаться от точного 𝑢𝑟 , но наши возможности уменьшения шагов ограничены доступными вычислительными ресурсами. В качестве кандидата мы примем базовое решение 𝑢𝑏 при 𝑑𝑥 = 0,002, 𝑑𝑡 = 2 ∙ 10−7 . Нам нужно будет сравнивать между собой решения, полученные при различных шагах разбиения по осям координат. Фактически нам нужно получить расстояние между двумя решениями. Сделать это можно несколькими различными способами. Мы будем приводить (интерполировать) полученные решения к 𝑢𝑏 , а затем вычислять либо максимальную разность, либо среднее квадратичное отклонение одного от другого. Решение у нас достаточно гладкое, поэтому используем интерполяцию с помощью сплайнов. Благодаря NumPy сделать это достаточно просто. from scipy.interpolate import RectBivariateSpline as RBS def dists(ub, tb, xb, dxs=(0.2, 0.1, 0.05, 0.02, 0.01, 0.005)): dm, ds = [], [] for dx in dxs: u_, t_, x_ = bar(dx=dx) ib = RBS(t_, x_, u_) ui = ib(tb, xb) dmax = np.max(np.abs(ui - ub))/np.max(ub) dstd = np.std(ui - ub)/np.max(ub) dm.append(dmax) ds.append(dstd) return dxs, dm, ds Функции передаётся базовое решение 𝑢𝑏 и соответствующие ему массивы времён 𝑡𝑏 и пространственных координат 𝑥𝑏, а также последовательность шагов по пространственной координате 𝑑𝑥𝑠. Для каждого шага производится решение задачи (3.1–3.2), после чего полученное решение интерполируется на сетку базового решения с помощью сплайновой интерполяции. Мы используем класс scipy.interpolate.RectBivariateSpline, предназначенный для двумерной интерполяции и аппроксимации. Далее вычисляются два относительных расстояния между полученными решениями и базовым. Первое из них — максимальное относительное уклонение одного от другого, а второе — среднее квадратичное отклонение. Эти вычисления могут потребовать пары минут на старом ноутбуке. На рисунке 3.2 приводится зависимость этих расстояний от выбранного шага по пространственной координате. Кстати, этот график позволяет выбрать шаг по времени. Действительно, мы можем измерить температуру в стержне с точностью, не превышающей 0,1 градуса, что соответствует относительному отклонению 0,01 при максимальной температуре 12 градусов. С запасом можно выбрать шаг 𝑑𝑥 ≤ 0,05. 76
Рис. 3.2 Зависимость расстояний от базового решения в зависимости от шага Выбранный нами простой подход обладает тем недостатком, что при уменьшении отношения шага по пространственной координате к шагу по времени решение теряется устойчивость, что продемонстрировано на рисунке 3.1 справа. Если увеличивать шаг по времени, то мы получим хаос. В связи с этим необходимо либо выбирать шаги так, чтобы 𝑑𝑥/𝑑𝑡 > 0,5, либо использовать неявную разностную схему. В этом примере мы продемонстрировали, что выбор метода для получения решения влияет на получаемое решение, что параметры решения (в нашем случае шаги по времени и пространственной координате) необходимо подбирать и согласовывать с имеющимися физическими соображениями (точностью измерения температуры), кроме того, некорректный выбор параметров может препятствовать получению решения, имеющего физический смысл. Таким образом, всё, с чем мы имеем дело при решении задач, вносит погрешности, включая: • исходные данные; • выбранную математическую модель; • метод решения задачи. 3.2. Погрешности при вычислениях с плавающей точкой Не является исключением и компьютер, на котором мы производим вычисления. Мы пытаемся всё демонстрировать на примерах, поэтому перейдём к сюжету тридцатилетней давности. В начале 90-х годов ХХ века Россию захватила гиперинфляция, мы получали заработную плату в миллионах рублей, но нельзя сказать, что жили на неё хорошо. Ещё веселее приходилось бухгалтерам, которым необходимо было сводить балансы с точностью до копейки. Персональные компьютеры в те времена имели длину машинного слова шестнадцать двоичных разрядов, и все вычисления приходилось проводить не аппаратными, а программными средствами. 77
Казалось бы, на современном компьютере свести баланс можно просто и без затей. Целые числа нам не подойдут, мы не сможем работать с копейками. Попробуем использовать для решения задачи числа с плавающей точкой. Их у нас два вида: 32- и 64-разрядные. В блокноте e03_02.py попытаемся сложить одну копейку (сотую долю рубля) c различными значениями, начиная с одного и кончая 1030 рублями, и проверим, когда копейка перестанет учитываться. Сделаем мы это с помощью функции: def get_tolerance_float32(min_value=0.01): min_value = np.float32(min_value) value = np.float32(1.) d10 = np.float32(10.) for i in range(12): new_value = value + min_value if np.abs(value - new_value)<min_value*0.1: return value, value - new_value value *= d10 get_tolerance_float32() Обратите внимание, как с помощью фабричной функции NumPy float32 мы задаём значения переменных именно этого типа. Проверку исчезновения копейки мы проверяем, вычисляя абсолютное значение разности между значением с копейкой и без копейки. Оказывается, что при использовании стандартных четырёхбайтовых чисел с плавающей точкой копейка исчезает, когда мы манипулируем с всего с одним миллионом рублей. Современные процессоры на уровне оборудования19 поддерживают вычисления с восьмибайтовыми числами с плавающей точкой (в терминах NumPy это float64 или просто float). С помощью функции get_tolerance_float64 (блокнот e03_02.py) вычислим, когда в этом случае теряется копейка. Оказывается, это происходит, когда значение достигает огромной величины 1015, т. е. миллиона миллиардов рублей. Казалось бы, задача о бухгалтерских вычислениях решена, но это не так, мы это выясним опытным путём чуть дальше. NumPy позволяет получить информацию о возможностях различных типов чисел с плавающей точкой с помощью вызова функции np.finfo(тип_числа). Эти данные сведены в таблицу 3.1. Таблица 3.1 Информация о числах с плавающей точкой Тип Число значащих десятичных цифр Минимальное значение float16 float32 float64 3 6 15 –6,55040e+04 –3,4028235e+38 –1,7976931348623157e+308 Максимальное значение 6,55040e+04 3,4028235e+38 1,7976931348623157e+308 Особенность в том, что числа с плавающей точкой в процессоре представлены двоичными числами, при преобразованиях из десятичного представления в 19 Это значит, что вычисления выполняются очень быстро. 78
двоичное и назад возможны двусмысленности, например, единица с плавающей точкой может представляться в десятичном виде как 1,000 000… и 0,999 999… Именно поэтому не нужно сравнивать два числа с плавающей точкой как a1=a2, правильный подход — abs(a1-a2)<eps, где eps определяется разрядностью, например, для восьмибайтных чисел с плавающей точкой это 10 -12. Брать меньшие eps нежелательно, так как погрешности имеют тенденцию накапливаться, и можно посчитать неравными числа, отличающиеся на погрешность вычислений. 3.3. Бухгалтерские вычисления Бухгалтерские вычисления проводятся с целыми числами, с большим числом значащих цифр, но при этом запоминается, где находится десятичная запятая (точка). Такой подход реализуется в основном программным образом, так что вычисления будут производиться медленнее, чем с числами с плавающей точкой. Для работы с такими числами необходимо осуществить импорт из стандартной библиотеки Python decimal (блокнот e03_03.py): from decimal import Decimal, getcontext Cразу получим информацию о десятичных числах вызовом функции getcontext без параметров: Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow]) Для нас здесь важны два атрибута объекта Context: prec — это число значащих десятичных цифр (28 по умолчанию) и rounding — правило округления. По умолчанию используется следующее правило: если последняя удерживаемая при округлении цифра нечётная, а следующая цифра равна 5, 6, …, 9, то последняя удерживаемая при округлении цифра увеличивается на единицу, если же последняя удерживаемая цифра чётная, то увеличение на единицу осуществляется, если следующая цифра равна 6, 7, 8, 9. Создание десятичных цифр осуществляется с помощью инициализатора Decimal, которому рекомендуется передавать не число с плавающей точкой в строку, чтобы не получить что-то вроде 0,99999… Выражение: print( Decimal('1.49').to_integral_value(), Decimal('1.5').to_integral_value(), Decimal('1.51').to_integral_value(), Decimal('2.49').to_integral_value(), Decimal('2.5').to_integral_value(), Decimal('2.51').to_integral_value() ) приведёт к выводу: 1 2 2 2 2 3 Как и для чисел с плавающей точкой, определим, когда для десятичных чисел с заданным числом значащих десятичных цифр теряется копейка, с помощью функции: def get_decimal_tolerance(prec=6, min_value='0.01'): getcontext().prec = prec # число значащих цифрр min_value = Decimal(min_value) 79
value = Decimal('1') d10 = Decimal('10') for i in range(120): if value == (value + min_value): return value value *= d10 Десятичные цифры мы можем проверять на равенство. Зависимость минимального числа, для которого теряется копейка, от числа значащих десятичных цифр представлена на рисунке 3.3. Рис. 3.3 Зависимость минимального числа, для которого теряется копейка, от числа десятичных цифр А вот теперь покажем, что числа с плавающей точкой не стоит использовать при бухгалтерских вычислениях. Смоделируем ситуацию, когда в начальный момент времени на счёте предприятия находится start рублей и осуществляется большое число случайных операций (транзакций) на сумму не более maxsum. Все операции выполняются на числах с плавающей точкой и десятичных числах с заданным числом значащих цифр prec. После проведения транзакций возвращается результат: def balance(start=1e14, transactions=10_000, maxsum=10_000_000, prec=50): getcontext().prec = prec # устанавливаем точность decimals = Decimal('0') floats = np.float64(start) for i in range(transactions): roubles = np.random.randint(maxsum//2, maxsum) kops = np.random.randint(0, 99) sign = "" if np.random.randint(0,2) else "-" d = Decimal(f"{sign}{roubles}.{kops}") decimals += d floats +=np.float64(d) result = Decimal(floats) - Decimal(start) - decimals return result balance(start=1e12, transactions=1_000_000) 80
Обратите внимание, что случайные транзакции формируются из строк в виде десятичных чисел и только после этого преобразуются в числа с плавающей точкой. Результат зависит от начальной суммы на счёте, числа и размеров транзакций, тем не менее при start = 1012 и миллионе транзакций отличие составляет доли копейки, а при start = 1013 почти тридцать копеек. Для гарантии мы проводили вычисления с десятичными числами с 50 значащими цифрами. Связано это как с потерей точности, отличиями правил округления, так и с накоплением ошибок. Именно это показывает, что применять арифметику с плавающей точкой для бухгалтерских вычислений нельзя. 3.4. Потеря точности С обыденной (но не с бухгалтерской) точки зрения ничего страшного от потери долей копейки при миллиардных операциях нет, но оказывается, что недостаточное число значащих цифр при проведении вычислений может существенно влиять на результат. Рассмотрим простое выражение: 1 − 𝑐𝑜𝑠 𝑥 𝜃(𝑥) = . (3.4) 𝑥2 Попытаемся вычислить (3.4) при 𝑥 → 0. Вспоминая первые лекции по математическому анализу, отметим, что у нас неопределённость типа деления ноля на ноль, нужно применять правило Лопиталя, дифференцируя числитель и знаменатель, получим: 1 𝑙im sin 𝑥/2𝑥 = . 𝑥→0 2 При непосредственном вычислении (3.4) при приближении x к нулю 𝑐𝑜𝑠 𝑥 стремится к единице, и когда мы подбираем x таким, что 𝑐𝑜𝑠 𝑥 отличается от единицы меньше чем на последнюю значащую цифру, числитель, а вместе с ним всё выражение, становится равным нулю. Вычислим значения 𝑥, стремящихся к нулю, для различного числа значащих цифр. Воспользуемся для этого библиотекой mpmath, которая устанавливается вмести со средствами для проведения символьных вычислений SymPy и позволяет производить вычисления на Python с произвольной точностью (блокнот e03_04.py). В первой ячейке блокнота мы импортируем необходимые библиотеки, а функция theta вычисляет выражение (3.4): import import import import marimo as mo numpy as np matplotlib.pyplot as plt mpmath as mp def theta(x): return (mp.mpf(1) - mp.cos(x))/x**2 Задание числа значащих цифр осуществляется с помощью атрибута mp.mp.dps. В функции failture перебираем уменьшающиеся значения x и осуществляем возврат, когда результат вычислений отличается от 0,5 более, чем на одну десятую: def failture(dps=7): 81
mp.mp.dps = dps mantissa = 7, 5, 2, 1 minexp, maxexp = 1, 100 for exp in range(minexp, maxexp): for m in mantissa: x_str = f"{m}e-{exp}" x = mp.mpf( x_str ) y = theta(x) if abs(y - 0.5)>.1: return np.float64(x_str) failture(3), failture(7), failture(15), failture(100) При трёх значащих цифрах это случается при x = 0,02, для чисел с плавающей точкой одинарной точности (семь значащих цифр) 𝑥 = 10−4 , для чисел с плавающей точкой с двойной точностью (15 значащих цифр) x = 10−8 . На рисунке 3.4 представлена зависимость потери точности от числа значащих цифр. Рис. 3.4 График потери точности при вычислении выражения 1−𝑐𝑜𝑠 𝑥 𝑥2 от числа значащих цифр 3.5. Интервальная арифметика Рассмотрим теперь вопрос о том, как распространяются погрешности. Пусть у нас есть вектор 𝑥; значения компонентов вектора мы знаем с погрешностью δ𝑥. Нам требуется вычислить значение функции, возможно векторной 𝑓(𝑥 + δ𝑥). В общем случае нам необходимо вычислить: min 𝑓(𝑥 + ϑ), max 𝑓(𝑥 + ϑ), ϑ ∈ δ𝑥. (3.5) Здесь 𝛿𝑥 — множество возможных значений погрешности. Таким образом, вместо задачи о вычислении функции мы получили гораздо более сложные и трудоёмкие задачи оптимизации. Если вектор 𝑥 многомерный, то нужно принимать во внимание зависимости компонентов вектора друг от друга. 82
В повседневных расчётах такой подход применяется достаточно редко. Далее мы рассмотрим различные подходы вычисления погрешностей функций и их поддержку экосистемой Python. Начнём с интервальной арифметики [17, 18], задачей которой является вычисление интервала, охватывающего все возможные значения 𝑓(𝑥 + ϑ), ϑ ∈ δ𝑥. Для этого пользуются свойством монотонности арифметических операций. Действительно, все арифметические операции, включая сложение, вычитание, умножение, деление, возведение в степень монотонны, что позволяет ограничиться вычислением результатов в граничных точках 𝑥 + 𝜗, а не решать задачи оптимизации. В библиотеку mpmath.iv включены возможности проводить интервальные вычисления с числами произвольной точности и даже вычислять некоторые элементарные функции, включая экспоненту, логарифм, тригонометрические функции. Для удобства мы написали класс — объектно-ориентированную обёртку, которая может служить примером построения расширений для работы с новыми сущностями, в данном случаем с интервалами. Сам класс Ivl находится в одноименном блокноте Ivl.py, который необходимо импортировать (см. блокнот e03_05.py): import marimo as mo import numpy as np from Ivl import Ivl При работе с интервалами можно задать параметры окружения: число значащих цифр и представление интервалов с помощью вывода статического метода env: Ivl.env(dps=15, pretty=False) # значения по умолчанию Параметр dps задаёт число значащих цифр (15 десятичных цифр по умолчанию), а pretty — способ вывода результатов вычислений. Мы сделали так, что интервалы являются элементами массива NumPy, каждый элемент которого является объектом iv.mpi — объектом библиотеки mpmath. Говорится об этом потому, что если pretty=False (умолчание), то это отображается в явном виде: Ivl.env(dps=15, pretty=False) iv0 = Ivl(0, eps=0.1) iv0, type(iv0), iv0.data, type(iv0.data) Будет выведено: [ [mpi('-0.10000000000000001', '0.10000000000000001')] <class 'Ivl.Ivl'> [mpi('-0.10000000000000001', '0.10000000000000001')] <class 'numpy.ndarray'> ] При указании pretty=True, mpi выводиться не будет. [ "[[-0.10000000000000000555, 0.10000000000000000555]]", "<class 'Ivl.Ivl'>", "[[-0.10000000000000000555, 0.10000000000000000555]]", "<class 'numpy.ndarray'>" ] На заметку Массив интервалов хранится в атрибуте экземпляра класса Ivl data. Обращаться к нему не надо, т. к. индексация осуществляется непосредственно в экземпляре класса Ivl. 83
Интервалы можно инициализировать как отдельным числом, так и последовательностью, указав полуширину интервала eps, имеющего по умолчанию нулевое значение. Например: iv02 = Ivl([1,2,3], eps=0.2) iv02 выдаст результат: [[0.80000000000000004441, 1.1999999999999999556] [1.8000000000000000444, 2.2000000000000001776] [2.7999999999999998224, 3.2000000000000001776]] Здесь dps=15, pretty=True. Можно обращаться к элементам массива, например, значением iv02[1] является: [1.8000000000000000444, 2.2000000000000001776] По аналогии с NumPy в Ivl встроены методы zeros и ones, которым передаются размерности массивов и полуширина интервала. Возвращают они массивы интервалов: Ivl.zeros((2,3)), Ivl.ones((3,2)) Результат: [[[0.0, [[0.0, [[[1.0, [[1.0, [[1.0, 0.0] 0.0] 1.0] 1.0] 1.0] [0.0, [0.0, [1.0, [1.0, [1.0, 0.0] [0.0, 0.0]] 0.0] [0.0, 0.0]]] 1.0]] 1.0]] 1.0]]]] Не всегда удобно работать непосредственно с интервалами, например, matplotlib работать с ними не умеет, поэтому в Ivl имеются следующие свойства: a, b, mid и delta, возвращающие соответственно нижнюю, верхнюю границы интервала, его середину и ширину. Приведём пример: # свойства интервала iv02, iv02.a, iv02.b, iv02.mid, iv02.delta Результат выполнения ячейки блокнота: [ [[0.80000000000000004441, 1.1999999999999999556] [1.8000000000000000444, 2.2000000000000001776] [2.7999999999999998224, 3.2000000000000001776]], [0.8 1.8 2.8], [1.2 2.2 3.2], [1. 2. 3.], [0.4 0.4 0.4]" ] На интервалах определены арифметические операции, мы их можем складывать, вычитать, умножать, делить, возводить в степень, в арифметических операциях могут участвовать не только интервалы, но и обычные числа, т. к. число — это интервал с нулевой полушириной. Попробуем показать, как работать с Ivl. Начнём с отличия интервальной арифметики от обычной, вычитая интервал iv1=[-0,1, 0,1] из самого себя: iv1, iv1 - iv1 Мы получим следующий результат: [ "[[-0.10000000000000000555, 0.10000000000000000555]]", 84
"[[-0.2000000000000000111, 0.2000000000000000111]]" ] В обычной арифметике такое вычитание даёт ноль, а в интервальной — увеличение ширины интервала. Интервальная арифметика придумана для того, чтобы исследовать наихудший случай распространения погрешностей. Например, можно вычислять определённые интегралы методом прямоугольников. Для этого разбиваем отрезок, на котором осуществляется интегрирование функции 𝑓(𝑥) на 𝑛 частей длиной ℎ. В середине каждой части вычисляем значение функции, тогда значение интеграла будет приближённо равно сумме этих значений, умноженной на шаг ℎ. Реализуем вычисление интеграла методом прямоугольников с помощью функции Integral: from scipy.integrate import quad def integral(f, ab=(0,1), n=10, epsx=0., epsf=0., dps=15): x = np.linspace(*ab, n+1) # разбиение ab на n частей h = (ab[1] - ab[0])/n xc = x + h/2 xc = xc[:-1] # середины отрезков Ivl.env(dps=dps) # задаем число значащих цифр # вычисляем середины разбиений с учетом погрешности epsx xci = Ivl(xc, eps=epsx) # вычисляем функцию в середине разбиений # с учетом погрешности espf yi = xci.apply(f, delta=epsf) # вычисляем сумму sum = yi.aggregate(np.sum)*h # суммируем # погрешность вычисления определенного интеграла err = np.abs(sum.mid - quad(f, ab[0], ab[1])[0]) # функция возвращает: # интервальное значение интеграла, # погрешность вычисления интеграла, # ширину интервала return sum, err, sum.delta Функции передаются следующие параметры: f — интегрируемая функция, ab — отрезок, на котором производится интегрирование, n — число разбиений отрезка интегрирования; epsx, epsf — погрешности задания координат точек разбиения и вычисления функции, соответственно, dps — число значащих десятичных цифр при проведении вычислений, по умолчанию 15. Из библиотеки scipy.integrate мы импортируем функцию quad для вычисления определённых интегралов. Мы будем использовать её для контроля точности вычисления интегралов. Различия с «чистым» Python невелики. Они заключаются в том, что точные значения середин разбиений мы преобразуем в интервалы. С помощью метода apply вычисляем интервальный массив значений функций yi, внося погрешность epsf, сумму значений функций вычисляем с помощью метода aggregate, применяемого к функции NumPy sum. Как говорилось выше, значение интеграла без погрешности вычисляется с помощью библиотечной функции quad. Функция возвращает вычисленное c помощью 85
метода прямоугольников значение интеграла sum, погрешность этого метода для данного числа разбиений err. Это значение вычисляется как абсолютное значение разности между значениями интеграла, вычисленного по методу прямоугольников (берётся середина интервала) и вычисленного с помощью функции quad. Кроме того, возвращается ширина интервала, именно она и характеризует накопление ошибок при использовании метода прямоугольников для вычисления интегралов. Проведём небольшой эксперимент с функцией 𝑥 3 и равными espx и espf. Оказалось, что при увеличении числа разбиений от 10 до 1000 погрешность уменьшается с 10–3 до 10–7, а ширина интервала пропорциональна 4 ∙ eps. Это показывает, что процесс вычислений протекает нормально, катастрофического накопления погрешности нет. В начале занятия мы рассчитывали распределение температуры в стержне. Функцию для его вычисления несложно доработать для анализа распространения погрешности (блокнот e03_06.py). Функция bar_ivl отличается от функции bar, рассмотренной выше, добавлением дополнительного параметра eps, позволяющего задать начальное распределение температуры 𝑢0 в виде интервала шириной 2 ∙ eps. def bar_ivl(u0=0., dx =0.05, tmax=0.1, dt=5e-6, alpha=1., C=1., D=10., p=1000., eps=1e-3): ''' nx = int(1./dx + 1) # число разбиений по x x = np.linspace(0.,1., nx) nt = int(tmax/dt+1) # число разбиений по t t = np.linspace(0., tmax, nt) A = dt/C*D/dx**2 B = 2*A + alpha*dt/C - 1 P = Ivl.ones((nt, nx), eps=0)*(p + alpha*u0)*dt/C u = Ivl.zeros((nt, nx), eps=eps)+u0 for i in range(nt-1): # цикл по времени u[i+1, 1:-1] = A*u[i, 2:] - B*u[i, 1:-1] +\ A*u[i, 0:-2] + P[i, 1:-1] return u, t, x Второе отличие состоит в инициализации массивов распределения температуры и мощностей, выделяющихся в стержне. Кроме того, время расчёта распределения температуры в стержне существенно возросло, связано это с тем, что операции с плавающей точкой удвоенной точности поддерживаются процессором, а при интервальных вычислениях операции выполняются программным образом. Поэтому мы увеличили шаги по времени и пространственной координате: _u, _t, _x = bar_ivl(dx =0.05, tmax=0.1, dt=1.25e-4, eps=0.001) _u[-1, _u.shape[1]//2], _u[-1, _u.shape[1]//2].delta Решения практически не отличаются от полученного с помощью функции bar, а максимальная ширина интервала температур практически не зависит от времени интегрирования tmax. Всё меняется, когда вычислительный процесс, реализованный с помощью явной разностной схемы, теряет устойчивость. На рисунке 3.5 показано, как интервальное решение начинает терять устойчивость при 𝑑𝑥 = 0,05, 𝑑𝑡 = 1,264 ∙ 10−4 . 86
Рис. 3.5 Потеря устойчивости решения уравнения теплопроводности Чёрные линии — интервал значений температуры, δ(𝑡) зависимость погрешности от времени. Здесь экспоненциальный рост погрешности определятся методом решения задачи. До сих пор мы рассматривали «хорошие» задачи, для которых вычисления не приводили к росту погрешностей. Теперь рассмотрим задачу численного дифференцирования, которое осуществляется по известной формуле: 𝑑𝑓(𝑥) 𝑓(𝑥 + ℎ) − 𝑓(𝑥 − ℎ) ≈ . (3.6) 𝑑𝑥 2ℎ Мы реализуем формулу (3.6), когда задаётся интервал значений 𝑥 с полушириной 𝜀𝑥 , кроме того, функция вычисляется с интервальной погрешностью 𝜀𝑓 , с помощью функции diff (блокнот e03_07.py): def diff(f, fs, ab=(0,1), h=0.01, epsx=0., epsf=0., n=20): ''' Численное дифференцирование с помощью интервальной арифметики f - дифференцируемая функция f(x) fs - производная функции f(x) ab - отрезок, на котором задана функция h - шаг, используемый при численном дифференцировании epsx, epsf - погрешности задания x и f n - число разбиений отрезка ab Функция возвращает максимальные погрешность вычисления производной и ширину интервала на отрезке ab ''' x = np.linspace(*ab, n+1) xi = Ivl(x, eps=epsx) xim, xip = xi - h, xi + h yim = xim.apply(f, delta=epsf) yip = xip.apply(f, delta=epsf) # считаем производную d = (yip - yim)/(2*h) da = d.a dm = d.mid db = d.b 87
ddelta = d.delta dmax = np.max(ddelta) # погрешность вычисления производной errmax = np.max(np.abs(fs(x) - dm)) return errmax, dmax Обратите внимание, что погрешности вычисляются не для точечных значений x, а вычисляется максимальная погрешность на интервале ab, то же имеет место для ширины интервала значений функции. В блокноте e03_07.py проведены вычисления для различных значений шага ℎ, погрешностей ε𝑥 , ε𝑓 и ширины интервала δ для различных элементарных функций, включая степенную функцию, cos 𝑥, ln 𝑥, 𝑒 𝑥 . Если задавать ε𝑥 = 0, то погрешность возрастает практически линейно при увеличении шага ℎ. В то же время ширина интервала катастрофически возрастает (рис. 3.6). Рис. 3.6 Зависимости погрешности вычисления и ширины интервала при численном дифференцировании экспоненты на отрезке [0, 3] от шага ℎ для различных 𝜀𝑓 при 𝜀𝑥 = 0 Если не учитывать погрешностей вычисления функции при численном дифференцировании, то чем меньше шаг ℎ, тем меньше погрешность, в то же время это увеличивает ширину интервала. На рисунке 3.7 показано, как меняются погрешности и ширина интервала, если ε𝑥 не равно нулю. Рис. 3.7 Зависимости погрешности вычисления и ширины интервала при численном дифференцировании экспоненты на отрезке [0, 3] от шага ℎ для различных 𝜀𝑓 и 𝜀𝑥 88
Мораль, которую можно извлечь из рисунка 3.7, состоит в том, что параметры численных процедур необходимо согласовать с погрешностью (неопределённостью) данных. Действительно, уменьшение шага численного дифференцирования данных с погрешностями не приводит к снижению погрешности, а только к увеличению неопределённости результатов численного дифференцирования. Интервальный анализ представляет собой достаточно простой инструмент для анализа распространения погрешностей, но он даёт максимально возможные погрешности и не учитывает связь между переменными. Предполагается, что все переменные независимы друг друга, а погрешности могут принимать все возможные значения из заданных интервалов. 3.6. Работаем с библиотекой uncertainties Другой подход к представлению и распространению погрешностей состоит в том, что значение погрешности считается нормально распределённой случайной величиной, тогда её можно охарактеризовать средним (номинальным) значением 𝜇 и средним квадратичным отклонением (СКО). СКО обычно обозначается через σ. На рисунке 3.8 представлена плотность нормального распределения с н μ = 0, σ = 1 [7]. Рис. 3.8 Плотность нормального распределения c  = 0, 𝜎 =1 Вертикальные линии на рисунке 3.8 являются границами отрезков [–3σ, 3σ], [–2σ, 2σ], [–σ, σ]. Долю значений случайной величины на этих отрезках можно определить с помощью функции ошибок erf(x): print(f'{erf(1/np.sqrt(2))=}') print(f'{erf(2/np.sqrt(2))=}') print(f'{erf(3/np.sqrt(2))=}') В результате получим: erf(1/np.sqrt(2))=0.6826894921370859 erf(2/np.sqrt(2))=0.9544997361036416 erf(3/np.sqrt(2))=0.9973002039367398 Таким образом, на отрезке [–3σ, 3σ] расположено 99,73% всех значений случайной величины. 89
Для расчёта распространения погрешностей при данном подходе делается ещё одно допущение: что СКО небольшие, и в этих условиях функции, для которых вычисляется распространение погрешностей, можно разложить в ряд 𝑑𝑓(𝑥) 𝑑𝑓2 (𝑥) Тейлора: 𝑓(𝑥 + δ) = 𝑓(𝑥) + δ+ δ2 +. .., а членами со степень при δ 𝑑𝑥 𝑑𝑥 2 больше единицы можно пренебречь. При этих предположениях достаточно легко посчитать погрешности. Именно на этих предположениях построена библиотека uncertainties. Она позволяет работать с числами с погрешностями, например, 2,71±0,01. Кроме того, в неё встроены средства дифференцирования и возможности учитывать взаимозависимости между переменными. Напомним, что в интервальной арифметике все переменные считаются независимыми друг от друга. Библиотека проводит вычисления с погрешностями, определяемыми линейной теорией распространения погрешностей20. Практически все расчёты погрешностей осуществляются автоматически, включая зависимости между переменными. Почти все расчёты погрешностей выполняются аналитически. Существуют и другие библиотеки экосистемы Python для работы с погрешностями, например soerp21, использующая аппроксимации высокого порядка для работы с погрешностями, или mcerp22, применяющая методы Монте-Карло, что позволяет работать с погрешностями, распределёнными не только по нормальному закону. Следует отметить, что mcerp в настоящее время не поддерживается разработчиками, и поэтому для её запуска приходится обращаться к исходным текстам. Вычисления с помощью библиотеки uncertainties можно вести даже в режиме калькулятора (блокнот marimo e03_08.py): import import import import import marimo as mo numpy as np matplotlib.pyplot as plt uncertainties as unc uncertainties.umath as um x = unc.ufloat(1, 0.1) y = unc.ufloat(1, 0.1) x, y, um.cos(2*x) # x = 1+/-0,1 Основным объектом библиотеки является ufloat. С его помощью задаются число и погрешность в виде СКО. На этих объектах определены арифметические операции, а вот элементарные функции приходится импортировать из uncertainties.umath. Результатом выполнения примера будет: [ "1.00+/-0.10", "1.00+/-0.10", "-0.42+/-0.18" ] 20 uncertainties. User Guide. [Электронный ресурс]. — URL: https://pythonhosted.org/uncertainties/ user_guide.html (дата обращения 23.04.2026). 21 soerp. [Электронный ресурс]. — URL: https://pypi.org/project/soerp/ (дата обращения 23.04.2026). 22 mcerp. [Электронный ресурс]. — URL: https://github.com/tisimst/mcerp (дата обращения 23.04.2026). 90
Это позволяет использовать код, предназначенный для обычных чисел, для вычислений с погрешностями практически без изменений. Важной особенностью библиотеки является то, что она автоматически учитывает взаимозависимости между переменными. Выполним две простые арифметические операции. # простые арифметические операции x - y, x - x В отличие от интервальной арифметики, для которой обе операции дают одинаковое значение [−0,2; 0,2], здесь из-за зависимостей между переменными мы получим: [ "0.00+/-0.14", "0.0+/-0" ] В первом случае переменные независимы друг от друга и погрешности складываются по правилам СКО: √СКО2𝑥 + СКО2𝑦 , во втором случае результирующая погрешность равна нулю. Переменные с погрешностями можно создавать с помощью фабричной функции ufloat, как мы это делали выше, но можно использовать для инициализации строковые представления: # инициализация с помощью строкового представления ( unc.ufloat_fromstr("0.50+/-0.01"), # каноническое представление unc.ufloat_fromstr("(5+/-0.1)e-01"), # экспоненциальное представление unc.ufloat_fromstr("5.00(1)"), # сокращенное представление unc.ufloat_fromstr(u"0.50±0.01"), # представление для печати # автоматическое задание погрешности +/-1 для последней цифры unc.ufloat_fromstr("0.50"), ) Во всех случаях будет определено число с погрешностью 0,500+/–0,010. Кроме арифметических операций, в библиотеке реализован набор элементарных функций, в основном совпадающий с со стандартной библиотекой math, например: ( um.log10(unc.ufloat(1.00, 0.01)**2), um.log(unc.ufloat_fromstr("1.00")), um.exp(unc.ufloat_fromstr("1.00")), um.sin(unc.ufloat(1.57, 0.5)), um.cos(unc.ufloat(1.57, 0.5)), ) Результат выполнения ячейки: [ "0.000+/-0.009", "0.000+/-0.010", "2.718+/-0.027", "1.0000+/-0.0004", "0.0+/-0.5" ] 91
Обратиться к атрибутам (свойствам) чисел с погрешностями можно следующим образом: для них поддерживаются атрибуты для номинального (среднего) значения nominal_value или сокращенное n, а также для СКО std_dev (сокращённое s). Приведём пример: v = unc.ufloat(0.5, 0.01) v, v.n, v.nominal_value, v.std_dev, v.s Результат выполнения ячейки выглядит так: [ "0.500+/-0.010", 0.5, 0.5, 0.01, 0.01 ] Вернёмся к примеру вычисления выражения (1 − 𝑐𝑜𝑠 𝑥)/𝑥 2 с использованием библиотеки uncertainties. Мы не можем непосредственно задать номинальное значение 𝑥 = 0, так как это приведёт к делению на ноль, поэтому номинальное значение будем задавать равным СКО. Эта зависимость реализуется функцией cos 1. def cos_1(eps): u = unc.ufloat(eps,eps) v = (1 - um.cos(u)) / u**2 return v, v.n, v.s, abs(0.5-v.n) Функции передаётся среднее квадратичное значение погрешности eps, возвращает она результат в виде объекта unc.ufloat, номинального значения, СКО и абсолютного значения разности результата и 0,5, что оказалось удобным при визуализации зависимости, представленной на рисунке 3.9. Рис. 3.9 1−𝑐𝑜𝑠 𝑥 Зависимость номинального значения и СКО для формулы | для различных значений погрешности eps 92 𝑥2 − 0,5|
Вычисления здесь ведутся с числами с плавающей точкой удвоенной точности, потеря точности вычисления формулы наступает при ε = 10−8 , а линейный подход, используемый в библиотеке, обеспечивает наложение графиков для номинального значения погрешности и СКО друг на друга. Ещё раз отметим различие интервальной арифметики и используемого в библиотеке uncertainties подходов. В последнем случае отслеживаются взаимозависимости переменных, в интервальной арифметике все переменные предполагаются независимыми, что увеличивает неопределённость получаемого результата. Как обычно, поясним это на примере. Рассмотрим так называемое логистическое отображение23 вида: 𝑥𝑖+1 = 𝑟 ∙ 𝑥𝑖 ∙ (1 − 𝑥𝑖 ), 𝑖 = 0, 1, 2, … , 1 ≤ 𝑟 ≤ 4, 0 ≤ 𝑥𝑖 ≤ 1. (3.7) В зависимости от 𝑟 поведение (3.7) будет различным, например, так (рис. 3.10) будет выглядеть зависимость 𝑥𝑖 для 𝑟 = 3,2 при 𝑥0 = 0,5. Рис. 3.10 Логистическое отображение при 𝑟 = 3,1, 𝑥0 = 0,5 При данном 𝑟 отображение имеет периодическое поведение, значения последовательно меняются от 0,794 до 0,558. Ничего не изменится, если в качестве 𝑥0 задать unc.ufloat(0.5, 1e-6). Библиотека отследит, что в формуле (3.6) используется одна и та же переменная, СКО результата будет равна нулю. Если же для каждого 𝑖 в формулу (3.6) передавать независимые переменные, то ситуация кардинально изменится. Для демонстрации этого явления напишем функцию logistic2 def logistic2(x=unc.ufloat(0.5, 1e-6), r=3.1): nom, std = x.n, x.s return r * unc.ufloat(nom, std) * (1 - unc.ufloat(nom, std)) 23 Логистическое отображение. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Логистическое_отображение (дата обращения 23.04.2026). 93
В ней мы устранили зависимость между переменными и получили катастрофический рост погрешностей. То же самое, но ещё быстрее имеет место, если воспользоваться интервальной арифметикой. Библиотека uncertainties позволяет работать и с промежуточными случаями, для чего введено понятие взаимозависимых (коррелированных) переменных. Создание коррелированных переменных осуществляется за три шага. На первом шаге создаётся корреляционная матрица 𝑅, её размер равен числу переменных, на главной диагонали расположены единицы, т. к. переменная полностью зависима от самой себя. Остальные матричные элементы 𝑅𝑖𝑗 — числа между нулем и единицей, при этом ноль означает независимость чисел друг от друга, а единица — полную зависимость. На втором шаге формируется так называемая ковариационная матрица 𝐶, её элементы равны 𝐶𝑖𝑗 = 𝑅𝑖𝑗 ∙ σ𝑖 ∙ σ𝑗 , здесь σ𝑖 — СКО 𝑖-ой переменной. На третьем шаге средствами библиотеки создаётся последовательность seq взаимозависимых переменных: seq = unc.correlated_values(noms, C) Здесь noms — последовательность номинальных значений формируемых коррелированных переменных. Покажем, как работает логистическое отображение с коррелированными переменными, написав функцию logistic3. def logistic3(nom=0.5, std=1e-7, r=3.1, corr=0.9): ''' Вычисляем логистическое отображение для коррелированных величин. Параметры: nom - номинальное значение std - СКО r - коэффициент логистического отображения corr - коэффициент корреляции corr = 0 - независимые переменные corr = 1 - одна и та же переменная Функция возвращает значение r * u * (1 - v), где u,v - коррелированные (зависящие друг от друга) переменные ''' # Шаг 1. Подготовка данных # создаем массив номинальных значений noms = np.array([nom, nom]) # корреляционная матрица R = np.array([[1, corr], [corr, 1]]) # создаем массив СКО stds = np.array([std, std]) # Шаг 2. Расчет ковариационной матрицы # элемент i,j ковариационной матрицы равен Rij*stdi*stdj # вычислить ковариационную матрицу удобно # через внешнее произведение cov_matrix = R * np.outer(stds, stds) 94
# Шаг 3. Создание коррелированных переменных # средствами библиотеки uncertainties u, v = correlated_values(noms, cov_matrix) # Шаг 4. Возвращаем результат логистического отображения result = r * u * (1 - v) return result На рисунке 3.11 представлены зависимости погрешности от силы связи между переменными (коэффициента корреляции corr) в зависимости от числа последовательных логистических отображений, полученных с помощью функции logistic3. Рис. 3.11 Зависимости распространения погрешности (СКО) от числа последовательных логистических отображений (𝑖) и коэффициентов корреляции Верхняя кривая на рисунке 3.11 соответствует проведению расчётов с помощью интервальной арифметики. Чем выше взаимная связь между переменными, тем меньше увеличивается СКО при увеличении числа логистических отображений. При коэффициенте корреляции равном 0,95 СКО перестаёт увеличиваться, напомним, что когда в формулу (3.7) в оба сомножителя подставляется одна и та же переменная (corr = 1), СКО = 0 для любого числа итераций. Этот пример приведён для того, чтобы показать, что при анализе распространения погрешностей необходимо учитывать не только их начальные значения, но и взаимосвязи между переменными. Задания читателям 1. Реализуйте неявный метод решения уравнения теплопроводности и проанализируйте распространение погрешностей с помощью интервальной арифметики. 95
2. Реализуйте метод решения алгебраических уравнений 𝑓(𝑥) = 0 методом Ньютона с использованием интервальной арифметики. Для вычисления произ𝑑𝑓(𝑥) 𝑓(𝑥+ℎ)−𝑓(𝑥−ℎ) водных используйте формулу ≈ , где ℎ — шаг дифференциро𝑑𝑥 2ℎ вания. Подумайте, как выбирать ℎ. 3. Решите задачу п.2 с использованием библиотеки uncertainty. Сравните решения. 4. На занятиях реализована интервальная версия для численного дифференцирования. Выполните реализацию численного дифференцирования с использованием библиотеки uncertainty. Сравните результаты. 96
ГЛАВА 4 ЗАНЯТИЯ 7 И 8. КРИПТОГРАФИЯ И ГОЛОВОЛОМКИ В госпитале у Йоссариана было все, что душе угодно… Правда, по утрам часок-другой ему приходилось выполнять обязанности почтового цензора. Это было нудное занятие, и уже на второй день он утратил всякий интерес к солдатским письмам, но, чтобы работа не казалась слишком скучной, он изобретал для себя всякие забавы. «Смерть определениям!» — объявил он однажды и начал вычеркивать из каждого письма, проходившего через его руки, все наречия и прилагательные. Назавтра Йоссариан объявил войну артиклям. Но особую изобретательность он проявил на следующий день, вымарав в письмах все, кроме определенных и неопределенных артиклей. С его точки зрения, стиль после такой операции становился более энергичным и письма обретали более широкий смысл. Вскоре он начал сражаться с обращениями и подписями, а текст письма оставлял нетронутым. Однажды он вымарал все, кроме обращения «Дорогая Мари», а внизу приписал: «Тоскую по тебе ужасно! А. Т. Тэппман, капеллан армии Соединенных Штатов». А. Т. Тэппман был капелланом их авиаполка. Дж. Хеллер, «Уловка-22» Информатика — это наука о работе с данными. В частности, о скрытии информации и её защите от посторонних. Именно безвозвратным скрытием информации занимался от скуки главный герой романа «Уловка-22». Защищать письменные тексты помогает раздел информатики под названием криптография, или тайнопись, а вот незаметное встраивание некой информации в изображение, в файл PDF или Word — это задача стеганографии24, кстати не путать со стенографией. 24 Стеганография. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Стеганография (дата обращения 23.04.2026). 97
4.1. Стеганография. Встраиваем цитату в заставку главы Чтобы не уходить слишком далеко, встроим цитату в заставку главы, используя средства экосистемы Python (блокнот 04_01.py). Идея состоит в том, чтобы текстовое сообщение превратить в последовательность битов (нулей и единиц) и встроить эти биты в изображение так, чтобы изменений не было бы видно невооружённым глазом. Для этого нам нужно решить некоторое число небольших задач: 1) разобраться, как кодируется текст в Python; 2) превратить текст в последовательность байтов; 3) затем превратить его в последовательность битов; 4) изменить младший бит в каждом байте массива; 5) разобраться, как устроены изображения, как они связаны с массивами байтов; 6) как незаметно встроить в изображение сообщение, а потом при необходимости извлечь его. У нас пять подзадач, будем решать их одну за другой. В Python, начиная с третьей версии, можно работать со строками практически любой письменности, включая латиницу, кириллицу, экзотические письменности южной Азии, китайские и японские иероглифы и даже эмодзи25. За это приходится платить тем, что любой символ требует для своего хранения четырёх байтов. Кодировка символов определяется международным стандартом Юникод26. В Юникоде есть несколько кодировок, ранее была упомянута четырёхбайтовая кодировка, которую можно использовать для любой письменности мира. Кроме того, имеются двухбайтовая кодировка и кодировка с переменной длинной символов под названием utf-8. Достоинством последней является то, что цифры, латиница кодируются одним байтом, кириллица двумя байтами, символы других письменностей кодируются тремя или четырьмя байтами. Давайте возьмём исходную строку вида: s = '01Ю\u5abf🐻' Строка содержит символ нуля, единицы, символ кириллицы Ю, код символа \u5abf (любой символ Юникода можно закодировать, используя обозначение "\u", после которого следуют четыре шестнадцатеричные цифры кода символа. Этот код мы выбрали случайным образом, и у нас получился иероглиф. Последний символ — эмодзи с изображением медведя. Таким образом, строка состоит из пяти символов Юникода. Нам нужно преобразовать символы Юникода в последовательность байтов. Как уже говорилось выше, сделать это можно многими различными способами. Ряд из них реализован в функции str_2_bytes. import base64 def str_2_bytes(s, encoding='utf-8'): ''' 25 В marimo для вставки эмодзи достаточно ввести двоеточие, после чего эмодзи можно выбрать из открывшегося меню. 26 Юникод. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Юникод (дата обращения 23.04.2026). 98
Преобразование строки s в последовательность байтов ''' print(f'Исходная строка: {s}, длина:{len(s)} кодировка: {encoding}') codes = [ord(c) for c in s] print(f"Коды символов исходной строки: {codes}. Длина:{len(codes)}") b = bytes(s, encoding=encoding) print(f"Последовательность байтов: {b}. Длина:{len(b)}") b10 = [int(by) for by in b] print(f"Десятичное представление байтов: {b10}. Длина:{len(b10)}") b64 = base64.b64encode(b) print(f"Кодирование последовательности байтов в base64: {b64}. Длина:{len(b64)}\n") Функции передаётся строка Юникода s и кодировка для преобразования строки в последовательность байтов. В функции осуществляется вывод на печать различных представлений полученной последовательности байтов. Ниже приводятся результаты работы функции для упомянутых выше кодировок. Исходная строка: 01Ю媿🐻, длина:5 кодировка: utf-32 Коды символов исходной строки: [48, 49, 1070, 23231, 128059]. Длина:5 Последовательность байтов: b'\xff\xfe\x00\x000\x00\x00\x001\x00\x00\x00.\x04\x00\x00\xbfZ\x00 \x00;\xf4\x01\x00'. Длина:24 Десятичное представление байтов: [255, 254, 0, 0, 48, 0, 0, 0, 49, 0, 0, 0, 46, 4, 0, 0, 191, 90, 0, 0, 59, 244, 1, 0]. Длина:24 Кодирование последовательности байтов в base64: b'//4AADAAAAAxAAAALgQAAL9aAAA79AEA'. Длина:32 Исходная строка: 01Ю媿🐻, длина:5 кодировка: utf-16 Коды символов исходной строки: [48, 49, 1070, 23231, 128059]. Длина:5 Последовательность байтов: b'\xff\xfe0\x001\x00.\x04\xbfZ=\xd8;\xdc'. Длина:14 Десятичное представление байтов: [255, 254, 48, 0, 49, 0, 46, 4, 191, 90, 61, 216, 59, 220]. Длина:14 Кодирование последовательности байтов в base64: b'//4wADEALgS/Wj3YO9w='. Длина:20 Исходная строка: 01Ю媿🐻, длина:5 кодировка: utf-8 Коды символов исходной строки: [48, 49, 1070, 23231, 128059]. Длина:5 Последовательность байтов: b'01\xd0\xae\xe5\xaa\xbf\xf0\x9f\x90\xbb'. Длина:11 Десятичное представление байтов: [48, 49, 208, 174, 229, 170, 191, 240, 159, 144, 187]. Длина:11 Кодирование последовательности байтов в base64: b'MDHQruWqv/CfkLs='. Длина:16 Наиболее экономной по длине представления последовательности байтов оказалась кодировка utf-8. Кроме того, мы привели имеющуюся в стандартной 99
библиотеке кодировку base64. Она задумана для представления последовательности байтов с помощью 64 больших и маленьких латинских букв, цифр, а также некоторых специальных символов, например, знака равенства «=» и косой черты. При обсуждении кодировок мы решили первые две подзадачи. Переходим к третьей подзадаче, попробуем разложить последовательность байтов на последовательность битов. Минимальной единицей при использовании Python является байт. В байт можно записать число от 0 до 255, этому соответствует тип данных NumPy np.uint8. В то же время бит — это либо нуль, либо единица, хранить биты мы будем опять-таки в байтах. При разложении байта на биты одному байту со значением 0÷ 255 будет соответствовать массив из восьми байтов со значениями 0 или 1. Для этого нам понадобится массив mask с восьмью элементами — степенями двойки: 1– это установленный младший бит в байте, 2 — установленный второй бит и т. д. Это позволяет проверить установку каждого бита и установить младший бит в массиве байтов, как показано ниже (блокнот 04_01.py). mask = number bits = for _, if 1, 2, 4, 8, 16, 32, 64, 128 = 241 np.zeros(8, dtype=np.uint8) b in enumerate(mask): number & b: bits[_] = 1 bits В результате число 241 представляется в виде массива байтов следующим образом: array([1, 0, 0, 0, 1, 1, 1, 1], dtype=uint8) При этом байты от младшего к старшему идут слева направо. Таким образом мы решили третью подзадачу. Если на биты нужно разложить более одного байта, действуем точно таким же образом, только в результирующем массиве байтов будет не 8, а 8 ∙ 𝑛 байтов, где 𝑛 — число байтов, раскладываемых на биты. Обратное преобразование делается с помощью NumPy ещё проще: мы поэлементно умножаем массив с битами на массив mask и суммируем результат: np.sum(bits * mask) Естественно, что получаем ожидаемый результат 241. Мы не просто так извлекали биты из байта и записывали их в массив байтов. Это нужно нам для того, чтобы установить в ноль или единицу младший бит в другом массиве байтов. О том, почему мы устанавливаем младший бит, речь пойдёт чуть ниже. Нам нужно установить или сбросить младший байт, что и делает функция set_bit. def set_bit(byte, bit): if bit: return byte | bit else: return byte & 254 Протестируем её на числах (байтах) 240, 241. (set_bit(240, False), set_bit(240, True), 100
set_bit(241, False), set_bit(241, True)) Мы получим результат: (240, 241, 240, 241) Все работает, нам осталось выяснить, как устроены изображения в matplotlib и NumPy. Подробно об этом говорится в Приложении 5. Для нас важно, что изображение может быть в градациях серого или цветным. В первом случае каждому пикселу соответствует его яркость — число от 0 до 255, если пиксел представлен однобайтовым целым без знака, или числом от 0 до 1, если пиксел — число с плавающей точкой. В свою очередь пиксели организованы в двумерный массив. В цветных изображениях каждый пиксел представлен либо тремя, либо четырьмя числами. В последнем случае первые три числа — это красная, зелёная и синяя составляющая, а четвёртое число отвечает за непрозрачность пиксела. Нулю соответствует непрозрачный пиксел, а 255 (1.0, если используются числа с плавающей точкой) соответствует полная прозрачность. При использовании представления пиксела триплетом считается, что пиксел полностью непрозрачен. Сочетанию (0, 0, 0) соответствует чёрный пиксел; (255, 255, 0) — жёлтый пиксел, а (0, 0, 255) — синий пиксел. В свою очередь цветное изображение — это трёхмерный или четырёхмерный массив однобайтовых целых без знака или чисел с плавающей точкой, нулевое измерение отвечает за вертикальную координату, первое за горизонтальную, а третье за цветность. Представление изображений в виде целых чисел предпочтительнее, так как требует в восемь раз меньше оперативной памяти. Рассматриваемая нами технология стеганографии основана на том, что если изменить младшие биты в представлении пиксела, то наш глаз этого не заметит. В файловой системе изображения хранятся в специальных форматах, .gif, .jpeg, .png и других, но matplotlib позволяет легко загрузить и преобразовать в массив изображение. Мы написали функцию, осуществляющую загрузку и преобразование изображений из файловой системы в массив целых чисел. def read_image(fn): img = plt.imread(fn) # массив изображения ndim, dtype, shape = img.ndim, img.dtype, img.shape max_brightness = np.max(img) # преобразуем в массив целых чисел imgn = np.asarray(img, dtype=np.uint8) if ndim==2: imgn[:, :] = img[:, :]*255 if max_brightness<=1 \ else img[:, :] else: imgn[:, :, :] = img[:, :, :]*255 if max_brightness<=1 \ else img[:, :, :] return imgn Заставка к главе представляет собой изображение в формате png в папке с исходными текстами примеров главы, так что его можно загрузить вызовом функции, а заодно получить информацию о нём: img = read_image("04_00.png") 101
print(f"Размер массива изображения: {img.shape}") print(f"Тип элементов массива: {img.dtype}") print(f"Количество байтов в массиве: {img.nbytes}") В результате мы получим: Размер массива изображения: (768, 768, 3) Тип элементов массива: uint8 Количество байтов в массиве: 1769472 Наконец-то мы решили все вспомогательные задачи и можем приступить к встраиванию сообщений, разложенных на отдельные биты, в изображения, изменяя младшие биты каждого пиксела. Встраивание сообщения в изображение осуществляется функцией embed_message_into_array, которой перелается два обязательных параметра: сообщение, массив изображения. Остальные параметры именованные, и можно использовать их значения по умолчанию. Отметим только, что при написании функции исходное сообщение дополняется префиксом — строкой с текстовым представлением длины сообщения. Сделано это, чтобы для восстановления сообщения не нужно было бы передавать никакой дополнительной информации. Функция возвращает модифицированный массив изображения и объект рисунка, который формируется, если дополнительно задать при вызове именованный параметр figsize — размер объекта изображения по умолчанию, вместо изображения возвращается None. def embed_message_into_array(message, a, figsize=None, fn='', info=True): ''' Встраивание сообщения в массив Параметры: message - строка с сообщением обязательно в кодировке utf-8 a - двумерный или трехмерный массив figsize - размер рисунка, если задан, то отображается рисунок со встроенным сообщением fn - путь для сохранения рисунка info - признак распечатки информации Функция возвращает: a - массив со встроенным сообщением fig - объект изображения, если задан figsize, None в противном случае ''' # преобразуем сообщение в последовательность байтов messageb = bytes(message, encoding='utf-8') l = len(messageb) + 6 # длина сообщения с префиксом slen = str(l) # строковое представление длины # дополнение строкового представления длины нулями слева # длина префикса всегда 6 символов prefix = '0'*(6 - len(slen)) + slen # преобразуем сообщение с префиксом длины pmessage = prefix + message # сообщение с префиксом pmessageb = bytes(pmessage, encoding='utf-8') l, lb = len(pmessage), len(pmessageb) 102
shape = a.shape nbytes = a.nbytes nbits = lb * 8 if info: print(f"Сообщение с префиксом: {pmessage}") print(f"Байтовое представление pmessage: {pmessageb}") print(f"Длина сообщения: {l}") print(f"Длина байтового представления сообщения: {lb}") print(f"Размер массива: {shape}") print(f"Число байтов в массиве: {nbytes}") print(f"Число битов в байтовом представлении сообщения: {nbits}") if nbytes >=nbits: # делаем массив одномерным a.shape = nbytes, # для выборки битов из байта сообщения # нам понадобится маска mask = 1, 2, 4, 8, 16, 32, 64, 128 # Изменяем младший бит в байтах массива for i in range(lb): # цикл по байтам сообщения for j in range(8): # цикл по битам байта сообщения # выбираем очередной бит из байта сообщения b = pmessageb[i] & mask[j] # модифицируем младший бит байта массива # пишем в него 1, если b>0, и 0, иначе if b: a[i*8 + j] |= 1 else: a[i*8 + j] &= 254 # возвращаемся к первоначальному размеру массива a.shape = shape # визуализация fig = None if figsize: fig = plt.figure(figsize=figsize) plt.imshow(a) plt.axis('off') if fn: plt.savefig(fn, dpi=300, facecolor="white") # возвращаем модифицированный массив и изображение return a, fig else: raise ValueError("Слишком длинное сообщение!") Встраивание цитаты в изображение заставки главы осуществляется следующими образом. def read_utf8(fn): with open(fn, 'r', encoding='utf-8') as f: return f.read() am, _fig =embed_message_into_array(read_utf8('cite.txt'), img.copy(), figsize=(6,6)) 103
При вызове мы передали функции копию массива изображения с тем, чтобы можно было бы сравнивать исходное и модифицированное изображения. Встраиваемое сообщение имеет длину 1048 символов, после добавления префикса — строчного представления длины изображения из шести символов (без него мы не сможем извлечь сообщение из соображения) — его длина увеличится до 1054 символов. Преобразования сообщения в последовательность байтов увеличит длину до 1914 байтов, или 15312 битов. Простой расчёт показывает, что из 768 строк изображения изменениями будут затронуты всего семь строк. На рисунке 4.1 показано изображение после встраивания в него цитаты. Рис. 4.1 Изображение заставки после встраивания в него цитаты Авторы долго старались найти какие-либо визуальные изменения, но так и не смогли разглядеть их и поэтому провели дополнительное исследование, сформировав разностное изображение. Для этого используем побитовую операцию сложения по модулю два. Это означает, что 0^0=0; 0^1=1; 1^0=1; 1^1=0. Это позволяет выявить все отличия одного байтового массива от другого. diff = img ^ am _n = 7 print(f"Максимальное различие элементов: {np.max(diff)}") _fig = plt.figure(figsize=(15,1)) plt.subplot(121) plt.imshow(255 - diff[:_n]) plt.axis('off') plt.subplot(122) plt.imshow(255 - diff[:_n]*255) 104
plt.axis('off') plt.tight_layout() _fig Как и должно быть, байты исходного массива img и массива со встроенным сообщением отличаются друг от друга на один младший бит (на единицу), визуально заметить это отличие невозможно (см. левое изображение на рис. 4.2), а вот если увеличить эти отличия в 255 раз и инвертировать (отличия отображаются цветными точками на белом фоне), а также повторить 10 раз каждую строку по вертикали, что-то можно увидеть (правое изображение на рисунке 4.2) Рис. 4.2 Отличия первых семи строчек исходного и преобразованного изображения Нам осталось написать функции извлечения сообщения из изображения. Делать это нам придётся в два приёма: сначала извлекаем из изображения 6 байтов, содержащих строковое представление длины изображения. Для этого написана отдельная функция extract, которой передаются массив со встроенным сообщением и длина сообщения. def extract(a, n): ''' Извлечение из массива сообщение заданной длины a - одномерный массив n - число байтов сообщения Функция возвращает строку сообщения ''' # заготовка массива сообщения bt = np.zeros(n, dtype=np.uint8) mask = 1, 2, 4, 8, 16, 32, 64, 128 # сборка байтов из младших битов массива for i in range(n*8): b = a[i] & 1 # бит сообщения if b: bt[i//8] |= mask[i % 8] s = str(bt, encoding='utf-8') # преобразование в строку return s На втором этапе мы извлекаем скрытое сообщение с помощью функции extract_message def extract_message(a): ''' Извлечение сообщения из массива a. Длина сообщения закодирована в префиксе Функция возвращает декодированное сообщение ''' shape = a.shape nbytes = a.nbytes # преобразуем в одномерный массив a.shape = nbytes, # текстовое представление длины сообщения s_len = extract(a, 6) 105
l = int(s_len) # длина сообщения с префиксом s = extract(a, l) # извлекаем сообщение с префиксом # возвращаемся к первоначальному размеру массива a.shape = shape return s[6:] # удаляем префикс Проверку корректности работы встраивания и извлечения сообщения из изображения делаем так: _ar, _fig = embed_message_into_array(cite, img) message = extract_message(_ar) cite == message В результате выполнения получаем True. Исходная цитата cite и извлеченное из изображения сообщение совпадают. Для загрузки и хранения изображения с внедрёнными сообщениями подойдут форматы без потерь, например, gif и png, а вот широко используемый для фотографий jpeg не подойдёт, т. к. не гарантирует точного попиксельного восстановления изображений27. Рассмотренная нами простая технология скрытия текстовых сообщений в изображениях только скрывает сообщение, которое достаточно просто извлечь. Очень интересная реализация большого числа методов стеганографии на Python имеется в приложении ChameleonLab (см. [21], а также серию статей, посвящённых этому приложению на habr.com). 4.2. Простейшие шифры Задачей криптографии является затруднить для посторонних чтение и использование текстовых сообщений, и этим, похоже, занимались с тех пор, как появилась письменность. История криптографии занимательно изложена в книге [22], а то, как это делалось в России, в [23]. Обе книги легко читаются и в основном посвящены докомпьютерной эре криптографии. Перед тем как заняться шифрами, необходимо ввести несколько терминов. Исходным для нас является открытый текст, т. е. обычный текст, который легко читать и изменять. При зашифровании он преобразуется в зашифрованный текст. Данный термин не совсем корректен, т. к. он может быть не текстом в обычном смысле слова, а последовательностью битов, организованных в байты. Между открытым и зашифрованным текстом находится «чёрный ящик», выполняющий процесс преобразования открытого текста в зашифрованный. Для нас этот «чёрный ящик» — программный код, который многократно используется для шифрования различных текстов. Нецелесообразно для каждого нового текста разрабатывать новый алгоритм и реализовывать его в виде программного кода. Самое интересное состоит в том, что алгоритмы и программные реализации, по крайней мере в гражданской криптографии, с помощью которой осуществляется электронная торговля, являются открытыми, более того, они тщательно изучаются. На рисунке 4.3 представлен процесс зашифрования и расшифрования. 27 JPEG. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/JPEG (дата обращения 23.04.2026). 106
Рис. 4.3 Процесс зашифрования и расшифрования: ОТ — открытый текст; А –алгоритм и его программная реализация; ЗТ — зашифрованный текст; К1, К2 — ключи. На этом рисунке не подлежащими разглашению являются только ОТ — открытый текст, К1 и К2 — ключи, которые являются параметрами алгоритма зашифрования и расшифрования А. Обычно для зашифрования и расшифрования используется один и тот же алгоритм. Если К1 = К2, то шифрование называется симметричным, если К1, К2 — разные, но связаны между собой, то А — асимметричный. Давайте посмотрим, как это все работает, начав с алгоритма, приписываемого Юлию Цезарю. Суть алгоритма проста: используемые символы упорядочиваются (во времена Цезаря уже существовал латинский алфавит), и каждый символ ОТ заменяется на символ того же алфавита, отстоящий от заменяемого символа на заданное число. Ключом К1 является именно сдвиг символа в алфавите. Сам Ю. Цезарь использовал К1 = 3. Для этого ключа латинское А заменяется на D, B на E и т. д. Если считать, что в латинском алфавите 26 символов (во времена Ю. Цезаря их было 23, отсутствовали J, U, W28), то сдвиг определяется как остаток от деления номера символа в алфавите на 26, так что X заменяется на A, Y на B, Z на С. Легко видеть, что для шифра Цезаря ключом расшифрования является К2 = –3. Для нас всё несколько сложнее, т. к. нам придётся работать с русскими и латинскими символами, строчными и прописными буквами, цифрами. Поэтому мы воспользуемся python way, проанализировав несколько достаточно объёмных русских литературных текстов, доступных в Интернете. В блокноте 04_02.py мы не только загрузили в оперативную память «Войну и мир» Л. Н. Толстого, «Словарь существительных русского языка», «Уловку-22» Дж. Хеллера, но и заодно провели частотный анализ текстов, т. е. выяснили, насколько часто встречаются символы этих текстов. import marimo as mo from collections import Counter import numpy as np import matplotlib.pyplot as plt def load_texts(paths): counter = Counter() for path in paths: # читаем очередной файл с текстом with open(path, 'r', encoding='utf-8') as f: txt = f.read() 28 Латиница [Электронный ресурс]. — URL: https://ru.wikipedia.org/wiki/Латиница (дата обращения 22.09.2025). 107
counter.update(txt) # добавляем текст в counter return counter symbols = load_texts(["./texts/catch22.txt", './texts/russian_nouns.txt', './texts/Tolstoy_Lev_Voyna_i_mir_1-2.txt', './texts/Tolstoy_Lev_Voyna_i_mir_3-4.txt']) Здесь мы воспользовались классом стандартной библиотеки Counter, который выполнил всю черновую работу по подсчёту числа вхождений символов. Counter устроен практически так же, как и словарь Python, ключами служат символы, а значениями число вхождений этих символов в тексты. Далее подсчитаем, сколько же символов в загруженных текстах, а также число уникальных символов. symbols_number = sum(list(symbols.values())) unique_symbols = len(symbols) print(f"Число загруженных символов: {symbols_number}") print(f"Число уникальных символов: {unique_symbols }") Как оказалось, мы загрузили больше четырёх миллионов символов. Число загруженных символов: 4444120 Число уникальных символов: 181 Число символов — это хорошо, но удобнее работать с частотами вхождения символов, для чего значения нам придётся поделить на общее число символов, а счётчик преобразовать в словарь Python. # словарь частот символов symbol_dict = {} for s in symbols: symbol_dict[s] = symbols[s] / symbols_number sorted_symbol_dict = dict(sorted(symbol_dict.items(), key=lambda item: item[1], reverse=True)) После этого можно построить визуальное представление частот вхождений символов (рис. 4.4). _fig = plt.figure(figsize=(15,4)) _n=50 plt.bar(list(sorted_symbol_dict.keys())[:_n], list(sorted_symbol_dict.values())[:_n]) _fig Как видно из рисунка, наиболее часто встречаются: пробел, о, а, е, и, н, т. Можно показать, что первые тридцать символов, представленных на рисунке 4.4, встречаются 94% случаев, на остальные символы (их 151) остается только 6%. Для зашифрования и расшифрования с помощью алгоритма Цезаря нам понадобится упорядоченная последовательность всех встретившихся нам символов, чтобы мы могли спокойно сдвигаться на любое число символов. sorted_symbols = sorted(list(symbols.copy().keys())) print(f"{sorted_symbols=}, {len(sorted_symbols)=}") 108
Рис. 4.4 Распределение частот 60 наиболее часто встречающихся символов Ниже приводится этот упорядоченный список символов: sorted_symbols=['\n', ' ', '!', '"', '#', '&', "'", '(', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '9', ':', ';', '<', '=', '>', '?', 'A', 'B', 'C', 'D', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'T', 'U', 'V', 'W', 'X', 'Z', '[', ']', '_', 'a', 'b', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '\xa0', '«', 'Ç', 'ß', 'à', 'á', 'â', 'ä', 'ç', 'è', 'é', 'ê', 'ë', 'ó', 'ô', 'ö', 'ù', 'û', 'ü', 'ý', 'œ', 'А', 'Б', 'В', 'Е', 'Ж', 'З', 'И', 'Й', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Т', 'У', 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Ь', 'Э', 'Ю', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'ы', 'ь', 'э', 'ю', 'я', 'ё', '‑', '–', '—', '’', '“', '№', '\ufeff'], len(sorted_symbols)=181 ')', '7', 'E', 'R', 'c', 'p', '°', 'î', 'Г', 'Р', 'Я', 'м', 'щ', '„', У нас всё готово для реализации алгоритма Цезаря. def ceaser(open_text, symbols, key=3): ''' Шифр Цезаря Параметры: open_text - открытый текст symbols - набор символов key - секретный ключ Функция возвращает зашифрованный текст: ''' symbols_dict = {s:i for i,s in enumerate(symbols)} symbols_len = len(symbols) сipher_text = "" for s in open_text: сipher_text += symbols[(symbols_dict[s] + key) \ % symbols_len] return сipher_text 109 '*', '8', 'F', 'S', 'd', 'q', '»', 'ï', 'Д', 'С', 'а', 'н', 'ъ', '…',
Не представляет сложности загрузить из файловой системы и зашифровать с помощью алгоритма Цезаря роман «Уловка-22». # Загружаем текст with open("./texts/catch22.txt", "r", encoding="utf-8") as f: catch22 = f.read() key = 42 # задаем ключ cipher_catch22 = ceaser(catch22, sorted_symbols, key=key) print(f"Первые 100 символов зашифрованного текста:\n{cipher_catch22[:100]}") Результат зашифрования таков: Первые 100 символов зашифрованного текста: Lэ1 3) 0WN940N3, 9 -)N,/)"5N!N &314/0*N?-&,420//0*N ) -)04&,&NpЕЕБЙqïûùZ«ùКiNýККЖi[[óЕЕБЙôïûùZДùКM Начинаем самое интересное — расшифрование. Последовательность наших действий зависит от того, что нам известно. Если известен ключ, то с помощью вызова open_text_ = ceaser(cipher_catch22, sorted_symbols, key=-k) получаем первоначальный открытый текст романа. Если же нам известно только то, что текст зашифрован шифром Цезаря, то нам придётся перебрать 181 ключ. Каждый раз просматривать нечитаемую последовательность символов не хочется, поэтому необходимо привлечь дополнительную информацию. Главного героя зовут Йоссариан, его имя неоднократно встречается в тексте и может быть использовано в качестве признака корректного расшифрования. from time import perf_counter as pfc time_stamp = pfc() key_ = -1 for k in range(len(sorted_symbols)): open_text_ = ceaser(cipher_catch22, sorted_symbols, key=-k) if "Йоссариан" in open_text_: key_ = k break t = pfc() - time_stamp if k>=0: print(f"Ключ найден: {key_=}, время счета {t:6.3f} c") else: print(f"Ключ не найден, время счета {t:6.3f} c") Результатом наших усилий стал текст: Ключ найден: key_=42, время счета 8.208 c Использование дополнительной информации является стандартным приёмом, используемым при криптоанализе. Во время Второй мировой войны англичане в течение достаточно длительного времени оперативно осуществляли расшифровку зашифрованных «Энигмой» сообщений за счёт того, что трудоёмкость перебора ключей снижалась на порядки из-за наличия в конце стандартного нацистского приветствия. 110
Шифр Цезаря не меняет распределения частот символов в сообщениях, что может быть использовано при расшифровке, оставляем эту задачу в качестве упражнения читателям. Как мы видели, криптостойкость шифра Цезаря оставляет желать лучшего, а нам нужно показать, что простыми средствами можно сконструировать нечто более устойчивое к вскрытию. В качестве такого кандидата рассмотрим вариант шифра Вернама [22]. Идея состоит в том, чтобы для каждого символа обеспечивать свой сдвиг. Естественно, за это придётся платить длиной ключа. Если для работы с шифром Цезаря достаточно одного числа, то для алгоритма Вернама ключ должен содержать столько цифр (сдвигов), сколько символов имеется в сообщении, а стойкость напрямую зависит от того, насколько непредсказуемой будет последовательность сдвигов в ключе. Шифр Вернама также связан с одноразовыми шифровальными блокнотами. В настоящее время шифр Вернама имеет ограниченное использование из-за необходимости передачи длинных ключей, но во времена «холодной войны» он использовался для обмена сообщениями между руководством СССР и США в критических ситуациях: ключи записывались на перфоленту, обмен ключами осуществлялся фельдъегерями, перевозящими их бронированном чемоданчике, прикрепленном к руке с помощью наручников, как в фильмах о Джеймсе Бонде. Первое, что нам нужно сделать при использовании алгоритма Вернама, — это сгенерировать одноразовый ключ, длина которого равна или превышает длину сообщения. from random import SystemRandom def one_time_pad(symbols=sorted_symbols, l=1_000_000): ''' Генерация одноразового блокнота (последовательности сдвигов) Параметры: symbols - набор символов l - размер одноразового блокнота Функция возвращает одноразовый блокнот: список из l случайных чисел ''' sr = SystemRandom() return [sr.randrange(1, len(symbols)) for _ in range(l)] Отметим, что здесь мы используем не обычную библиотеку random, а рекомендуемую SystemRandom, т. к. от качества последовательности случайных чисел зависит стойкость алгоритма. Здесь нужно вспомнить цитату из басни И. А. Крылова «Щука и кот»29: «Беда, коль пироги начнёт печи сапожник, / А сапоги тачать пирожник». Профессиональной деятельностью должны заниматься специалисты. Это в особенности касается таких критических областей деятельности как защита информации. Возвращаясь к генераторам случайных чисел, авторы вспоминают, что в библиотеке 29 И. А. Крылов. Басня «Щука и кот». [Электронный ресурс]. — URL: https://azbyka.ru/fiction/basnyashhuka-i-kot/ (дата обращения 23.04.2026). 111
прикладных программ на Фортран для ЕС ЭВМ30 был датчик случайных чисел, который выдавал последовательности чисел, коррелированных с периодом три. Это принесло много седых волос не только авторам книги, но зато позволило разобраться, как строятся генераторы случайных чисел и как нужно проверять их качество. Собственно реализация алгоритма Вернама очень похожа на реализацию алгоритма Цезаря, просто для каждого символа открытого текста из одноразового блокнота извлекается очередной сдвиг, а не используется постоянный сдвиг. def vernam(open_text, pad, symbols=sorted_symbols, encrypt = True): ''' Зашифрование / расшифрования с помощью алгоритма Вернама Параметры: open_text - преобразуемый текст pad - одноразовый блокнот (ключ) symbols - набор символов encrypt - признак зашифрования (True), расшифрования (False) Функция возвращает преобразованный текст ''' symbols_dict = {s: i for i, s in enumerate(symbols)} symbols_len = len(symbols) сipher_text = "" if len(open_text) > len(pad): raise NotImplementedError("Длина одноразового блокнота (ключа) меньше длины текста") result = "" for _, s in enumerate(open_text): k = pad[_] if encrypt else -pad[_] сipher_text += symbols[(symbols_dict[s] + k) % symbols_len] return сipher_text Функция используется как для зашифрования, так и для расшифрования текстов. Ниже приводится исходный текст преобразований «Уловки-22». _time_stamp = pfc() _n = 10000 _t = catch22 #[:_n] _pad = one_time_pad(l=len(_t)) _cf = vernam(_t, _pad, encrypt = True) _of = vernam(_cf, _pad, encrypt = False) elapsed_time = pfc() - _time_stamp print(f"Успешность зашифрования/расшифрования: {_t==_of}") print(f"Длина текста: {len(_t)}") print(f"Время счета: {elapsed_time:6.3f} c") Тест размером немного меньше мегабайта зашифрован и расшифрован примерно за одну секунду. Успешность зашифрования/расшифрования: True Длина текста: 939078 Время счета: 1.289 c 30 Отечественный аналог IBM 360/370. 112
Теперь давайте посмотрим, как эта деятельность осуществляется в библиотеках экосистемы Python. Библиотек достаточно много, мы затронем только возможности cryptography. 4.3. Как это делается в экосистеме Python Cryptography — библиотека, разработанная сообществом PyCA (Python Cryptographic Authority — Криптографическая служба Python), даёт доступ к высокоуровневым функциям, позволяющим выполнять все необходимые действия, не вникая в криптографические сложности и используя набор рецептов. Так, зашифрование/расщифрование с помощью симметричного алгоритма осуществляется, как показано в блокноте 04_03.py. Прежде всего импортируем необходимые библиотеки. from cryptography.fernet import Fernet # средства замера времени выполнения from time import perf_counter as pfc Подгружаем многострадальную «Уловку-22», а для подтверждения успешной загрузки вычисляем длину загруженного текста. with open("./texts/catch22.txt", "r", encoding="utf-8") as f: catch22 = f.read() len(catch22) В результате получим, что длина загруженного текста 939077 символов. Создаём ключ: key = Fernet.generate_key() print(f"Ключ: {key}") Ключ случайный (в вашем случае он будет иметь другое значение): Ключ: b'cZ3YShJxoXdY9GzT1T0Gc_b1y29b9LMC31D2SX8pD90=' Далее приводится последовательность действий по симметричному защифрованию/расшифрованию «Уловки-22» средствами cryptograpy. time_stamp = pfc() # метка времени # Создание объекта шифрования _token = Fernet(key) # Зашифровываем _encrypted_text = _token.encrypt(catch22.encode()) # расшифровываем, не забывая раскодировать текст _decrypted_text = _token.decrypt(_encrypted_text).decode() _elapsed_time = pfc() - _time_stamp print(f'Длина открытого текста: {len(catch22)}') print(f"Длина зашифрованного текста: {len(_encrypted_text)}") print(f"Первые 100 символов зашифрованного текста: \n{_encrypted_text[:100]}") print(f'Успешность зашифрования/расшифрования "Уловки-22": {catch22==_decrypted_text }') print(f'Время зашифрования/расшифрования "Уловки-22": {_elapsed_time:6.3e}') Результат выполнения ячейки: Длина открытого текста: 939077 Длина зашифрованного текста: 2277880 Первые 100 символов зашифрованного текста: 113
b'gAAAAABo05Vms3Ba0y4Wns2icimTOO0Hcw8zSf_5tJsLwyCMNCov4Su7Mzge9vqytnCzhVJOf92AfP BFi0bFFvRjKrFI70TJOFq' Успешность зашифрования/расшифрования "Уловки-22": True Время зашифрования/расшифрования "Уловки-22": 1.334e-01 Существенно, что для зашифрования/расшифрования используется один и тот же ключ, действия выполняются над последовательностями байтов, поэтому открытый текст необходимо кодировать, расшифрованный текст декодировать, чтобы его можно было бы прочитать. Время проведения операций на порядок меньше, чем для разработанной нами реализации алгоритма Вернама. Результатом зашифрования является последовательность байтов. При зашифровании сохраняется время проведения операции, а при расшифровании можно контролировать, не устарело ли сообщение (ttl — время жизни сообщения). import time _token = Fernet(key) _encrypted_text = _token.encrypt(catch22.encode()) _ttl = 3 # время жизни # выполняем задержку на 4 секунды time.sleep(_ttl + 1) # Проверка времени жизни сообщения try: # время жизни (ttl) 3 секунды _decrypted_text = _token.decrypt(_encrypted_text, ttl=_ttl).decode() print("Текст расшифрован ранее завершения срока действия") print(f'Успешность зашифрования/расшифрования "Уловки-22": {catch22==_decrypted_text }') except: print("Текст устарело") Естественно, что результатом выполнения данной ячейки будет сообщение, что сообщение устарело. В качестве упражнения закомментируйте строку с задержкой перед расшифрованием текста. В случае асимметричной криптографии необходимо генерировать два связанных ключа, один из которых называется секретным, а второй открытым. В библиотеке реализовано по крайней мере два алгоритма, мы рассмотрим использование только алгоритма RSA31. Прежде всего создадим два связанных между собою ключа, один из них назовём секретным, а второй открытым. from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization # секретный ключ private_key = rsa.generate_private_key( public_exponent=65537, key_size=4906 ) 31 RSA. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/RSA (дата обращения 23.04.2026). 114
# генерация открытого ключа public_key = private_key.public_key() Мы не будем углубляться в подробности асимметричного шифрования и используем рекомендуемые параметры. В отличие от симметричных алгоритмов длина ключа RSA велика, не рекомендуется использовать длину ключа менее 2048 битов, мы задали 4096 битов (256) байтов. Ключи представляют собой объекты Python, для их представления в сколько-нибудь удобочитаемом виде, сохранения в файловой и чтения из неё имеются процедуры сериализации и десериализации. # Сериализация секретного ключа private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) # Сохранение секретного ключа в файловой системе with open('private_key.pem', 'wb') as _f: _f.write(private_pem) # Сериализация открытого ключа public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) # Сохранение открытого ключа в файловой системе with open('public_key.pem', 'wb') as _f: _f.write(public_pem) # Чтение ключей из файловой системы with open("private_key.pem", "rb") as _f: loaded_private_key = serialization.load_pem_private_key( _f.read(), password=None, backend=default_backend(), ) with open("public_key.pem", "rb") as _f: loaded_public_key = serialization.load_pem_public_key( _f.read(), backend=default_backend() ) print(f"Секретный ключ: \n{private_pem}") print(f"Открытый ключ: \n{public_pem}") Здесь мы приведём только открытый ключ в том виде, как он сохранен в файловой системе: -----BEGIN PUBLIC KEY----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjOVd7yjBN3WrJtoHjkiO 115
ztDP7oiZvQlTATFamzD1hCJmYN/1wEJUaI4o/y9eLWHYmymp8YFJPnk5BNsWzcTR O8gj4ceD8YTvZ79pZAM2729ua78RHpnFcCHAaTB63Du3syzue7KRX5HXkFVtCBHh cRz+mrprsgR49vXpOTn8TsWsR7H8r+4PZT8TcaTvotPRFmBnfrdDJevNxM4FjRKV TqMyp7iBiSXuItM76h9JAcSTEh1hZ1C71QdL4cK7ePxRYl7Zooirtn8yJlz8Vrgz GfeRoxLDnjGydM7O9DECT8w4w6ZRhr+esjY8I4L1L/dAb6RXAOlx4kDfB1tv2G5X eVigdal2jPKE+DMtfiYp6qsH5A/dXTrVGRxoDf+Rthuv6adUQNX9Yq4FbqwvH1hO p38VqqD6UJ6H17M8auo7OogfstsZ6ZR1bdHEbrsvpBKcOhhs9hMig/SF4ALsUIvK 0Xw/jb/ouKMmTrTrr5W44De/8bRCvx/ffML6yTxdmua8WRQ2uaO8mS/7gTcZjqmZ hmi2FIgHvbcf1YtQ8q2odGQeMBAScyZdNxr97JZi3RgWVrdoYszzagohLXLKbmDN E1lpwYkkuZ4Uoykacg39NFzajNZt6HvDGofKntjvOm76En3fRshpoAviq4JihYHd VUXyqGJomI8LwL56KW+NP3sCAwEAAQ== -----END PUBLIC KEY----- Для сохранения в файловой системе ключи кодируются в текстовом виде с помощью алгоритма base64. Зашифрование проводится на секретном ключе из пары, расшифрование — на открытом ключе. from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend # Зашифрование _n = 200 text = catch22[:_n].encode() # зашифровываем на открытом ключе, расшифровываем на секретном _encrypted_text = public_key.encrypt( text, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) # расшифрование _decrypted_text= private_key.decrypt( _encrypted_text, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ).decode() print(f"Проверяем корректность работы RSA") print(f"{text==_decrypted_text=}") Естественно, исходный текст совпадет с расшифрованным текстом, в результате чего на печать выдаётся True. Будем относиться к передаваемым при зашифровании/расшифровании ключам как к иероглифам, просто именно так и нужно делать. Здесь существенно, что таким образом можно работать только с короткими текстами, асимметричные алгоритмы ресурсоемкие, при увеличении длины текста возбуждается исключительная ситуация. 116
Из сказанного выше следует, что асимметричные алгоритмы работают медленно и только с коротким текстами, так зачем же они нужны? Для объяснения этого нам придётся ввести ещё одно понятие под названием хеш. Хеш — это функция, принимающая в качестве параметра текст любой длины и возвращающая последовательность байтов фиксированной длины. От результата хеширования требуется, чтобы по нему было практически невозможно восстановить исходный текст, вероятность одинаковых результатов хеширования для различных исходных текстов была исчезающе мала, а изменение хотя бы одного бита в исходном тексте приводило к значимому изменению результатов хеширования. Наконец, требуется, чтобы хеширование осуществлялось быстро. Покажем, как осуществляется вычисления хеша для «Уловки-22». import base64 catch22_ = catch22 + '_' # добавляю символ bcatch22 = bytes(catch22, encoding='utf-8') bcatch22_ = bytes(catch22_, encoding='utf-8') # Создание объектов хеша _time_stamp = pfc() digest = hashes.Hash(hashes.SHA256()) digest_ = hashes.Hash(hashes.SHA256()) digest.update(bcatch22) digest_.update(bcatch22_) _time_elapsed = pfc() - _time_stamp print(f"Хеш catch22: {base64.b64encode(digest.copy(). \ finalize())}") print(f"Хеш catch22_: {base64.b64encode(digest_.copy(). \ finalize())}") print(f"Время вычисления хешей: {_time_elapsed:6.3e}") Мы вычислили хеш для романа «Уловка-22», добавили к тексту символ подчёркивания и вычислили хеш для изменённого текста, результаты преобразовали в текст с помощью base64. Результаты наших вычислений представлены ниже: Хеш catch22: b'ghXIB6kR5SoXnm+hPPLRH9LnOARZUBSC+FiLcL/bzWQ=' Хеш catch22_: b'O//LxTUAv8ek10c9LxMgK8WN2Gk88x+mUlIdR5kRV98=' Время вычисления хешей: 5.986e-03 Хеши отличаются друг от друга очень сильно, а время вычислений не превышает 6 миллисекунд для текстов размером почти мегабайт. Теперь у нас появляются два обычных в криптографии персонажа с именами Алиса и Боб. Пусть Алисе требуется переслать через Интернет, который является незащищённой средой передачи данных, «Уловку-22». Ни Алиса, ни Боб не хотят, чтобы кто-либо кроме них прочитал передаваемый текст, тем более изменил его. Казалось бы, чего проще Алисе сгенерировать ключ симметричного алгоритма шифрования, зашифровать текст и передать его Бобу по электронной почте, но тут встает вопрос, каким образом передать ключ. Передача осуществляется в небезопасной среде, передаваемое может быть прочитано на серверах электронной почты, через которые осуществляется передача (их по крайней мере 117
два), да и на любом маршрутизаторе по пути передачи сообщения. Разделение сообщения и ключа также не решает вопросов безопасности. Именно поэтому придётся действовать более сложным образом. Прежде всего, Алиса создаёт два ключа асимметричного шифрования: alice_private_key и alice_public_key и сохраняет их в файловой системе своего компьютера, для этих операций мы написали функцию (см. блокнот 04_03.py) generate_rsa_keys, которой передаётся имя участника общения. Эта функция не только генерирует секретный и открытый ключи, но и их текстовое представление, которое сохраняется в текущей папке. Алисе достаточно вызвать эту функцию, чтобы получить свои ключи. Кроме того, мы написали функцию load_public_key(name), загружающую из файловой системы открытый ключ пользователя. (alice_private_key, alice_public_key, alice_private_pem, alice_public_pem) = generate_rsa_keys('alice') Кроме того, Алиса отсылает Бобу свой открытый ключ (alice_public_pem), пишет или звонит, чтобы он прислал ей свой открытый ключ. Действия Боба: # Боб создает пару ключей (bob_private_key, bob_public_key, bob_private_pem, bob_public_pem) = generate_rsa_keys('bob') # Боб загружает присланный Алисой открытый ключ alice_public_key_ = load_public_key('alice') # Боб пересылает bob_public_pem Алисе, например, # по электронной почте. В свою очередь, Алиса сохраняет присланный открытый ключ Боба в текущей папке и загружает его оттуда с помощью нашей функции: # Алиса загружает присланный Бобом открытый ключ bob_public_key_ = load_public_key('bob') Алиса и Боб обменялись ключами, но использовать их для зашифрования длинных текстов, как уже говорилось выше, нельзя, поэтому Алисе (передающей стороне) придётся сгенерировать симметричный ключ для зашифрования «Уловки-22». Этот ключ используется один-единственный раз и называется сессионным, или одноразовым. Генерация сессионного ключа и зашифрование «Уловки-22»: # Алиса генерирует одноразовый сессионный ключ session_key = Fernet.generate_key() # Алиса зашифровывает "Уловку-22" одноразовым ключом # Создание объекта шифрования _token = Fernet(session_key) # Зашифровываем "Уловку-22" encrypted_catch22 = _token.encrypt(catch22.encode()) Для проверки целостности переданного сообщения нам понадобится его хеш: # Создаем хеш зашифрованной "Уловки-22" _hash = hashes.Hash(hashes.SHA256()) 118
_hash.update(encrypted_catch22) catch22_hash = _hash.finalize() Теперь Алиса создаёт подпись хеша зашифрованного сообщения с помощью своего секретного ключа. Для проверки целостности (отсутствия изменений) достаточно проверить подпись на открытом ключе Алисы. Кроме проверки целостности, это позволяет убедиться, что отправила сообщение именно она — владелица секретного ключа. # Алиса подписывает хеш "Уловки-22" своим секретным ключом catch22_signature = alice_private_key.sign( catch22_hash, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) Далее Алиса шифрует сессионный ключ открытым ключом Боба. Только он сможет расшифровать сессионный ключ. encrypted_session_key = bob_public_key.encrypt( session_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) Все готово для отправки. Алиса отсылает Бобу любым доступным ей способом: • encrypted_catch22 — зашифрованную на одноразовом сессионном ключе «Уловку-22»; • encrypted_session_key — зашифрованный на открытом ключе Боба сессионный ключ; • catch22_hash — хеш передаваемого зашифрованного сообщения («Уловка-22»); • catch22_signature — подпись открытым ключом Боба хеша «Уловки22». Получив присланные Алисой объекты, Боб осуществляет следующие действия. Боб расшифровывает сессионный ключ своим секретным ключом. Использование секретного ключа гарантирует, что сделать это может только Боб. decrypted_session_key = bob_private_key.decrypt( encrypted_session_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) 119
Боб проверяет, что переданное сообщение не изменено. Для этого необходимо вычислить хеш переданного сообщения и проверить подпись с помощью открытого ключа Алисы. Если проверка не пройдёт, будет возбуждено исключение. Если же пройдёт, то нужно расшифровать само сообщение на расшифрованном сессионном ключе. try: # Проверка подписи с использованием открытого ключа Алисы alice_public_key.verify( catch22_signature, catch22_hash, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) print("Подпись действительна. Сообщение не было изменено.") # Теперь можно расшифровать зашифрованную "Уловку-22" # на сессионном ключе. _token = Fernet(decrypted_session_key) # расшифровываем, не забывая раскодировать текст decrypted_catch22 = _token.decrypt(encrypted_catch22). \ decode() if catch22 == decrypted_catch22: print('Исходный и расшифрованный тексты "Уловки-22" совпадают.') else: print("Произошла ошибка. Исходный и расшифрованный тексты не совпадают.") except Exception as e: print(f"Ошибка проверки подписи: {e}") Результатом выполнения этой ячейки будет сообщение, выданное на печать: Подпись действительна. Сообщение не было изменено. Исходный и расшифрованный тексты "Уловки-22" совпадают. Мы показали, каким образом, используя асимметричное и симметричное шифрование, передавать не подлежащую разглашению информацию в незащищённой среде передачи данных. Нерешённым у нас остался один вопрос: каким образом распространять открытые ключи. Алиса и Боб их просто пересылали по электронной почте. Это хорошо до тех пор, пока некто, назовём его Чарли, не получит возможность перехватывать сообщения между Алисой и Бобом. Чарли может представиться Бобу Алисой, а Алисе Бобом, и инициировать обмен открытыми ключами, а затем расшифровывать и перешифровывать всю переписку между ними. Это называется атакой «человек посередине»32. Проблема состоит в том, чтобы связать открытый ключ с конкретным человеком. Эта задача решается выпуском (созданием) специального объекта, 32 Атака посредника. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Атака_посредника (дата обращения 23.04.2026). 120
называемого сертификатом. Технически сертификат выпускается с помощью нескольких вызовов. Пусть некая организация ООО «Рога и копыта» с сайтом horns_n_hoofs.com выпустила сертификат для Алисы. from cryptography import x509 from cryptography.x509.oid import NameOID import datetime # Один день - интервал времени one_day = datetime.timedelta(1, 0, 0) # Ключи выпускающего сертификат issuer_private_key = rsa.generate_private_key( public_exponent=65537, key_size=4096, ) issuer_public_key = private_key.public_key() Собственно создание сертификата выглядит так. # Выпуск сертификата builder = x509.CertificateBuilder() # Включаем в сертификат имя Алисы builder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, 'Алиса'), ])) # Включаем в сертификат ссылку на сайт «Рогов и копыт» builder = builder.issuer_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, 'horns_n_hoofs.com'), ])) # Время годности сертификата 90 дней builder = builder.not_valid_before(datetime.datetime.today() - one_day) builder = builder.not_valid_after(datetime.datetime.today() + (one_day * 90)) builder = builder.serial_number(x509.random_serial_number()) # Включаем в сертификат открытый ключ Алисы builder = builder.public_key(alice_public_key) builder = builder.add_extension( x509.SubjectAlternativeName( [x509.DNSName('horns_n_hoofs.com')] ), critical=False ) builder = builder.add_extension( x509.BasicConstraints(ca=False, path_length=None), critical=True, ) certificate = builder.sign( private_key=issuer_private_key, algorithm=hashes.SHA256(), ) Сертификат связывает Алису и её открытый ключ и может быть опубликован на сайте тех же «Рогов и копыт» с тем, чтобы, обратившись к сайту, можно было бы извлечь сертификат и узнать, чей он. 121
print(f"Владелец сертификата: {certificate.subject}") print(f"Начало действия сертификата: {certificate.not_valid_before_utc}") print(f"Конец действия сертификата: {certificate.not_valid_after_utc}") print("Организация/лицо, выпустившая сертификат:", certificate.issuer) print(f"Открытый ключ владельца сертификата:", certificate.public_key()) Вот сведения о сертификате Алисы: Владелец сертификата: <Name(CN=Алиса)> Начало действия сертификата: 2025-09-26 18:45:38+00:00 Конец действия сертификата: 2025-12-26 18:45:38+00:00 Организация/лицо, выпустившая сертификат: <Name(CN=horns_n_hoofs.com)> Открытый ключ владельца сертификата: <cryptography.hazmat.bindings._rust.openssl.rsa.RSAPublicKey object at 0x00000263057158F0> Связи между Алисой и её открытым ключом доверяют, наверное, только Алиса, «Рога и копыта» и, возможно, Боб. Что же делать всем остальным? Мы бы не приводили бы пример с выпуском сертификата, если бы на этом механизме не держалась бы электронная торговля и онлайн-оплата товаров и услуг. Вместо «Рогов и копыт» выступают доверенные центры сертификации, основная функция которых — удостоверять связь между открытыми ключами и их владельцами, этот механизм работает уже в течение многих лет. Вы им ежедневно пользуетесь, вводя в адресной строке браузера что-то вроде https://dzen.ru. На этом закончим сюжет, связанный с криптографией, и перейдём к кодированию представлению одних и тех же сущностей в различных видах. 4.4. Римские и арабские Одна из важных тем курса информатики — это позиционные системы счислений (двоичная, восьмеричная, десятичная, шестнадцатеричная и др.), где значение зависит не только от используемого символа (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, G, H и др.), но и от той позиции, где он расположен. Школьники и студенты должны уметь как минимум перевести десятичное число в двоичное и наоборот. С числами в двоичном, восьмеричном и прочих представлениях в обыденной жизни мы почти не имеем дела. Решая задачу на компьютере или просто с помощью калькулятора, мы вводим привычные десятичные числа. Компьютер переводит их в двоичный формат, проводит с ними нужные действия, получает двоичный ответ, который переводит в десятичный формат и выводит на экран. Более привычным для нас в бытовом плане является римское (непозиционное) представление чисел — римские числа. С ними, как это ни покажется странным, мы имеем дело очень часто — практически каждый день. И не только тогда, когда смотрим на часы с римскими числами на циферблате, но и тогда, когда, например, снимаем деньги в банкомате. В банкомат мы вводим арабское число (7120 руб., например), а банкомат выдаёт нам римское число. «Римский» счёт 122
денег мы ведём и тогда, когда нас просят записать сумму денег не числами, а словами: «семь тысяч сто двадцать рублей». В блокноте e04_04.py представлена функция, имитирующая работу банкомата по выдаче денег. def withdraw(s): ''' Имитация выдачи банкоматом набора купюр на сумму s Параметры: s - выдаваемая сумма Функция возвращает последовательность купюр ''' # набор банкнот banknotes = { '5000 р':5000, '2000 р':2000, '1000 р':1000, '500 р': 500, '200 р': 200, '100 р': 100, '50 р': 50, '10 р': 10 } # последовательность выдаваемых банкнот if s%10: print("Выдаются суммы, кратные 10 рублям") return [] seq = [] for banknote in banknotes: nominal = banknotes[banknote] if s >= nominal: num = s // nominal seq += [banknote] * num s -= nominal * num return seq Здесь все банкноты упорядочены по номиналу в порядке убывания и сохранены в словаре banknotes. Выдача осуществляется только сумм, кратных десяти рублям, т. к. банкомат работает только с бумажными купюрами. В цикле выбираем из словаря очередную банкноту и, если её номинал меньше оставшейся суммы, вычисляем, сколько банкнот данного номинала необходимо выдать, добавляем банкноты данного номинала в список seq и уменьшаем сумму s на выданное число рублей. Вот что будет, если мы запросим выдачу 7120 рублей. print(withdraw(7120)) К сожалению, вместо денег мы получим список Python: ['5000 р', '2000 р', '100 р', '10 р', '10 р'] Если в функции словарь банкнот заменить на римские цифры, то она будет переводить арабские числа в римские. def arabic_number_2_roman(a): ''' Перевод арабских чисел в римские. Параметры: a - арабское число Функция возвращает строку с переводом числа арабского числа в римское ''' arab_roman_dict = { 123
1000: 100: 10: 1: "M", 900: "CM", 500: "D", 400: "CD", "C", 90: "XC", 50: "L", 40: "XL", "X", 9: "IX", 5: "V", 4: "IV", "I", } r = '' for arab_nominal in arab_roman_dict: while a - arab_nominal >= 0: r += arab_roman_dict[arab_nominal] a -= arab_nominal return r Алгоритм перевода арабского числа в римское довольно прост: из арабского числа a изымаются «римские номиналы банкнот» до тех пор, пока «арабские» числа данного номинала не кончатся. При этом мы не забываем дополнять римское число и вычитать из арабского числа номиналы римских. Сложнее написать обратную функцию — функцию перевода римского числа в арабское или функцию для подсчёта суммы, заданной банкнотами и монетами. Такую операцию мы проводим в уме, когда видим старинное здание с годом постройки в виде римского числа на фронтоне или памятник с «римскими» датами рождения и смерти33. Задачу можно упростить, если воспользоваться прямой (исходной) функцией перевода арабского числа в римское: перебираются все арабские числа от единицы, далее они переводятся в «римский» вид, который сравнивается с исходным римским числом. Если произошло совпадение, то функция возвращает найденное арабское число. def roman_number_2_arabic(r): n = 10_000 for i in range(n): roman = arabic_number_2_roman(i) if roman == r: return i def roman_number_2_arabic(r): n = 10_000 for i in range(n): roman = arabic_number_2_roman(i) if roman == r: return i return "Решение не найдено" (roman_number_2_arabic('MDCCCXXVIII'), roman_number_2_arabic('MMDCCCXXVIII')) В результате мы получим: (1828, 2828) Римские числа можно рассматривать как некие арабские числа. В связи с этим можно предложить на занятиях информатики такую игру: создать функции, 33 Это как в игре, когда играющим показывают годы жизни великого человека в римском написании (например, MDCCCXXVIII–MCMX) и предлагают назвать этого человека. Можно также прописывать такие годы в двоичном, восьмеричном или шестнадцатеричном формате. 124
подобные тем, которые рассматривались выше, и с их помощью кодировать арабские числа в различных системах исчисления (например, шумеры использовали шестидесятеричную систему). С помощью этих функций одни студенты будут кодировать арабские числа, которые другие студенты будут пытаться раскодировать. Упоминавшийся нами банкомат не только переводит арабское число затребованной суммы денег в римское число банкнот, но и зашифровывает все введённые клиентами банка числа (PIN-код банковской карточки, например) перед пересылкой их в банк, выдавший карточку. Изучение этих приёмов — это не только очень увлекательное, но и очень полезное занятие, помогающее стать востребованным специалистом в области безопасности информационных технологий. И последнее. Было бы лучше, если бы у человека в процессе эволюции оказалось бы не десять, а восемь пальцев — по четыре на каждой из двух рук. Как у персонажей некоторых мультфильмов или картинок, генерируемых искусственным интеллектом34. В этом случае наша основная система счисления была бы не десятичной, а восьмеричной, что упростило бы наше общение с компьютером, базирующемся на байте (восьмерка — пальцы на руке) и бите (двойка — две руки). Была идея пронумеровать занятия этой книги в восьмеричной системе исчисления. Тогда бы занятий оказалось не 16, а 20, и нумеровались бы они так: 01, 02, 03, 04, 05, 06, 07, 10, 11, 12, 13, 14, 15, 16, 17 и 20. Но в такой замене таился бы некий обман — обсчёт читателя. С другой стороны, уроков в книге намного больше, чем 16 или 20. В почти каждой главе книги содержатся темы нескольких уроков. И ещё один интересный аспект тайнописи. Расчёт, сделанный на каком-то языке программирования, для многих людей является зашифрованным текстом. Расчёты же, выполненные в среде вычислительных документов, например, Jupyter Notebook или marimo, — это обычный читабельный, а кроме того, интерактивный текст. 4.5. Буквенная головоломка Разгадывание буквенных ребусов — это своеобразный процесс дешифрования. Рассмотрим подробнее. Когда-то давно в журнале «Квант» был опубликован такой буквенный ребус: USA + USSR = PEACE. Предлагалось определить, какие цифры стоят за этими латинскими буквами. При этом, естественно, одинаковым буквам должны соответствовать одинаковые цифры, а разным — разные. Ясно, что буквой «Р» (крайняя слева в сумме) может быть только единица, а буквой «U» — девятка. Раз так, то буква «Е» хранит ноль (1 + 9 = 10)35. Можно и дальше продолжить такие логико-арифметические рассуждения и полностью решить этот буквенный ребус — найти, какие цифры соответствуют остальным неизвестным буквам «S», «A», «C» и «R». Но есть смысл привлечь к этой работе компьютер: составление программы для него — это тоже неплохая головоломка для ума [24, 25]. В блокноте 04_05.py приводится функция, позволяющая решать такие головоломки методом грубой силы. Более того, она позволяет ответить на 34 При генерации заставок для книги авторы неоднократно сталкивались с летающими котами с двумя роскошными хвостами. 35 Есть подобные головоломки с подвохом, где одна буква «С» — латинская, а другая — русская. 125
вопросы, есть ли у головоломки решение, а если есть, то сколько решений имеет головоломка. Задача решается функций solve_word_puzzle. Функции придаются три параметра: one — первое слово (строка Python) op — арифметическая операция из набора '+', '-', '*'; two — второе слово и result — результат выполнения этой операции над числами one и two. Функция выдаёт список из словарей с решениями головоломки и время, затраченное на решение в секундах. Начнём с конца, решив две головоломки. Первая из них решается вызовом функции: solve_word_puzzle("USA", '+', "USSR", "PEACE") Мы получили набор решений: ( [{'сопоставление символов и цифр:': [('E', 0), ('U', 9), ('S', 3), ('C', 7), ('P', 1), ('R', 8), ('A', 2)], 'первое_число': 932, 'операция': '+', 'второе_число': 9338, 'результат': 10270}, {'сопоставление_символов_и_цифр:': [('E', 5), ('U', 4), ('S', 8), ('C', 6), ('P', 0), ('R', 2), ('A', 3)], 'первое_число': 483, 'операция': '+', 'второе_число': 4882, 'результат': 5365}, {'сопоставление_символов_и_цифр:': [('E', 9), ('U', 8), ('S', 6), ('C', 2), ('P', 0), ('R', 4), ('A', 5)], 'первое_число': 865, 'операция': '+', 'второе_число': 8664, 'результат': 9529}, {'сопоставление_символов_и_цифр:': [('E', 9), ('U', 8), ('S', 7), ('C', 4), ('P', 0), ('R', 3), ('A', 6)], 'первое_число': 876, 'операция': '+', 'второе_число': 8773, 'результат': 9649}], 1.3341366999993625 ) Мы получили четыре решения, а время счёта составило 1,3 секунды. Для второй задачи: solve_word_puzzle("USA", '+', "IRAN", "PEACE") получено двадцать решений за 8,25 секунды. Из-за длины ответа мы не будем приводить его, отсылая читателя к блокноту 04_05.py. Для решения головоломки нам необходимо решить несколько подзадач. 1. Научиться выполнять арифметические операции двумя числами и сравнивать с ожидаемым результатом. Решение этой задачи достаточно просто и реализуется функцией def check_solution(a, op, b, c): ''' Проверяем корректность решения задачи a @ b = c, где a, b, c - целые числа @ - арифметическая операция: +, -, * Функция возвращает True, если решение найдено ''' 126
if op=='+': r = a + b elif op=="-": r = a - b elif op=="*": r = a*b else: r = np.nan # нераспознанная операция дает результат "не число (nan)" return r == c 2. Вторая подзадача, связанная с преобразованием чисел строки головоломки в число, не вызывает затруднений и выполняется функцией str_2_number. Функция возвращает сгенерированное число. def str_2_number(s, d): ''' Перевод строки s в число result. Сопоставление символов строки цифрам в словаре s ''' result = 0 for i, c in enumerate(s[::-1]): result += d[c]*10**i return result Здесь символам строки s сопоставляются цифры из словаря d. В свою очередь в ключах словаря d числами являются символы, а значениями — цифры. Результирующее число получается сложением цифр, являющихся значениями словаря, умноженных на степени числа 10. Именно поэтому просмотр строки начинается с конца (символы строки s берутся в обратном порядке с помощью среза s[::-1]). 3. Нам нужно из строк one, two, result извлечь символы без повторений и определить их число (symbols_len). Сделать это можно, сцепив эти строки, преобразовав их в множество для получения последовательности уникальных символов, а затем в обычный список Python. 4. Следующая подзадача заключается в генерации всех перестановок из цифр от нуля до 9, перестановки содержит по symbols_len элементов. Именно эти перестановки будут сопоставляться с набором символов головоломки при поиске решения. Генерация перестановок осуществляется средствами стандартной библиотеки Python всего в одну строку: prm = permutations(digits, symbols_len) 5. Здесь предполагается перебор всех возможных перестановок. Функция permutations возвращает результат в такой форме, что перебор перестановок осуществляется с помощью цикла for. 6. Наконец, последняя подзадача предполагает создание словаря из набора уникальных символов головоломки symbols и текущего набора цифр p. Делается это в одну строку с помощью встроенной функции zip: d = dict(zip(symbols, p)) Ниже приводится исходный текст функции solve_word_puzzle. def solve_word_puzzle(one, op, two, result): op_in = op in '+', '-', '*' 127
if not op_in: raise ValueError(f"Некорректная операция op:{op}") time_stamp = pfc() # набор цифр digits = (0,1,2,3,4,5,6,7,8,9) # уникальные символы, входящие в головоломку # подзадача 3 symbols = list(set(one + two + result)) symbols_len = len(symbols) #print(f"{symbols=}, {symbols_len=}") # максимальная длина слова в головоломке max_len = max(len(one), len(two), len(result)) #print(f"{max_len=}") # генерируем все перестановки символов длиной max_len # подзадача 4 prm = permutations(digits, symbols_len) #print(f"{len(tuple(prm))=}") solution = [] # решение задачи # пятая подзадача for p in prm: # сопоставляем символам symbols цифры из p # в словаре d d = dict(zip(symbols, p)) #print(f"{p=}, {d=}") # превращаем строки в числа # подзадача 2 number_one = str_2_number(one, d) number_two = str_2_number(two, d) number_result = str_2_number(result, d) # подзадача 1 if check_solution(number_one, op, number_two, number_result): # сохраняем решение головоломки r = {'сопоставление_символов_и_цифр:':list(d.items()), "первое_число": number_one, "операция": op, "второе_число": number_two, "результат": number_result, } solution.append(r) elapsed_time = pfc() - time_stamp print(f"{elapsed_time=:6.3e} c") return solution, elapsed_time В исходном тексте функции имеются ссылки на решаемые подзадачи. Как говорилось выше, функция возвращает кортеж из списка полученных решений (словарей) и времени, затраченного на решение задачи. 128
Задания читателям 1. Напишите функцию, которая случайным, но воспроизводимым образом распределяет изменяемые байты по изображению, в отличие от функции в главе, которая модифицирует байты изображения подряд. Каким образом нужно восстанавливать изображение? 2. Попытайтесь расшифровать текст, например, роман «Уловка-22», зашифрованный шифром Цезаря, с помощью частотного анализа зашифрованного текста с использованием рисунка 4.4 и блокнота 04_02.py. 3. Центры сертификации — это небольшое число организаций с многомиллиардным бизнесом. Можно ли придумать другие механизмы, обеспечивающие доверие в открытой незащищённой среде общения? 4. Составьте программу перевода римского числа в арабское без использования программы перевода арабского числа в римское. 5. Составьте программу перевода числа с любым форматом в число с любым форматом. 6. Попробуйте оценить, как растёт время решения задачи от числа символов, входящих в головоломку. Есть ли способы радикально сократить это время? 129
ГЛАВА 5 ЗАНЯТИЯ 9 И 10. НАШ СТАРЫЙ ЗНАКОМЫЙ МАЯТНИК Слова хозяйки были прерваны странным шипением, так что гость было испугался; шум походил на то, как бы вся комната наполнилась змеями; но, взглянувши вверх, он успокоился, ибо смекнул, что стенным часам пришла охота бить. За шипеньем тотчас же последовало хрипенье, и наконец, понатужась всеми силами, они пробили два часа таким звуком, как бы кто колотил палкой по разбитому горшку, после чего маятник пошел опять покойно щелкать направо и налево. Н. В. Гоголь, «Мёртвые души» 5.1. Просто маятник Разбудите старшеклассника или студента и спросите, чему равен период колебания маятника. Он без запинки ответит: «Два пи, умноженное на корень от l, делённое на g». Но вряд ли кто скажет, какое допущение заложено в этой формуле и верна ли она вообще по большому счёту. Давайте за пару минут с помощью экосистемы Python решим задачу о колебании маятника. В главе 2 (занятие 4) на рисунке 2.1 показан кадр из фильма «Амаркорд» со сценой урока математики. А вот кадр из того же фильма со сценой урока физики: учитель демонстрирует колебание маятника — рисунок 5.1. Рис. 5.1 Кадр из фильма «Амаркорд»: маятник 130
Итак, представим занятие в компьютерном классе (а лучше в компьютеризированном физическом кабинете) со школьниками или студентами в рамках новой учебной дисциплины МИТ (Математика — Информатика — Техника), во время которого разбираем задачу о колебании маятника. Предварить такое занятие можно было бы реальным экспериментом — демонстрацией раскачивания маятника (рис. 5.1), благо, он есть почти в каждом физическом кабинете. Можно замерять два значения — длину нити маятника и период его колебания: запустить секундомер и подсчитать количество «взмахов» маятника за определённый период времени. Можно менять длину нити маятника и попытаться связать этот параметр с периодом колебания маятника — пометить эти точки на графике и провести через них кривую — линию тренда, как сейчас говорят. 5.2. Аналитическое решение задачи о маятнике На рисунке 5.2 показана схема задачи о маятнике, позволяющая составить дифференциальное уравнение. Маятник у нас математический — это материальная точка, имеющая массу m, но не имеющая размеров, подвешенная к неподвижной точке с помощью невесомого жёсткого стержня длиною L в однородном гравитационном поле с ускорением свободного падения g. Рис. 5.2 Схема задачи о колебании математического маятника [1] 𝑑 2 α(𝑡) 𝑚∙ = −𝑔 ∙ 𝑚 ∙ sin(α(𝑡)) . 𝑑𝑡 2 С начальными условиями 𝑑α(0) α(0) = α0 , = 0. 𝑑𝑡 131 (5.1) (5.2)
Здесь α(𝑡) — угол отклонения маятника от вертикали в радианах. Дифференциальное уравнение второго порядка: начальные условия состоят в том, что маятник отклонили от вертикали на угол 𝛼0 и отпустили с нулевой начальной скоростью. Угол 𝛼0 может быть больше 90°, а начальная скорость ненулевой. Поэтому мы и упомянули стержень, а не нить подвески маятника. Если стержень имеет вес, то это будет уже не математический, а физический ма𝑑α(0) ятник. Выражение означает, что угловая скорость маятника (первая произ𝑑𝑡 водная функции угла 𝛼 по времени) равна нулю. Уравнение (5.1) является математической записью второго закона Ньютона. Масса маятника m, умноженная на его тангенциальное ускорение (на вторую производную от углового перемещения, умноженную на длину маятника), равна силе, приложенной к маятнику в том же тангенциальном направлении. Эта проекция силы равна произведению g ∙ m, умноженному на синус угла 𝛼 отклонения маятника от вертикали. Мы можем попробовать аналитически решить задачу с помощью библиотеки символьных вычислений экосистемы Python sympy (блокнот 05_01.py). Прежде всего импортируем необходимые библиотеки и объявляем символьные переменные. import import import import marimo as mo numpy as np sympy as smp matplotlib.pyplot as plt # объявляем символьные переменные m, g, t, L, α0 = smp.symbols('m, g, t, L, α0', positive=True) α,β = smp.Function('α'), smp.Function('β') Обратите внимание, что все переменные заданы положительно определёнными, а функции объявлены отдельно. При работе с SymPy желательно ограничивать области определения переменных в соответствии с условиями решаемой задачи и передавать решателю всю доступную информацию. Дифференциальное уравнение составляется с помощью экземпляра класса smp.Eq, а дифференцируем функцию с помощью метода diff. Решение дифференциального уравнения осуществляется функцией dsolve. # Пытаемся решить исходную систему средствами sympy smp.dsolve(smp.Eq(m*L*α(t).diff(t, 2), -m*g*smp.sin(α(t)))) Запускаем ячейку на выполнение, но здесь нас ждет неудача: после получаса счёта результат не был получен, решатель SymPy не справился с нелинейностью в правой части уравнения. Что же делать? У нас два пути, во-первых, упростить задачу, удалив нелинейность в правой части, либо, во-вторых, использовать численные методы решения дифференциальных уравнений. Мы рассмотрим оба подхода. Для небольших углов отклонения маятника sin α ≈ α, преобразуем (5.1), (5.2) в систему двух дифференциальных уравнений: 𝑑α(𝑡) = β(𝑡), 𝑑𝑡 𝑑β(𝑡) 𝑚∙𝐿∙ = −𝑔 ∙ 𝑚 ∙ α(𝑡), (5.3) 𝑑𝑡 132
с начальными условиями: 𝛼(0) = 𝛼0 , 𝛽(0) = 0. В терминах SymPy (5.3), (5.4) запишем так. (5.4) # Система дифференциальных уравнений eq1 = smp.Eq(α(t).diff(t), β(t)) eq2 = smp.Eq(m*L*β(t).diff(t), -m*g*α(t)) Функции dsolve передаём список уравнений системы и словарь начальных условий. result = smp.dsolve([eq1, eq2], ics={α(0):α0, β(0):0}) result Результат решения приведён на рисунке 5.3. Рис. 5.3 Решение задачи для малых колебаний маятника средствами SymPy Естественно, ничего неожиданного мы не получили, незатухающие гармо𝐿 нические колебания с периодом 2π√ . 𝑔 Кстати, если забыть указать, что 𝐿 > 0, 𝑔 > 0, то решатель выдал бы эквивалентное решение, но посредством формулы Эйлера (рис. 5.4), и нам бы пришлось работать с комплексными числами. Рис. 5.4 Решение задачи о малых колебаниях маятника, записанное с помощью формулы Эйлера Для визуализации преобразуем аналитический результат — формулы на рисунке 5.3 — в универсальные функции NumPy. 133
alpha = smp.lambdify((α0, g,t,L),result[0].rhs, 'numpy') beta = smp.lambdify((α0, g,t,L),result[1].rhs, 'numpy') Зависимости угла отклонения и скорости изменения угла отклонения от времени для малых колебаний маятника строятся очень просто, и для 𝑚 = 1, 𝑔 = 9,81 и 𝐿 = 1, α0 = 1 представлены на рисунке 5.5. tt = np.linspace(0, 10, 1000) # массив времен _fig, _ax = plt.subplots(nrows=1, ncols=1, figsize=(6,4)) lns1 = _ax.plot(tt, alpha(1, 9.81, tt,1),'b-', lw=3, label=r'$\alpha(t)$') _ax2 = _ax.twinx() lns2 = _ax2.plot(tt, beta(1, 9.81, tt,1), 'r--', lw=3, label=r'$\frac{d\alpha(t)}{dt}$') _ax.set_xlabel(r'$t$') _ax.set_ylabel(r'$\alpha(t)$') _ax2.set_ylabel(r'$\frac{d\alpha(t)}{dt}$') lns = lns1+lns2 labs = [l.get_label() for l in lns] _ax.legend(lns, labs, loc=4) _fig Здесь мы отдельно задали оцифровку рисунка для угла отклонения слева, а скорости изменения угла отклонения справа. Рис. 5.5 Зависимость угла и скорости изменения угла отклонения при малых колебаниях для 𝑚 = 1, 𝑔 = 9,81 и 𝐿 = 1, 𝛼0 = 1 На рисунке 5.6 построен фазовый портрет малых колебаний маятника для нескольких значений длины 𝐿. Ls= .5, 1, 2 _fig = plt.figure(figsize=(4,4)) for l in Ls: plt.plot(alpha(1, 9.81, tt,l), beta(1, 9.81, tt,l), 134
label=f'${l=}$') plt.xlabel(r'$\alpha(t)$') plt.ylabel(r'$\frac{d\alpha(t)}{dt}$') plt.legend() _fig Рис. 5.6 Фазовый портрет малых колебаний маятника для 𝑚 = 1, 𝑔 = 9,81 и 𝐿 = 1, 𝛼0 = 1 Замкнутые линии на рисунке 5.6 говорят о том, что процесс колебаний периодический, а при увеличении длины маятника скорость изменения угла уменьшается. 5.3. Численное решение системы уравнений маятника Раз у нас не получилось решить задачу о маятнике аналитически, попробуем сделать это численно. Здесь мы не будем изобретать велосипед, а сразу обратимся к экосистеме Python. В SciPy имеется несколько модулей для решения задачи Коши для обыкновенных дифференциальных уравнений, которая формулируется следующим образом 𝑑𝑦̅(𝑡) ̅ 𝑦̅(𝑡)), = 𝑓(𝑡, (5.5) 𝑑𝑡 𝑦̅(0) = 𝑦̅0 , где 𝑡 — время, 𝑦̅(𝑡) — вектор-функция, 𝑦̅0 — вектор (список начальных условий — значений искомой функции в начальный момент времени). 135
Библиотека scipy.integrate включает в себя достаточно много инструментов для решения задачи Коши, начиная с простой и устаревшей функции odeint и объектно-ориентированной обёртки ode и заканчивая более современной solve_ivp, которой мы и воспользуемся. К достоинствам solve_ivp относится: • Функция позволяет работать как с жёсткими36, так и нежёсткими системами дифференциальных уравнений за счёт выбора подходящего метода при вызове функции (см. ниже). • Позволяет задавать события, например, останавливать интегрирование при выполнении некоторого условия. • Поддерживает компактный вывод для интерполяции. • Обладает улучшенным интерфейсом по умолчанию по сравнению с устаревшей функцией odeint. При вызове функции необходимо передать следующие параметры: • f — функция, реализующая правую часть системы дифференциальных уравнений и возвращающая: производную по времени от состояния y в момент времени t. Список параметров функции выглядит так f(t, y, *args), где t — скаляр, а y — массив с len(y) = len(y0). Если используются дополнительные параметры, то их необходимо передать в виде кортежа в именованном параметре args функции solve_ivp. Функция f должна возвращать массив той же формы, что и y. • t_span — интервал интегрирования (t0, tf). Решатель начинает с t = t0 и интегрирует до тех пор, пока не достигнет времени t = tf. И t0, и tf должны быть числами с плавающей точкой. • y0 — одномерной массив начальных значений; если необходимо интегрирование в комплексной области, то y0 должно быть комплексным. • method — метод интегрирования, необязательный параметр, значениями могут быть строки: − ‘RK45’ метод по умолчанию. Явный метод Рунге — Кутты порядка 5(4). Погрешность контролируется как в методе четвёртого порядка, но шаги выбираются по формуле пятого порядка (выполняется локальная экстраполяция). Может применяться в комплексной области; − 'RK23'. Явный метод Рунге — Кутты порядка 3(2). Может применяться в комплексной области; − 'DOP853'. Явный метод Рунге — Кутты 8-го порядка. Может применяться в комплексной области; − 'Radau'. Неявный метод Рунге — Кутты; − 'BDF'. Неявный многошаговый метод переменного порядка (от 1 до 5), основанный на формуле обратного дифференцирования для аппроксимации производной. Может применяться в комплексной области; 36 Под жёсткими системами обыкновенных дифференциальных уравнений понимаются системы, численное решение которых явными методами (например, методами Рунге — Кутты или Адамса) является неудовлетворительным из-за резкого увеличения объёма вычислений за счёт уменьшения шагов интегрирования или изза резкого возрастания погрешности (так называемого взрыва погрешности) при недостаточно малом шаге. Для жёстких систем характерно то, что для них необходимо использовать неявные методы. Жёсткие системы часто встречаются при анализе электронных схем. 136
− 'LSODA'. Метод Адамса/BDF с автоматическим определением жёсткости системы уравнения. Явные методы Рунге — Кутты ('RK23', 'RK45', 'DOP853') используются для нежёстких задач, а неявные методы ('Radau', 'BDF') — для жёстких. Из методов Рунге — Кутты для решения с высокой точностью рекомендуется использовать 'DOP853'. Рекомендуется также для решения задач экспериментировать с выбором метода, запустив сначала метод по умолчанию 'RK45'. Если он делает слишком много шагов или не может решить задачу, скорее всего, задача жёсткая и следует использовать методы 'Radau' или 'BDF'. 'LSODA' также может быть хорошим универсальным выбором, но он несколько менее удобен в работе, поскольку представляет собой обёртку для кода на языке программирования Fortran. • t_eval — необязательный параметр с массивом времён, для которых вычисляются значения y; значения в массиве не должны выходить из интервала t_span. Если задать None (значение по умолчанию), используются точки, выбранные решателем. Рекомендуется массив t_eval задавать явным образом. Функция возвращает объект со следующими полями: • t — массив времён; • y — массив решения в точках t; • nfev — число вычислений правой части системы уравнений; • status — целое число, характеризующее причину завершения работы решателя: –1 — неудачный шаг интегрирования; 0 — решатель успешно достиг конца интервала tspan; • message — описание причины завершения; • success — признак успешности завершения процесса интегрирования. Теперь нам необходимо написать функции, реализующие правые части системы обыкновенных дифференциальных уравнений для линеаризованного и исходного случаев (блокнот 05_02.py). import marimo as mo import numpy as np import matplotlib.pyplot as plt from scipy.integrate import solve_ivp def pendulum1(t, y, g, L): α, β = y return [β, - g/L*np.sin(α)] # исходная система уравнений def pendulum2(t, y, g, L): α, β = y return [β, - g/L*α] # линеаризованная система # параметры t0, tmax = 0., np.pi # интервал времен g, L = 9.81, 1. α0, β0 = 2*np.pi/3, 0. n = 10_000 t = np.linspace(t0, tmax, n) 137
# решение исходной и линеаризованной систем уравнений y1 = solve_ivp(pendulum1, y0=(α0, β0), t_span=(t0, tmax), t_eval=t, args=(g, L)) y2 = solve_ivp(pendulum2, y0=(α0, β0), t_span=(t0, tmax), t_eval=t, args=(g, L)) y1.success, y2.success Решение систем оказалось успешным даже при выборе метода по умалчанию. На рисунке 5.7 приводится зависимость решения систем уравнений для исходной и линеаризованной систем. Рис. 5.7 Зависимость угла отклонения от времени для различных начальных углов отклонения: 2𝜋 (120𝑜 ) 𝑚 = 1, 𝑔 = 9,81 и 𝐿 = 1, 𝛼0 = 3 Решения и особенно скорость изменения угла 𝛽(𝑡) для исходной и линеаризованной систем достаточно сильно отличаются друг от друга, на рисунке 5.8 приводятся фазовые портреты для тех же данных. 138
Рис. 5.8 Фазовый портрет решений исходной и линеаризованной систем уравнений для 2𝜋 (120𝑜 ) 𝑚 = 1, 𝑔 = 9,81 и 𝐿 = 1, 𝛼0 = 3 Давайте посмотрим, как будут меняться решения систем уравнений от начального угла отклонения маятника 𝛼0 (рис. 5.9). a0s = 10, 60, 132, 160, 171, 175, 179.9 # начальные углы _n = len(a0s) _tmax=8. _fig, _axs = plt.subplots(nrows=_n, ncols=1, figsize=(6, 15)) _t = np.linspace(t0, _tmax, n) for _, _a in enumerate(a0s): _α0 = _a/180*np.pi _y1 = solve_ivp(pendulum1, y0=(_α0, β0), t_span=(t0, _tmax), t_eval=_t, args=(g, L)) _y2 = solve_ivp(pendulum2, y0=(_α0, β0), t_span=(t0, _tmax), t_eval=_t, args=(g, L)) _axs[_].plot(_t, _y1.y[0, :], lw=3, label=r"$исходная\ система\ уравнений$") _axs[_].plot(_t, _y2.y[0, :], lw=3, label=r"$линеаризованная\ система\ уравнений$") _axs[_].set_title(rf"$\alpha_0=${_a}$^o$") _axs[_].set_xlabel('$t$') _axs[_].set_ylabel(r'$\alpha(t)$') plt.tight_layout() _fig 139
Рис. 5.9 Зависимость угла и скорости изменения угла отклонения при малых колебаниях для 2𝜋 (120𝑜 ): 𝑚 = 1, 𝑔 = 9.81 и 𝐿 = 1, 𝛼0 = 3 синяя линия — решение исходной; оранжевая линия — решение линеаризованной систем. При малых начальных углах, как и следовало ожидать, решения исходной и линеаризованной систем практически неотличимы, а вот если маятник поставить в начальный момент практически вертикально, то он начинает вращаться, и не всегда 140
в одну сторону. Кроме того, при увеличении начального угла меняется период колебаний маятника. Для линеаризованной задачи он остается неизменным. Если маятник поднять в самую верхнюю точку (a0 = 180°), то при нулевой начальной скорости он останется в неподвижном неравновесном состоянии. Так что умный школьник или студент при ответе на вопрос о периоде колебания математического маятника должен дать такой ответ: «Примерно два пи, помноженное на корень от L, делённое на g, если начальный угол отклонения маятника от вертикали невелик, а начальная его скорость тоже мала или вообще равна нулю!» В противном случае всё намного сложнее и интереснее. 5.4. Дивертисмент о метре В наших расчётах мы задавали длину маятника, равную одному метру. Если разбудить школьника или студента и спросить его, что такое метр, то он, скорее всего, ответит, что это какая-то там доля какого-то там меридиана Земли или что-то, связанное со светом (длина пути, проходимого светом в вакууме за заданное время). Но почти никто не вспомнит, что метр происходит от метрового маятника напольных часов (рис. 5.10), имеющего период колебания в две секунды. Каждый взмах маятника таких часов через систему шестерёнок перемещал секундную стрелку на 6 угловых градусов. Предлагаем читателям посчитать, на сколько угловых градусов передвигал механизм минутную и часовую стрелки. Внизу такого маятника на резьбовой шпильке есть пара гаек, вращая которые, можно менять длину маятника и заставлять часы идти точно. Остановиться маятнику не даёт заводная пружина часов или гиря, которую время от времени нужно с треском поднимать вверх. Можно предположить, что у стенных часов, упомянутых в эпиграфе данной главы, период колебания маятника составляет одну секунду, а не две, как у напольных часов (рис. 5.10). Несложно подсчитать длину маятника стенных часов — что-то около 25 см. В Европе со времён распада империи Карла Великого не существовало общих стандартных мер длины: они могли быть стандартизированы в пределах одной юрисдикции (которая зачастую имела размеры одного торгового города), но единых мер не было, и каждый регион мог иметь свои собственные. Причиной этого служило в какой-то мере то, что меры длины использовались в налогообложении (налог, например, мог измеряться в определённой длине натканного или проданного полотна), а поскольку каждый местный правитель вводил свои налоги, то для соответствующей местности законами устанавливались свои единицы измерений. С развитием науки в XVII в. стали раздаваться призывы к введению «универсальной меры» (universal measure, как назвал её английский философ и лингвист Джон Уилкинс в своем эссе 1668 г.) или «католического (всеобщего) метра» (metro cattolico, термин использовал итальянский учёный и изобретатель Тито Ливио Бураттини в своей работе “Misura Universale” 1675 г.) — меры, которая бы основывалась на каком-либо естественном явлении, а не на постановлении властей, и которая была бы десятичной, что заменило бы множество разнообразных систем счисления, одновременно существовавших в то время, например, распространённую двенадцатеричную (пример: в футе 12 дюймов). 141
Рис. 5.10 Напольные часы с метровым маятником Идея Уилкинса заключалась в том, чтобы выбрать для единицы длины длину маятника с полупериодом колебаний, равным одной секунде. Подобные маятники были незадолго до этого продемонстрированы Христианом Гюйгенсом (рис. 5.10), и их длина была весьма близка к длине современного метра (так же как к единицам длины, использовавшимся в те времена, например, ярду). Однако вскоре было обнаружено, что длина, измеренная таким способом, различается в зависимости от места измерений. Французский астроном Жан Рише во время экспедиции в Южную Америку (1671–1673) обнаружил увеличение периода колебаний секундного маятника по сравнению с тем, который наблюдался в Париже. Выверенный в Париже маятник в процессе наблюдений им был сокращен на 1,25 французской линии (~ 2,81 мм), дабы избежать отставания во времени на две минуты в день. Это было первое прямое доказательство уменьшения силы веса и силы тяжести, действующих на тело, по мере приближения к экватору, и это дало разницу в 0,3% длины между Кайенной (во французской Гвиане) и Парижем. Вплоть до французской революции 1789 г. в вопросе установления «универсальной меры» не было никакого прогресса. Франция была озабочена вопросом распространения единиц измерений длины, необходимость реформы в этой 142
области поддерживали самые различные политические силы. Талейран возродил идею о секундном маятнике и предложил её Учредительному собранию в 1790 г. с тем уточнением, что эталон длины будет измерен на широте 45° (примерно между Бордо и Греноблем). Таким образом, метр получал следующее определение: метр — это длина маятника с полупериодом колебаний на широте 45°, равным 1 с. Первоначально за основу было принято это определение (8 мая 1790 г., Французское Национальное собрание). Но несмотря на поддержку собрания, а также поддержку Великобритании и новообразованных Соединённых Штатов, предложение Талейрана так и не было осуществлено (подробнее см.: https://ru.ruwiki.ru/wiki/Метр). 5.5. Дивертисмент об эластичности На сайте пользователей пакета SMath (по адресу https://en.smath.com/ forum/yaf_postsm84202_Derivative-in-Maple.aspx) подробно обсуждалась задача о маятнике. В частности, она была решена для случая, когда маятник висит на эластичной подвеске — на некой пружине с нулевой массой, которая способна растягиваться и сжиматься. На рисунке 5.11 показаны три кадра анимации движения такого маятника — прорисовывание его траекторий движения. Рис. 5.11 Маятник на эластичной подвеске Если ещё раз разбудить школьника или студента и спросить его, по какому закону растягивается (сжимается) резинка (пружина), то он опять же без запинки ответит: «По закону Гука, гласящему, что растяжение/сжатие пропорционально приложенной силе». Но если задать запрос в Интернете, то можно узнать, что практически нет материалов, которые при деформации ведут себя так, как предписано этим законом, даже при незначительных сжатиях или растяжениях. Это вторая «незначительность» маятника, если вспомнить о «незначительности» отклонения маятника от вертикали, которую мы рассмотрели выше. В физическом кабинете можно провести ещё один эксперимент — подвесить на штативе пружину, нагружать её и замерять, насколько она будет растягиваться. Полученные точки затем нанести на график и интерполировать их некоей кривой (рис. 5.12). 143
Рис. 5.12 Графическое отображение нелинейного закона Гука На рисунке 5.12 изображена так называемая логистическая функция, которая достаточно точно описывает свойство эластичной подвески маятника: она под действием силы F сначала растягивается/сжимается более-менее линейно, а затем эластичность исчерпывается и связь становится жёсткой. 5.6. Дивертисмент о логистической функции Логистическое уравнение, известное как уравнение Ферхюльста (по имени впервые сформулировавшего его бельгийского математика), изначально появилось при изучении изменений численности населения. Было составлено соответствующее дифференциальное уравнение, решение которого и дало данную функцию. Почему Ферхюльст назвал уравнение логистическим, остается загадкой. Однако эта S-образная кривая широко используется для отображения развития многих процессов (https://ru.ruwiki.ru/wiki/Логистическое_уравнение). Так, например, качественно выглядит количество выпущенных в мире паровозов: они появились в начале XIX в. (первая промышленная революция), потом их количество стало возрастать, а затем их вытеснили тепловозы и электровозы. Наша замена линейного закона Гука на нелинейную модель — это ещё один пример использования этой кривой. На рисунке 5.12 можно видеть точки, которыми «облеплена» логистическая кривая. Представим себе, что это графическое отображение эксперимента, который ещё Гук проводил. Берется резинка, на которую подвешивают груз с известным весом. Длину резинки измеряют. Потом вес груза увеличивают и делают новые замеры длины резинки. Так делают до тех пор, пока резинка перестанет растягиваться или просто порвется. Затем точки на графике обрабатывают методом наименьших квадратов. В экосистеме Python, кстати, есть для этого нужные инструменты. Например, функция scipy.optimize.curve_fit (от fitting — сглаживание). На рисунке 5.13 показано решение задачи о маятнике на растяжке: строятся графики изменения во времени трёх видов энергии: кинетической энергии маятника (функция KE), потенциальной энергии маятника (PE) и потенциальной энергии сжатой или растянутой пружины (SE), а также сумма (ΣE) этих трёх видов энергии. Потенциальная энергия маятника переходит в кинетическую энергию 144
и энергию растянутой пружины и наоборот. Но сумма этих трёх энергий остаётся постоянной. Это очередной раз подтверждает закон сохранения энергии и (косвенно) говорит о высокой точности численного метода решения данной задачи. Рис. 5.13 Изменение трёх видов энергии качающегося маятника на эластичной подвеске 5.7. Дивертисмент о потенциальной энергии пружины Мы уже замучили нашего школьника вопросами-побудками. Но давайте разбудим его последний раз и спросим, чему равна потенциальная энергия сжатой или растянутой пружины. Ответ заранее известен: «Половина квадрата отклонения пружины от исходной точки, делённая на коэффициент упругости». Все помнят эту формулу, но опять же никто не знает, откуда она взялась и что она по большому счёту неверна. Вернее, верна только для частного, линейного случая. Да, формула потенциальной энергии растянутой пружины «с квадратом и половинкой» вытекает из линейного закона Гука. Но об этом многие забывают, как забывают и о том, что формула периода колебания маятника, с которой мы начали это занятие, также является частным «линейным» случаем. Нам же с нашим нелинейным законом подвески маятника придётся от готовой формулы отказаться и идти к истокам: вспомнить, что энергия («несделанная работа») — это сила, умноженная на перемещение. Отсюда возникает определённый интеграл силы по расстоянию в формуле для потенциальной энергии пружины. Если же в данный интеграл вписать линейную функцию Гука и взять этот интеграл, то и получится формула с квадратом значения растяжения пружины, делённым на два и на коэффициент эластичности. 5.8. Гравитационный поезд Ещё одну нелинейность в задаче о колебаниях маятника или другого объекта, связанную с сопротивлением среды, приводящей к затуханию движения маятника, мы рассмотрим на примере гравитационного поезда — специфического вида маятника. 145
Есть легенда о том, как Николай I перед строительством железной дороги из Санкт-Петербурга в Москву положил на карту линейку и провел карандашом прямую линию между этими двумя столицами Российской империи. А вот ещё один анекдот, но уже не исторический, а наших дней. Один человек сдавал в бухгалтерию отчет о командировке, в котором цена железнодорожного билета из Москвы в Петербург была выше цены обратного билета. На вопрос бухгалтера, откуда взялась такая разница, подотчётное лицо посоветовало взглянуть на глобус: из Санкт-Петербурга в Москву поезд катится под горку, а на обратном пути поднимается вверх! Шутки шутками, но уже давно обсуждается полуфантастический проект так называемого гравитационного поезда, катящегося без трения в прямолинейном подземном туннеле [27] (рис. 5.14). По плану первую половину пути такой поезд будет катиться под горку без какой-либо тяги локомотива, а вторую половину пути начнёт по инерции подниматься вверх, замедляясь до остановки в пункте назначения. Рис. 5.14 Схема гравитационного поезда В Интернете (рис. 5.14) находится множество формул, по которым можно оценить, сколько времени гравитационный поезд будет в пути и какой максимальной скорости он достигнет в середине туннеля. Но мы сейчас не будем считать по этим формулам, а составим баланс сил, действующих на гравитационный поезд, и решим получившееся дифференциальное уравнение — создадим функцию, возвращающую положение поезда в туннеле в зависимости от времени. Отказ от готовых формул позволит нам в дальнейшем усложнить модель и приблизить её к реальности с силами трения. 146
На рисунке 5.15 изображена простейшая расчётная модель гравитационного поезда: на планете Земля (идеальный шар с радиусом R) сделан прямолинейный туннель (пунктирная линия) длиной L, по которому катится поезд. Начало декартовых координат, от которого будет вестись отсчёт, находится в центре Земли. Рис. 5.15 Схема задачи о гравитационном поезде Несложно доказать, что на наш поезд (на материальную точку, имеющую массу, но не имеющую размеров) вдоль координаты х будет действовать ускоряющая сила (первая половина пути) или тормозящая сила (вторая половина пути), равная весу поезда (mg), умноженному на отношение значения координаты х к радиусу Земли R37. Это положение поезда будет зависеть от времени — будет функцией х(t). Если от этой функции взять первую производную хʹ(t), то мы получим скорость поезда, а если вторую производную хʺ(t) — то его ускорение. Дифференциальное уравнение движения нашего гравитационного поезда будет иметь вид: 𝑚 ∙ хʺ(𝑡) = −𝑚 ∙ 𝑔 ∙ 𝑥(𝑡) / 𝑅, 37 Это отношение получено в предположении, что значение ускорения свободного падения g линейно зависит от расстояния от центра Земли. На поверхности Земли оно равно примерно 9,8 м/с2, а в центре Земли — нулю. Если туннель для гравитационного поезда будет неглубоким, то допустимо считать, что расстояние от поезда до центра Земли равно примерно R (см. символ «примерно равно» на рис. 5.15), а значение ускорения свободного падения g будет постоянной величиной. Наше же соотношение x/R в дифференциальном уравнении годится для всех случаев, вплоть до самого крайнего — до гравитационного лифта, когда туннель проходит через центр Земли. Но это уже гиперфантастический проект (x стремится к бесконечности), если принять во внимание температуру в толще Земли, которую не выдержат стенки туннеля. 147
обусловленный вторым законом Ньютона: сумма сил, действующих на тело, равна произведению его массы на его ускорение. Его можно переписать в виде системы уравнений, которая с точностью до обозначений совпадает с (5.3): 𝑥(𝑡) 𝑥 ′ (𝑡) = 𝑣(𝑡), 𝑣 ′ (𝑡) = −𝑔 ∙ 𝑅 𝐿 с начальными условиями 𝑥(0) = − , 𝑣(0) = 0. Кстати, тригонометрическая 2 функция sin отсутствует на вполне законном основании, т. к. мы работаем с горизонтальным перемещением 𝑥(𝑡), а не с углом отклонения α(𝑡). Такую систему мы уже решали и аналитически, и численно, поэтому немного усложним задачу, добавив в неё сопротивление воздуха (если считать, что воздух в туннеле всё же есть) и силой трения колёс о рельсы. Так мы приблизим наш гравитационный поезд к реальным условиям. Поезд же у нас теперь будет не материальной точкой, а реальным физическим объектом с массой m и поперечным сечением S. Силу сопротивления воздуха обычно принимают пропорциональной (коэффициент k) плотности воздуха ρair, помноженной на площадь поперечного сечения поезда S и квадрат его скорости 𝑣(𝑡). Сила трения колёс о рельсы пропорциональна (коэффициент трения качения f) составляющей веса g поезда, направленной по оси y. Кроме того добавим силу постоянную силу тяги force, компенсирующую силу трения, по умолчанию она будет равняться нулю. В результате мы получим следующую систему дифференциальных уравнений. 𝑥 ′ (𝑡) = 𝑣(𝑡), 𝑥(𝑡) 𝑣 ′ (𝑡) = −𝑔 ∙ − sign(𝑣(𝑡)) ∙ 𝑅 (5.6) 𝑣(𝑡)2 𝑥 2 force ∙ (𝑘 ∙ ρair ∙ 𝑆 ∙ + 𝑓 ∙ 𝑔 ∙ √1 − ( ) − ) 2∙𝑚 𝑅 𝑚 с начальным условием 𝑥(0) = −300, 𝑣(0) = 0. (5.7) В уравнении (5.6) используется функция sign, которая равна единице, если её аргумент положительный, минус единице, если значения аргумента отрицательное, и нулю, если аргумент равен нулю. Сделано это для того, чтобы силы трения поезда о воздух и колёс о рельсы всегда действовали против движения поезда, а сила тяги, наоборот, совпадала с направлением движения. Такую систему нельзя решить аналитически, так что придётся решать её численно в блокноте 05_03.py. import marimo as mo import numpy as np import matplotlib.pyplot as plt from scipy.integrate import solve_ivp # R g m константы и переменные = 6371_000. # Радиус Земли, м = 9.81 # ускорение земного притяжения м/c^2 = 20_000 # масса поезда, кг 148
S = 3 # площадь, учитывая при расчете сопротивления воздуха ρ_air = 1.25 # сопротивление воздуха, кг/м^3 k = 0.01 # коэффициент пропорциональности f = 0.001 # коэффициент трения качения t_end = 420*60 # время интегрирования в c force = 0. # сила тяги поезда, Н n = 10_000 # число разбиений интервала времени t_eval = np.linspace(0, t_end, n) # начальные условия x0 = -300_000 # начальное положение поезда, м v0 = 0. # начальная скорость поезда Напишем функцию для вычисления правой части системы уравнений (5.6). def grav_train2(t, y, g, R, S, m, ρ_air, k, f, force): x, v = y # распаковка xs = v # первое уравнение системы # второе уравнение системы vs = -g*x/R \ -np.sign(v)/m*k*ρ_air*S*v**2/2 \ -np.sign(v)*f*g*np.sqrt(1 - (x/R)**2) \ +np.sign(v)*force/m return [xs, vs] В следующей ячейке решим систему уравнений, проконтролировав успешность решения. y2 = solve_ivp(grav_train2, [0, t_end], y0=(x0, v0), method='RK45', t_eval=t_eval, args=(g, R, S, m, ρ_air, k, f, force) ) y2.success, y2.message Решение мы получили в 10_000 точек, так что необходимо выполнить визуализацию, функции для визуализации имеются в блокноте, а здесь не приводятся. На рисунке 5.16 показано, что будет, если запустить поезд в туннель. Рис. 5.16 Перемещение поезда в туннеле 149
Поезд не дотянет до Санкт-Петербурга почти сто километров, почти достигнет скорости звука и будет ездить по туннелю туда-сюда, застыв в конце концов в середине туннеля. На рисунке 5.17 изображен фазовый портрет движения поезда [28]. Рис. 5.17 Фазовая траектория движения гравитационного поезда в туннеле Изображённое здесь поведение фазового тпортрета называется устойчивым фокусом. Если построить графики изменения во времени потенциальной и кинетической энергии нашего гравитационного поезда с силами трения, то график суммы этих энергий уже не будет горизонтальной линией (см. рис. 5.13), а станет плавно уменьшаться до нуля за счёт рассеивания потенциальной энергии и перехода её в тепловую энергию. Наверное, мчаться под землёй со скоростью, близкой к звуковой, не совсем правильно. Представьте себе, какой будет ураган на выходе из туннеля! Поэтому попробуем выкачать из туннеля воздух, чтобы уменьшить сопротивление и не вредить окружающей среде. Результат представлен на рисунке 5.18а. Если же включить тяговый двигатель, обеспечивающий силу всего 200 Н, то движение поезда в туннеле станет периодическим. Такой тип фазового портрета называется центром. 150
б а Рис. 5.18 Фазовые траектории движения гравитационного поезда: а — в туннеле с откачанным воздухом; б — в туннеле с откачанным воздухом и тягой force = 200 Н. 5.9. Волки зайчиков жуют… В советские времена была студенческая песня, о том, что жук-буржуй и жук-рабочий гибнут в классовой борьбе, а хор мрачно подпевал то, что написано в названии раздела. В этом мире всё связано между собой, волки питаются зайцами, в то же время поддерживая жизнеспособность заячьей популяции, поедая больных и слабых… Нет зайчиков — нет волков, в то же время, когда нет волков, у зайцев начинаются эпидемии. Давайте рассмотрим простую модель этого взаимодействия, определяемую системой уравнений Лотки — Вольтерры38. Взаимодействие зайчиков и волков описывается системой обыкновенных дифференциальных уравнений [4, 5]: 𝑑𝑥(𝑡) = α ∙ 𝑥(𝑡) − λ1 ∙ 𝑥(𝑡) ∙ 𝑦(𝑡) − γ ∙ 𝑥 2 (𝑡), 𝑑𝑡 (5.8) 𝑑𝑦(𝑡) = λ2 ∙ 𝑥(𝑡) ∙ 𝑦(𝑡) − β ∙ 𝑦(𝑡), 𝑑𝑡 с начальным условием (5.9) 𝑥(0) = 𝑥0 , 𝑦(0) = 𝑦0 . Здесь 𝑥(𝑡), 𝑦(𝑡) — численность жертв (зайцев) и хищников (волков) соответственно; α — коэффициент прироста численности жертв (без учёта уничтожения их хищниками); β — величина, характеризующая смертность хищников; λ1, λ2 — величины, характеризующие скорость уничтожения жертв хищниками и обусловленную этим скорость изменения численности хищников; γ — величина, учитывающая внутривидовую конкуренцию в популяции жертв. 38 Модель Лотки — Вольтерры. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Модель_Лотки_—_Вольтерры (дата обращения 22.04.2026). 151
Задача (5.8)–(5.9) нелинейная, будем её решать численными методами, но на этот раз решение снабдим пользовательским интерфейсом, позволяющим в реальном времени увидеть, как параметры системы влияют на численность волков и зайцев. Начнём, как и ранее, с написания функции для вычисления правой части системы уравнений (блокнот 05_04.py). def predator_prey(t, z, α, β, λ1, λ2, γ): ''' Правая часть системы уравнений Лотки-Вольтерры ("хищник жертва") ''' x, y = z return [α*x - λ1*x*y - γ*x**2, λ2*x*y - β*y] Решение системы уравнений и визуализацию выполняет функция сalculate_visualize. def calculate_visualize(t_end=250, x0=50, y0=5, α=0.5, β=0.2, λ1=0.05, λ2=0.02, γ=0, figsize=(4, 6), lw=1): n = 10_000 # число разбиений интервала времени t_eval = np.linspace(0, t_end, n) # массив времен z = solve_ivp(predator_prey, [0, t_end], (x0, y0), method='RK45', t_eval=t_eval, args=(α, β, λ1, λ2, γ) if z.success: # успешное интегрирование fig, axs = plt.subplots(nrows=2, ncols=1, figsize=figsize) axs[0].plot(z.t, z.y[0,:], 'b-', lw=lw, label='Численность жертв' ) axs[0].plot(z.t, z.y[1,:], 'r-', lw=lw, label='Численность хищников' ) axs[0].set_xlabel('Время') axs[0].set_ylabel('Численность жертв и хищников') # легенда axs[0].legend() axs[1].plot(z.y[0,:], z.y[1,:],'b-', lw=lw) axs[1].set_xlabel('Численность жертв') axs[1].set_ylabel('Численность хищников') plt.tight_layout() return fig else: return mo.md(f"**Интегрирование завершилось неудачей. Сообщение: {z.message}**") Ей передаются максимальное время интегрирования t_end, начальные условия x0, y0, параметры задачи, которые обсуждались ранее, размер рисунка и толщина линий графиков. Интегрирование системы осуществляется функцией solve_ivp, а в случае успеха осуществляется визуализация (рис. 5.19), в противном случае выдаётся сообщение об ошибке. При визуализации демонстрируются зависимости от времени 152
численности хищников и жертв, а под нею — зависимость численности хищников от численности жертв. Набор элементов пользовательского интерфейса формируется следующим образом. # Элементы пользовательского интерфейса title_label = mo.md('#Хищник-жертва (система уравнений ЛоткиВольтерры)').center() tmax_label = mo.md('Время интегрирования:') tmax_slider = mo.ui.slider(label='', start=0, stop=500, step=5, value=50, debounce=True, show_value=True) x0_label = mo.md('Начальная численность жертв:') x0_slider = mo.ui.slider(label='', start=0, stop=200, step=1, value=50, debounce=True, show_value=True) y0_label = mo.md('Начальная численность хищников:') y0_slider = mo.ui.slider(label='', start=0, stop=200, step=1, value=5, debounce=True, show_value=True) α_label = mo.md('Прирост численности жертвы:') α_slider = mo.ui.slider(label='', start=0, stop=1, step=.01, value=.5, debounce=True, show_value=True) β_label = mo.md('Прирост численности хищников:') β_slider = mo.ui.slider(label='', start=0, stop=1, step=.01, value=.2, debounce=True, show_value=True) λ1_label = mo.md('Скорость уничтожения жертв хищниками:') λ1_slider = mo.ui.slider(label='', start=0, stop=0.2, step=.01, value=.05, debounce=True, show_value=True) λ2_label = mo.md('Скорость изменения численности хищников:') λ2_slider = mo.ui.slider(label='', start=0, stop=0.2, step=.01, value=.02, debounce=True, show_value=True) γ_label = mo.md('Внутривидовая конкуренция у жертв:') γ_slider = mo.ui.slider(label='', start=0, stop=0.005, step=.0001, value=.0, debounce=True, show_value=True) w_label = mo.md('Ширина рисунка, дюймы') w_slider = mo.ui.slider(label='', start=1, stop=15, step=.5, value=6, debounce=True, show_value=True) h_label = mo.md('Высота рисунка, дюймы') h_slider = mo.ui.slider(label='', start=1, stop=15, step=.5, value=6, debounce=True, show_value=True) lw_label = mo.md('Толщина линии') lw_slider = mo.ui.slider(label='', start=0.5, stop=5, step=.5, value=1, debounce=True, show_value=True) 153
Рис. 5.19 Интерактивное приложение моделирования взаимоотношений хищников и жертв Все элементы пользовательского интерфейса настроены так, что значения, задаваемые пользователем, передаются вовне только тогда, когда пользователь заканчивает работу с движком слайдера, делается это заданием свойства debounce=True. Связь между элементами пользовательского интерфейса, интегрированием системы дифференциальных уравнений осуществляет функция c_and_v. def c_and_v(): return calculate_visualize(t_end=tmax_slider.value, x0=x0_slider.value, y0=y0_slider.value, α=α_slider.value, β=β_slider.value, λ1=λ1_slider.value, λ2=λ2_slider.value, #γ=γ_slider.value, δ=δ_slider.value, γ=γ_slider.value, δ=0, figsize=(w_slider.value, h_slider.value), lw=lw_slider.value) Собственно пользовательский интерфейс формируется в два столбца с помощью функций vstack, hstack, которые располагают элементами пользовательского интерфейса по горизонтали и вертикали. lhs = mo.vstack([ # левый столбец tmax_label, tmax_slider, x0_label, x0_slider, y0_label, y0_slider, α_label, α_slider, β_label, β_slider, λ1_label, λ1_slider, λ2_label, λ2_slider, 154
γ_label, γ_slider, # δ_label, δ_slider, w_label, w_slider, h_label, h_slider, lw_label, lw_slider, ]) gui = mo.vstack([ title_label, # заголовок mo.hstack([lhs,c_and_v()]) # два столбца ]) gui На рисунке 5.19 видно, что максимумы численности хищников и жертв разнесены во времени, увеличение поголовья зайцев приводит к увеличению поголовья волков и наоборот, но с некоторым запаздыванием. Всё не так просто, как мы представили: зайчикам тоже нужно питаться, а урожайность травы и капусты зависит не только от дождей, но и от деятельности людей… 5.10. Электромагнитный маятник В школе на уроке физики можно продемонстрировать совсем другой маятник (рис. 5.20). Электромагнитный маятник (мобиле) раскачивает, вернее, не даёт ему заглохнуть, батарейка, спрятанная в подставке и дающая ощущение вечного двигателя. Рис. 5.20 Пример электромагнитного маятника 155
На занятиях по физике отдельно изучают раздел про электричество и раздел про магнетизм, а затем начинают понимать, что такое разделение не совсем правомерно. Дело в том, что электричество и магнетизм — это две стороны одной медали, и они не могут существовать друг без друга. Движущееся электрическое поле порождает магнитное, а движущееся магнитное поле порождает электрическое. Это как при колебании простого маятника — потенциальная энергия порождает кинетическую, а кинетическая энергия порождает потенциальную. Рис. 5.21 Элементы схемы: катушка индуктивности, конденсатор и резистор Давайте решим задачу о колебательном электромагнитном контуре, состоящем из катушки индуктивности, конденсатора, резистора и достаточно экзотического электронного прибора под названием туннельный диод39. На рисунке 5.21 показана элементы схемы, включая катушку индуктивности, конденсатор и резистор. 39 Алгоритм расчёта схемы с туннельным диодом приведен в книге: В. П. Дьяконов, И. В. Абраменкова, А. А. Пеньков Новые информационные технологии. Ч. 3 : Основы математики и математическое моделирование : учеб. пособие / под ред. В. П. Дьяконова; Смол. гос. пед. ун-т. — Смоленск, 2003. — 192 с. 156
В экосистеме Python, как и в Греции (по словам персонажа фильма «Свадьба40» по рассказам А. П. Чехова) «всё есть», в том числе библиотека для рисования электрических и электронных схем schemdraw, которую можно установить в терминале так: uv add schemdraw У неё есть подробная документация с большим количеством примеров41, но мы прокомментировали каждую строку скрипта рисования схемы (блокнот e05_05.py). with schemdraw.Drawing() as d: # инициализируем рисунок # Европейский стиль (МЭК) отображения электронных схем elm.style(elm.STYLE_IEC) # Источник ЭДС (направление вверх) d += elm.SourceI().label('E').up() # Резистор с надписью рисуем горизонтально d += elm.Resistor().label('R').right() # Индуктивность d += elm.Inductor2(). label('L').dot() # Здесь у нас разветвление d.push() # запоминаем состояние # Рисуем туннельный диод, направление вниз, рисуем точку d += elm.DiodeTunnel().label('D').down().dot() # Возвращаемся назад вверх d.pop() # Рисуем укороченную горизонтальную линию d += elm.Line(l=2) # Рисуем конденсатор, направление вниз d += elm.Capacitor().label('C').down() # Рисуем линии и точку для замыкания схемы d += elm.Line(l=2).left() d += elm.Line() d += elm.Line().dot() # Заземляем схему d += elm.Vss() На рисунке 5.22 представлен результат наших усилий. Схема очень простая: источник подаёт на схему постоянное напряжение 𝐸. Через резистор 𝑅 и индуктивность 𝐿 течет ток 𝑖(𝑡). Ток связан с падением напряжения на резисторе законом Ома: 𝑈𝑅 = 𝑖(𝑡) ∙ 𝑅. В свою очередь ток через индук𝑑𝑖(𝑡) тивность связан с падением напряжения на ней формулой 𝑢𝐿 = 𝐿 ∙ . 𝑑𝑡 Падение напряжения на туннельном диоде и ёмкости одинаковое и равно 𝑢𝐷 . Пользуясь законом Ома для полной цепи, который гласит, что напряжение на источнике напряжения равно сумме падений напряжений на резисторе, индуктивности и туннельном диоде, получим уравнение с двумя неизвестными 𝑖(𝑡), 𝑢𝐷 : 𝐸 = 𝑢𝑅 + 𝑢𝐿 + 𝑢𝐷 , которое можно переписать в виде 40 Свадьба (фильм, 1944) [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Свадьба_(фильм,_1944), (дата обращения 22.04.2025). 41 Schemdraw documentation. [Электронный ресурс]. — URL: https://schemdraw.readthedocs.io/en/stable/), (дата обращения 22.04.2026). 157
𝑑𝑖(𝑡) 𝐸 − 𝑖(𝑡) ∙ 𝑅 − 𝑢𝐷 = . 𝑑𝑡 𝐿 (5.10) Рис. 5.22 Схема генератора на туннельном диоде Второе уравнение мы получим из условия, что ток в цепи складывается из тока, протекающего через ёмкость и туннельный диод, а падение напряжения на обоих этих элементах схемы 𝑢𝐷 . В первом случае всё просто, и ток через ёмкость равен произведению ёмкости на производную напряжения на ней по времени 𝑑𝑢 (𝑡) 𝑖𝐶 = 𝐶 𝐷 . С туннельным диодом несколько сложнее. Связь между напряже𝑑𝑡 нием и током, проходящим через него, мы зададим вольтамперной характеристикой (ВАХ). Это может быть формула (для резистора ВАХ — это прямая, проходящая через точку с нулевыми током и напряжением). В случае туннельного диода сделаем это с помощью таблицы 5.1. Таблица 5.1 Вольтамперная характеристика туннельного диода 𝒖𝑫 𝒊𝑫 0,0 0,0000 0,2 0,0100 0,4 0,0040 0,6 0,0010 0,8 0,0009 1,0 0,0030 1,2 0,0100 В таблице всё здорово, но для полноценной работы нам необходимо получить значения тока через диод в любых промежуточных точках, например, в точке 𝑢𝐷 = 0.42. Сделаем это с помощью сплайновой интерполяции. Для нас важно, что такое значение можно получить с помощью функции 𝑖𝐷 = vah(𝑢𝐷 ). Вернёмся к составлению системы уравнений. Ток в цепи складывается из тока через диод и ёмкость: 𝑑𝑢𝐷 (𝑡) 𝑖(𝑡) = 𝑖𝐷 + 𝑖𝐶 = 𝑣𝑎ℎ(𝑢𝐷 ) + 𝐶 . 𝑑𝑡 Для решения системы дифференциальных уравнений каждое из уравнений нужно привести в форме Коши так, чтобы в левой части находились только первые производные неизвестных функций: 𝑑𝑢𝐷 (𝑡) 𝑖(𝑡) − vah(𝑢𝐷) (5.11) = . 𝑑𝑡 𝐶 158
Система уравнений у нас есть, зададим начальные условия. В начальный момент времени ток в схеме и напряжение на туннельном диоде равны нулю. 𝑖(0) = 0, 𝑢𝐷 (0) = 0 . (5.12) Условия задачи мы полностью определили, можем приступать к её решению, но для этого нам необходима функция 𝑣𝑎ℎ(𝑢𝐷 ), которую мы сконструируем с помощью полиномиальной аппроксимации. from numpy import polynomial as poly ud_ = np.array([ 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2]) id_ = np.array([0.0000, 0.0100, 0.0040, 0.0010, 0.0030,0.0100]) # аппроксимация полиномом 6 степени vah = poly.Polynomial.fit(ud_, id_, 6) 0.0009, Результаты наших усилий представлены на рисунке 5.23. Рис. 5.23 Вольтамперная характеристика туннельного диода Напишем функцию для вычисления правых частей системы дифференциальных уравнений (5.10), (5.11). def rh_td(t, y, E, R, L, vah, C): i, ud = y return [(E - i * R - ud) / L, (i - vah(ud)) / C] Решение системы уравнений и визуализация осуществляется функцией t_diod. 159
def tdiod(E=0.35, R=10., L=5.e-8, C=5.e-11, umax=1.2, vah=vah, method='RK45', tmax=5.e-8, n = 1000, figsize=(8, 8), fn=''): t = np.linspace(0., tmax, n) # решение системы уравнений y = solve_ivp(rh_td, [0, tmax], (0., 0.), method=method, t_eval=t, args=(E, R, L, vah, C)) if y.success: i, ud = y.y[0, :], y.y[1, :] # распаковка # Визуализация fig = plt.figure(figsize=figsize) # рисуем ток от времени plt.subplot(2,2,1) plt.plot(t, i, lw=3, color='black') plt.xlabel('$t$') plt.ylabel('$i(t)$') plt.grid(ls='solid', lw=0.2) plt.title("Ток от времени", fontsize=14) # рисуем напряжение на диоде от времени plt.subplot(2,2,2) plt.plot(t, ud, lw=3, color='black') plt.xlabel('$t$') plt.ylabel('$u_D(t)$') plt.grid(ls='solid', lw=0.2) plt.title("Напряжение на диоде от времени", fontsize=14) # Зависимость напряжения от тока plt.subplot(2,2,3) plt.plot(ud, i, lw=3, color='black') plt.xlabel('$u_D(t)$') plt.ylabel('$i(t)$') plt.grid(ls='solid', lw=0.2) plt.title("Зависимость тока напряжения ", fontsize=14) # ВАХ plt.subplot(2,2,4) ud_ = np.linspace(0, umax, n) id_ = vah(ud_) plt.plot(ud_, vah(ud_), lw=3, color='black') plt.xlabel('$u_D$') plt.ylabel('$i$') plt.grid(ls='solid', lw=0.2) plt.title("ВАХ и линия нагрузки", fontsize=14) plt.ylim(0, np.max(id_)) # линия нагрузки ur = np.linspace(0.,E, n) ir = (E - ur)/R plt.plot(ur, ir, lw=3, ls='--', color='red') 160
plt.tight_layout() if fn: plt.savefig(fn, dpi=300, facecolor='white') return fig else: return f'Интегрирование закончилось неудачно. \nСообщение:{y.message}' Проверим функцию, вызвав её со значениями параметров по умолчанию (рис. 5.24): tdiod() Рис. 5.24 Периодические колебания в генераторе на туннельном диоде Генерация в схеме связана с тем, что рабочая точка находится на участке с отрицательным сопротивлением ВАХ. На рисунке 5.25 генерация затухает при увеличении сопротивления резистора до 18 Ом. 161
Рис. 5.25 Периодические колебания в генераторе на туннельном диоде Период колебаний генератора определяется произведением √𝐿𝐶. 5.11. Детерминированный хаос двойного маятника Казалось бы, что в мире механики, в частности — и маятников, всё определено и предсказуемо: отклонил маятник на больший угол, получил колебания с большей амплитудой. Однако это не так. Достаточно всего-навсего прикрепить к концу маятника, который мы рассматривали в начале занятия, ещё один такой же маятник (рис. 5.26), чтобы получить хаотическое поведение. Таким образом, у нас имеется два маятника массой 𝑚1 , 𝑚2 на невесомых подвесах длиной 𝑙1 , 𝑙2 . В начальный момент времени углы отклонения маятников равны 𝛼1 , 𝛼2 . Вывод уравнений двойного маятника достаточно сложный, мы приведём только конечный результат. Координаты плеч маятника связаны с углами отклонения соотношениями [31]. 162
Рис. 5.26 Двойной маятник 𝑥1 = 𝑙1 sin α1 , 𝑥2 = 𝑙1 sin α1 + 𝑙2 sin α2 , (5.13) 𝑦1 = −𝑙1 cos α1 , 𝑦2 = −𝑙1 cos α1 − 𝑙2 cos α2 . Нам понадобится лангражиан системы, представляющий собой разность между потенциальной и кинетической энергией, который можно выразить через углы 𝛼1 , 𝛼2 следующим образом: 𝑚1 𝑚2 2 2 𝑚2 2 2 𝐿=( + ) 𝑙 α̇ + 𝑙 α̇ + 𝑚2 𝑙1 𝑙2 α̇ 1 α̇ 2 cos(α1 − α2 ) + 2 2 1 1 2 2 2 + (𝑚1 + 𝑚2 )𝑔𝑙1 cos α1 + 𝑚2 𝑔𝑙2 cos α2 . Здесь α̇ 1 α̇ 2 — производные углов отклонения маятников по времени. Введём также переменные обобщённых импульсов: 𝜕𝐿 𝑝1 = = (𝑚1 + 𝑚2 )𝑙12 α̇ 1 + 𝑚2 𝑙1 𝑙2 α̇ 2 cos(α1 − α2 ), 𝜕α1̇ 𝜕𝐿 𝑝2 = = 𝑚2 𝑙22 α̇ 2 + 𝑚2 𝑙1 𝑙2 α̇ 1 cos(α1 − α2 ). 𝜕α2̇ В окончательном виде система дифференциальных уравнений для двойного маятника при 𝑙1 = 𝑙2 = 𝑙 запишется следующим образом: 𝑝1 − 𝑝2 cos(α1 − α2 ) 𝛼̇ 1 = , 𝑚1 𝑙 2 (1 + μ sin2 (α1 − α2 )) 𝑝2 (1 + μ) − 𝑝1 μ cos(α1 − α2 ) 𝛼̇ 2 = , 𝑚1 𝑙 2 (1 + μ sin2 (α1 − α2 )) 𝑝̇1 = −𝑚1 (1 + μ)𝑔𝑙1 sin α1 + 𝐴1 + 𝐴2 , 163
𝑝̇2 = −𝑚1 μ𝑔𝑙 sin α2 + 𝐴1 − 𝐴2, (5.14) 𝑝1 𝑝2 sin(α1 − α2) 𝐴1 = , 2 𝑚1 𝑙 (1 + μ sin2 (α1 − α2)) (𝑝12 μ − 2𝑝1 𝑝2 μ cos(α1 − α2) + 𝑝22 (1 + 𝜇)) sin(2(α1 − α2 )) 𝐴2 = ; 2𝑚1 𝑙 2 (1 + μ 𝑠𝑖𝑛2 (α1 − α2 )) 𝑚2 μ= . 𝑚1 Реализацию программы для моделирования двойного маятника начнём с преобразования углов 𝛼1 , 𝛼2 в координаты (блокнот e05_06.py). def coords(a1, a2, l1=1, l2=1): """ Пересчет углов в координаты двойного маятника a1, a2 - углы в радианах l1, l2 - длины плеч маятника Функция возвращает x1, y1, x2, y2 - координаты грузов маятника """ x1 = l1 * np.sin(a1) y1 = -l1 * np.cos(a1) x2 = l1 * np.sin(a1) + l2 * np.sin(a2) y2 = -l1 * np.cos(a1) - l2 * np.cos(a2) return x1, y1, x2, y2 Функция rh_dp возвращает правые части системы из четырёх дифференциальных уравнений. def rh_dp(t, y, m1=1, mu=1, l=1, g=9.81): a1, a2, p1, p2 = y # распаковка a1d = (p1 - p2 * np.cos(a1 - a2)) / ( m1 * l**2 * (1 + mu * np.sin(a1 - a2) ** 2) ) a2d = (p2 * (1 + mu) - p1 * mu * np.cos(a1 - a2)) / ( m1 * l**2 * (1 + mu * np.sin(a1 - a2) ** 2) ) A1 = ( p1 * p2 * np.sin(a1 - a2) / (m1 * l**2 * (1 + mu * np.sin(a1 - a2) ** 2)) ) A2 = ( (p1**2 * mu - 2 * p1 * p2 * mu * np.cos(a1 - a2) \ + p2**2 * (1 + mu)) * np.sin(2 * (a1 - a2)) / (2 * m1 * l**2 * (1 + mu * np.sin(a1 - a2) ** 2) ** 2) ) p1d = -m1 * (1 + mu) * g * l * np.sin(a1) - A1 + A2 p2d = -m1 * mu * g * l * np.sin(a2) + A1 - A2 return a1d, a2d, p1d, p2d 164
Решение системы visualize_double_pendulum. и визуализацию обеспечивает функция def visualize_double_pendulum( m1=1.0, # масса первого маятника mu=1.3, # m2/m1 - отношение масс alpha1=np.pi / 2, # начальное отклонение 1-го маятника alpha2=np.pi / 2, # начальное отклонение 2-го маятника tmax=10, # время интегрирования n=10000, # число разбиений отрезка [0, tmax] l=1.0, # длины плеч маятника g=9.81, # ускорение земного притяжения method="BDF", # метод интегрирования figsize=(4, 4), # размер изображения lw=1, # толщина линии lw2=0.5, # толщина линии fn="", # путь к файлу для сохранения картинки aspect=True, # признак равных масштабов по осям координат axis=True, # признак отключения отображения осей координат mode=1, # тип визуализации ): # начальные условия y0 = [alpha1, alpha2, 0.0, 0.0] # массив времен t = np.linspace(0, tmax, n) # дополнительные параметры # m1, mu, g, l args = m1, mu, g, l # решение системы дифференциальных уравнений y = solve_ivp( rh_dp, # правые части системы уравнений [0, tmax], # временной интервал y0, # начальные условия t_eval=t, # массив времен method=method, # метод решения dense_output=True, args=args, # дополнительные параметры ) # пересчет в координаты a1, a2 = y.y[0, :], y.y[1, :] # углы x1, y1, x2, y2 = coords(a1, a2, l1=l, l2=l) # визуализация if mode == 1: # зависимости y1 vs x1, y2 vs x1 fig, ax = plt.subplots(nrows=1, ncols=1, figsize=figsize) ax.plot(x1, y1, lw=lw, label="$(x_1, y_1)$") ax.plot(x2, y2, lw=lw2, label="$(x_2, y_2)$") if aspect: ax.set_aspect(1) ax.set_xlabel("x") ax.set_ylabel("y") plt.legend() if axis: plt.axis("off") 165
else: # трехмерная зависимость колебаний от времени fig = plt.figure(figsize=figsize) ax = plt.subplot(121, projection="3d") ax.plot(t, x1, y1, color="b", lw=lw, label="$x_1, y_1=f_1(t)$") ax.set_xlabel("t") ax.set_ylabel("x") ax.set_zlabel("y") plt.legend() ax = plt.subplot(122, projection="3d") ax.plot(t, x2, y2, color="r", lw=lw, label="$x_2, y_2=f_2(t)$") ax.set_xlabel("t") ax.set_ylabel("x") ax.set_zlabel("y") plt.legend() if axis: plt.axis("off") plt.tight_layout() if fn: plt.savefig(fn, dpi=300, facecolor="white") return x1, y1, x2, y2, fig Визуализация осуществляется двумя способами. Можно получить трёхмерные зависимости координат маятников от времени. На рисунке 5.27 изобраπ жены малые колебания маятника при начальных условиях α1 = 0, α2 = (150 ) 12 и отношении масс маятников μ = 0,2. Рис. 5.27 𝜋 Малые колебания двойного маятника при начальных условиях 𝛼1 = 0, 𝛼2 = 12 и 𝜇 = 0,2 Даже в режиме малых колебаний у двойного маятника возникает такое явление, как эффект биений. А при увеличении энергии характер колебаний маятников меняется принципиально — колебания становятся хаотическими. Вот так колебания двойного маятника выглядят при увеличении начальный углов отклонения (рис. 5.28). 166
Рис. 5.28 Колебания двойного маятника при начальных условиях 𝛼1 = 2,6, 𝛼2 = 1,5 и 𝜇 = 1 Если некоторая регулярность колебаний первого маятника ещё сохраняется, то колебания второго маятника хаотичны. Более наглядно выглядит хаос траекторий второго маятника, если визуализировать зависимости 𝑦1 (𝑥1 ), 𝑦2 (𝑥2 ). Для первого маятника траектория отображается синим цветом, а для второго оранжевым (рис. 5.29). а б Рис. 5.29 Зависимости 𝑦1 (𝑥1 ), 𝑦2 (𝑥2 ) для колебания двойного маятника: а — α1 = 2,6, α2 = 1,5, μ = 2, 𝑡max = 5000; б − α1 = 2,6, α2 = 1,5 μ = 2, 𝑡max = 5000. При работе с двойным маятником метод Рунге — Кутта ‘RK45’, используемый по умолчанию в solve_ivp, а также и ‘RK23’ перестают работать при больших временах интегрирования, приходится пользоваться методами ‘BDF’ или ‘DOP853’. Несмотря на то, что двойной маятник можно описать системой нескольких обыкновенных дифференциальных уравнений, то есть вполне детерминированной моделью, появление хаоса выглядит очень необычно. Попробуйте поэкспериментировать с блокнотом 05_06.py, понаблюдайте за траекториями движения 167
двойного маятника при различных отношениях масс тел и начальных углах, имея в виду, что интегрирование системы дифференциальных уравнений может занять некоторое время. Задания читателям 1. Воспроизведите расчёты, проделанные на этих занятиях. 2. Создайте расчёт затухающего маятника на эластичной подвеске. 3. Создайте модель гравитационного поезда, едущего не по прямолинейному туннелю, а по дуге окружности с радиусом, равным двум радиусам (диаметру) Земли. 4. Проведите вычислительные эксперименты с волками и зайцами. При каких условиях эта простая экосистема вымирает? 5. Снабдите модель генератор с туннельным диодом пользовательским интерфейсом. Проведите вычислительные эксперименты. 6. Каким условиям должна отвечать вольтамперная характеристика для обеспечения генерации? Проведите вычислительные эксперименты. 168
ГЛАВА 6 ЗАНЯТИЯ 11 И 12. МИСТЕР X ИЛИ НЕМНОГО СТАТИСТИКИ Из числа всей её челяди самым замечательным лицом был дворник Герасим, мужчина двенадцати вершков роста, сложенный богатырем и глухонемой от рожденья. И. С. Тургенев, «Муму» Какой-то <...> огромный вершков двенадцати господин, тоже необычайно толстый, чрезвычайно мрачный и молчаливый и, очевидно, сильно надеявшийся на свои кулаки. Ф. М. Достоевский, «Идиот» А фельдъегерь уж там, понимаете, и стоит: трехаршинный мужичина какой-нибудь, ручища у него, можете вообразить, самой натурой устроена для ямщиков, — словом, дантист эдакой... Н. В. Гоголь, «Мёртвые души» Для лучшего понимания темы рассмотрим несколько определений. Функция (от лат. functio — исполнение, осуществление) — это закон f, по которому каждому элементу x из одного числового множества ставится в соответствие некоторый определённый элемент y из другого числового множества, так что y = f(x). Корреляция (от лат. correlatio — соотношение) — вероятностная или статистическая зависимость, не имеющая строго функционального характера из-за невозможности точно учесть влияние множества одновременно меняющихся факторов. Регрессия (от лат. regressio — обратное движение) — такая зависимость среднего значения какой-либо величины от некоторой другой величины или от нескольких величин, что, в отличие от функциональной зависимости y = f(x), одному и тому же значению переменной x могут соответствовать в зависимости от случая различные значения величины y. Тренд — основная тенденция изменения чего-либо. Гистограмма — способ представления табличных данных в графическом виде — в виде столбчатой диаграммы. Интерполяция (от лат. interpolation — изменение, переделка) — способ нахождения промежуточных значений величины по имеющемуся дискретному 169
набору известных значений этой величины, т. е. восстановление (обычно приближённое) функции по её нескольким известным значениям. Аппроксимация (от лат. proxima — ближайшая) — нахождение по ряду имеющихся данных такой таблицы значений функции, которая приблизительно соответствует искомой и позволяет определить значения функции в точках, отличных от уже известных. Экстраполяция (от лат. extra — вне, снаружи, за, кроме и лат. polio — выправляю, изменяю) — в математике и статистике особый тип аппроксимации, при котором функция аппроксимируется вне заданного интервала, а не между заданными значениями. В более общем смысле экстраполяция — перенос выводов, сделанных относительно какой-либо части объектов или явлений, на всю совокупность данных объектов или явлений, а также на их другую какую-либо часть. В сентябре каждого года в родном вузе авторов — в МЭИ — проводятся два конкурса: «Мисс Первокурсница» и «Мистер Первокурсник». Авторы предлагают провести более предметный конкурс в рамках занятия, используя объективные данные — вес и рост участников конкурса, например. 6.1. Статистическая выборка студентов Когда-то перед лекцией на тему «Регрессионный анализ» в рамках курса «Информационные технологии» авторы подбирали пример статистической выборки для такого анализа. Рис. 6.1 Студенты НИУ «МЭИ» 170
Была проведена перекличка студентов. Юноши через чат или иным способом сообщали лектору свой вес и рост, а также номер своей учебной группы. Полученная информация, но не вся, а выборка из выборки — данные юношей одной учебной группы курса — заносилась в массив NumPy data с двумя строками и двадцатью столбцами, которые затем были превращены в два массива с именами height (рост) и weight (рост). Можно было бы ограничиться списками или кортежами, но работать с массивами при статистической обработке данных гораздо удобнее, что будет показано ниже (блокнот e06_01.py). import marimo as mo import numpy as np import matplotlib.pyplot as plt import pandas as pd import seaborn as sns data = np.array([ [189, 177, 179, 183, 180, 185, 178, 186, 176, 185, 184, 180, 180, 188, 183, 195, 194, 188, 182, 195], [ 90, 80, 73, 73, 85, 83, 75, 75, 77, 73, 76, 74, 68, 81, 79, 88, 75, 79, 85, 80], ]) height, weight = data[0, :], data[1, :] Здесь мы, кроме обычных marimo, NumPy и matplotlib, импортировали библиотеки pandas и seaborn, широко применяемые для обработки и визуализации статистических данных. 6.2. Статистические характеристики В массивах height и weight содержатся данные о двадцати студентах. Покажем, как выяснить характеристики массивов (количество элементов, минимальные и максимальные значения): (len(height), len(weight), np.min(height), np.min(weight), np.max(height), np.max(weight)) Мы получим результат: 20, 20, 176, 68, 195, 90 Это не всё, так же просто можно получить статистические характеристики наших массивов: (np.mean(height), np.mean(weight), np.median(height), np.median(weight), np.std(height), np.std(weight), np.var(height), np.var(weight), np.corrcoef(height, weight)) Результат: [ 184.35, 78.45, [[1., 0.362] [0.362, 1. ]] 183.5, 78.0, ] 171 5.61, 5.50, 31.53, 30.25,
В рассматриваемом случае массивы данных вполне обозримы, и их можно просмотреть. Что же делать, если в массивах десятки тысяч или даже миллиарды элементов? Информацию о них необходимо свернуть. Именно для этого служат статистические характеристики. Диапазон, в котором находятся значения элементов, определяется минимум и максимумом, для них в NumPy имеются функции, применимые как к одномерным, так и многомерным массивам. Рост студентов в выборке лежит в диапазоне [176, 195] см, а вес [68, 90] кг. Отметим, что минимум и максимум — неустойчивые характеристики, зависящие от значения единственного элемента в массиве, и могут варьироваться в широких пределах. Общеизвестной статистической характеристикой является среднее (mean), получаемое сложением всех элементов массива и последующим делением на число элементов. Очевидно, что среднее значение находится всегда между минимальным и максимальным значениями, в нашем случае средний рост 184,35 см, а вес 78,45 кг. Если значения в выборке не очень отличаются друг от друга, эта характеристика соответствует нашему интуитивному представлению. А вот если они отличаются сильно, то можно получить достаточно странные результаты. Представим себе ситуацию маленькой фирмы из десяти сотрудников, в которой руководитель получает заработную плату миллион рублей, а остальные сотрудники по 50 тысяч. Казалось бы, что среднее должно быть близко к 50 тысячам, однако это не так: salaries = np.array([1_000_000, 50_000, 50_000, 50_000, 50_000, 50_000, 50_000, 50_000, 50_000, 50_000]) len(salaries), np.mean(salaries), np.median(salaries) Результат: [ 10, 145000.0, 50000.0] Среднее значение почти в три раза выше, чем заработная плата девяти из десяти сотрудников, что заставляет вспомнить крылатое выражение: «Существуют три вида лжи: ложь, наглая ложь и статистика», ошибочно приписываемое Б. Дизраэли и М. Твену42. Как это всегда бывает, дело не в данных, а их интерпретации. Нам необходимо использовать характеристику, отражающую интуитивное представление о среднем. Такой характеристикой может служить медиана (median). Рассчитывается она несколько сложнее: массив данных упорядочивается по возрастанию, медианой является значение, слева и справа от которого располагается одинаковое число элементов массива. Если в массиве нечётное число элементов, то медианой становится элемент с индексом n//2 (индексация начинается с нуля). Если число элементов чётное, то медиана равна полусумме элементов с индексами n//2-1 и n//2, но даже этого не нужно запоминать, так как за нас вычислит медиану функция np.median. В случае заработных плат медианное значение равно 50_000, а для веса и роста медианы и средние близки друг к другу. Считается, что медиана более устойчивая (робастная) характеристика, и дополнительно может служить признаком несимметричности распределения данных, если среднее и медиана сильно отличаются друг от друга. 42 Ложь, наглая ложь и статистика. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/ Ложь,_наглая_ложь_и_статистика (дата обращения 20.04.2026). 172
Мы обсудили, как можно характеризовать условную «середину», перейдем теперь к характеристикам разброса значений. Таких три: размах, вариация и среднеквадратическое отклонение43. Размах для массива вычисляется как как разность между максимальным и минимальным значениями. Для вычисления вариации достаточно сложить квадраты разностей значений элементов и среднего значения и разделить на число элементов. Среднеквадратичное отклонение равно корню квадратному из вариации. В NumPy для вычисления характеристик разброса используются функции np.ptp, np.var и np.std. Мы свели статистические характеристики трёх массивов в таблицу и скомпоновали её с помощью marimo (рис. 6.2). Рис. 6.2 Статистические характеристики для зарплат в организации, веса и роста студентов Исходный текст для компоновки таблицы приводится ниже: vars = [ {"Массив":"Зарплаты", "Среднее":f"{np.mean(salaries):6.2e}", "Медиана":f"{np.median(salaries):6.2e}", "Размах": f"{np.ptp(salaries):6.2e}", "Вариация":f"{np.var(salaries):6.2e}", "СКО":f"{np.std(salaries):6.2e}"}, {"Массив":"Рост", "Среднее":f"{np.mean(height):6.2e}", "Медиана":f"{np.median(height):6.2e}", "Размах": f"{np.ptp(height):6.2e}", "Вариация":f"{np.var(height):6.2e}", "СКО":f"{np.std(height):6.2e}"}, {"Массив":"Вес", "Среднее":f"{np.mean(weight):6.2e}", "Медиана":f"{np.median(weight):6.2e}", "Размах": f"{np.ptp(weight):6.2e}", "Вариация":f"{np.var(weight):6.2e}", "СКО":f"{np.std(weight):6.2e}"}, ] mo.ui.table(data=vars, label="Таблица 6.1. Статистические характеристики для трех массивов" ) До сих пор мы говорили только об отдельных массивах данных, но хотелось бы знать, насколько данные одного массива связаны с данными другого 43 Вариация (статистика). [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Вариация_(статистика) (дата обращения 20.04.2026). 173
массива. Ответ на этот вопрос даёт корреляция. По определению44 корреляция (от лат. correlatio «соотношение»), или корреляционная зависимость — статистическая взаимосвязь двух или более случайных величин (либо величин, которые можно с некоторой допустимой степенью точности считать таковыми), при этом изменения значений одной из этих величин сопутствуют систематическому изменению значений другой или других величин. В NumPy функция np.corrcoef возвращает двумерный массив с коэффициентами корреляции: np.corrcoef(height, weight) Результат выполнения этой функции представлен ниже: array([[1. , 0.36244123], [0.36244123, 1. ]]) Это означает, что рост и вес имеют тесную связь сами с собой (диагональные элементы массива) с коэффициентом корреляции равным единице, а вот связь между ростом и весом (а также весом и ростом) не очень сильная и равна 0,36. Вообще-то при расчёте коэффициентов корреляции предполагается линейная зависимость, а их значения лежат на отрезке [–1, 1]. Нулевой коэффициент корреляции характеризует отсутствие зависимости, отрицательные значения — уменьшение значений одного набора данных при увеличении другого. Единичное значение корреляции говорит о тесной связи наборов данных. Здесь мы намеренно не приводим формул для расчёта коэффициентов корреляции. Обратитесь к любому учебнику по математической статистике. 6.3. Представление и визуализация данных С табличным и графическим представлением данных в экосистеме Python всё отлично, осуществить это можно большим числом способов, несмотря на заповеди Python45: «Должен существовать один и, желательно, только один очевидный способ сделать это. Хотя он поначалу может быть и не очевиден, если вы не голландец». Способов много, а большинство из них очень просты. Начнём с нашей палочки-выручалочки, библиотеки matplotlib. Данные о росте и весе студентов изобразим в виде диаграммы рассеяния (рис. 6.3). _fig = plt.figure(figsize=(4, 4)) plt.scatter(height, weight, marker='s', s=20) plt.xlabel('Рост, см') plt.ylabel('Вес, кг') _fig В экосистеме имеется библиотека seaborn, производная от matplotlib и специально ориентированная на статистическую визуализацию. В отличие от всеядной matplotlib, которая принимает данные практически в любой форме, для работы с seaborn необходимо подготовить специальную таблицу — фрейм данных 44 Что такое корреляция в статистике и как её правильно понимать. [Электронный ресурс]. — URL: https://skillbox.ru/media/code/chto-takoe-korrelyaciya-prostymi-slovami/?ysclid=mo8flmews733118298 (дата обращения 21.04.2026). 45 Zen of Python: история, реализация и пасхалки. [Электронный ресурс]. — URL: https://habr.com/ru/articles/825884/ (дата обращения 21.04.2026). 174
(dataframe) библиотеки pandas или более новой polars, представляющий собой двумерную таблицу с именованными столбцами. Рис. 6.3 Диаграмма рассеяния, построенная с помощью matplotlib Это привносит небольшие дополнительные накладные расходы, зато становится удобно работать с разнородными данными, так как столбцы фрейма данных могут быть различных типов, чего не может быть в массивах NumPy, которые должны быть однородными. Кроме того, в библиотеки встроено много всего, что позволяет вводить данные из различных источников, обрабатывать их и представлять. Если собираетесь работать с данными, то знакомство хотя бы с одной из этих библиотек является необходимым условием успеха [32, 33]. Создание фрейма данных не представляет собой никаких трудностей. import pandas as pd # Создаем и выводим фрейм данных в marimo height_weight = pd.DataFrame({'рост':height, 'вес':weight}) height_weight Дополнительным бонусом использования фреймов данных является то, что с ними работают большинство современных библиотек. На рисунке 6.4 показано, как будет выведен фрейм данных в ячейке marimo. Кроме аккуратной таблицы с постраничной разбивкой мы получили небольшие гистограммы данных в столбцах. О том, как строятся гистограммы, будет рассказано далее. Но и это не всё. С помощью одной строки — вызова метода describe — можно сразу получить статистические характеристики данных (рис. 6.5): height_weight.describe() В описание входят: • числа элементов (count) в каждом столбце; • средние (mean); • среднеквадратичное отклонение (std); 175
• минимальные значения (min); • 25%, 50% и 75% процентили; • максимальные значения (max). Рис. 6.4 Вывод фрейма данных в ячейке marimo Рис. 6.5 Статистические характеристики фрейма данных height_weight Здесь необходимо пояснить, что такое процентили. Под процентилем q понимается значение, для которого q% данных меньше или равно этому значению. Медиана представляет собой 50% процентиль, поэтому отдельно не указывается. 176
Вернёмся к рисунку 6.5. Фреймворк marimo позволяет искать данные и визуализировать их без написания дополнительного кода. Для этого достаточно нажать кнопку вверху слева и настроить с помощью мыши, какие данные и как отображать (рис. 6.6). Рис. 6.6 Визуализация фрейма данных в ячейке marimo Вот теперь перейдём к отображению данных средствами библиотеки seaborn. Сначала воспользуемся функцией lmplot, которой передаются фрейм данных и столбцы, данные из которых необходимо отобразить (рис. 6.7). sns.lmplot(data=height_weight, x="рост", y="вес", height=4) Рис. 6.7 Отображение фрейма данных с помощью функции lmplot библиотеки seaborn Кроме визуализации, функции seaborn выполняют дополнительную обработку данных, в частности, lmplot построила линию линейной регрессии (о регрессии мы 177
поговорим чуть ниже) и закрасила область 𝑟(𝑥) ± σ(𝑥), где 𝑟(𝑥) — линия регрессии, а 𝜎(𝑥) — среднеквадратическое отклонение. Другой тип графика — jointplot — позволяет на одном рисунке отображать не только диаграмму рассеяния, но и гистограммы данных: sns.jointplot(data=height_weight, x="рост", y="вес", height=4) Рис. 6.8 Отображение фрейма данных с помощью функции jointplot библиотеки seaborn 6.4. Регрессия По определению46 регрессия (лат. regressio — обратное движение, отход) в теории вероятностей и математической статистике — односторонняя зависимость, устанавливающая соответствие между случайными переменными, то есть математическое выражение, отражающее связь между зависимой переменной у и независимой переменной х при условии, что это выражение будет иметь статистическую значимость. В отличие от чисто функциональной зависимости y = f(x), когда каждому значению независимой переменной x соответствует одно определённое значение величины y, при регрессионной связи одному и тому же значению x могут соответствовать различные значения величины y. В докомпьютерную и докалькуляторную эру на диаграмму рассеяния (рис. 6.8), построенную на миллиметровке, накладывали прозрачную линейку, двигали её вверх-вниз, поворачивали влево-вправо (изменение параметра b), пытаясь такими действиями «поймать» линию тренда — сделать ручной, в буквальном смысле этого слова, регрессионный анализ. Кстати, отметим, такая задача решалась очень хорошо, с помощью вычислений улучшить решение можно было не более чем на 10%. 46 Регрессия (математика). [Электронный (математика) (дата обращения 21.04.2026). ресурс]. 178 — URL: https://ru.ruwiki.ru/wiki/Регрессия_
Что же понимается под построением линии регрессии? Во-первых, необходимо зафиксировать зависимость между переменными, например, в нашем случае зададим линейную зависимость между ростом и весом: (6.1) вес(рост) = 𝑎 ∙ рост + 𝑏. В формуле (6.1) нам нужно определить коэффициенты 𝑎, 𝑏 так, чтобы линия регрессии была минимально удалена от точек нашего набора данных в некотором смысле: 𝑛−1 ∑(𝑎 ∙ рост𝑖 + 𝑏 − вес𝑖 )2 → min, 𝑖=0 (6.2) или 𝑛−1 ∑|𝑎 ∙ рост𝑖 + 𝑏 − вес𝑖 | → min. 𝑖=0 Таким образом, необходимо решать задачу оптимизации. Повторно нарушим заповедь Python, так как в экосистеме для нас имеется несколько готовых инструментов. Сначала вспомним, что функция в формуле (6.1) — это полином первой степени, а в NumPy встроены средства для работы с полиномами. Для построения модели, отвечающей формуле (6.1), достаточно сделать вызов: model1 = np.polyfit(height, weight, 1) Функции передаются две последовательности данных, в нашем случае независимая переменная height и зависимая переменная weight, а также степень полинома, для линейной модели (6.1) нам возвращается массив, состоящий из коэффициентов 𝑎, 𝑏: array([ 0.35500753, 13.00436127]). Мы можем поменять переменные местами, построив линейную зависимость роста студента от его веса: model2 = np.polyfit(weight, height, 1) При этом мы, естественно, получили другие коэффициенты модели: array([0.37003058, 155.32110092]). Совсем несложно построить квадратичную модель: 𝑤𝑒𝑖𝑔ℎ𝑡 = 𝑎 ∙ ℎ𝑒𝑖𝑔ℎ𝑡 2 + + 𝑏 ∙ ℎ𝑒𝑖𝑔ℎ𝑡 + 𝑐: model3 = np.polyfit(height, weight, 2) Мы получим следующий результат: array([ 5.36887093e-03, -1.64036692e+00, 1.98221711e+02]) Можно было бы написать функции для вычисления полиномов первой и второй степени, но за нас это опять-таки сделает NumPy, создав функции для работы с моделями. p1, p2, p3 = np.poly1d(model1), np.poly1d(model2), np.poly1d(model3) Теперь самое важное, нам нужно создать массивы для значений независимых переменных роста для первой и третьей модели, а также веса для второй модели, после чего вычислить соответствующие зависимые переменные: n = h = w = w1, 1000 np.linspace(170, 200, n) np.linspace(60, 100, n) h2, w3 = p1(h), p2(w), p3(h) 179
Остается визуализировать полученные результаты (рис. 6.9). _fig = plt.figure(figsize=(6,6)) # данные опроса plt.scatter(height, weight, marker='s', s=20, label="Данные опроса") plt.plot(h, w1, lw=3, label='model1') # модель 1 plt.plot(h, w3, lw=3, label='model3') # модель 3 plt.plot(h2,w, lw=3, label='model2') # модель 2 plt.xlabel('Рост, см') plt.ylabel('Вес, кг') plt.legend() _fig Рис. 6.9 Визуализация трёх линий регрессии Давайте разбираться, что у нас получилось. На рисунке 6.9 синими квадратными маркерами нанесены результаты опроса. Мы трижды аппроксимировали результаты опроса, построив три модели. Почему аппроксимация? Дело в том, что прямые и квадратичная парабола не проходят через все точки опроса. Если бы это было так, то это называлось бы интерполяцией. Для моделей мы задали более широкие диапазоны значений для роста и веса, чем получили в результате опроса, что позволило получить экстраполяцию — предсказать значения зависимых переменных вне диапазона значений экспериментальных данных. 180
И, наконец, построенные модели дают существенно различные предсказания, которые, более того, зависят от выбора независимых и зависимых переменных! В то же время линии линейной регрессии дают интуитивно ожидаемый результат, именно так проводились линии регрессии в докомпьютерную эпоху, хотя здесь мы несколько утрируем, такие расчёты проводились тогда с помощью карандаша, бумаги и логарифмической линейки. То, что мы сделали выше, — это использование «чёрного ящика». Мы не знаем, каким образом было выполнено вычисление коэффициентов полиномов. Хотя и здесь мы несколько покривили душой, т. к. достаточно сделать вызов help(np.polyfit), чтобы выяснить, что для решения задачи использовался метод наименьших квадратов. Более того, прелесть экосистемы Python состоит в том, что большинство библиотек поставляется в исходных текстах, так что, зайдя в папку виртуального окружения и выполнив текстовой поиск по имени функции, можно посмотреть, как она устроена. Раз уж был упомянут метод наименьших квадратов, вычислим коэффициенты для первой модели. Нам нужно будет решить систему из 20 уравнений с двумя неизвестными: 𝑎 ∙ 189 + 𝑏 = 90, 𝑎 ∙ 177 + 𝑏 = 80, … 𝑎 ∙ 195 + 𝑏 = 80. В левую часть уравнений подставляется рост студента, а в правую его вес. Система из двадцати уравнений с двумя неизвестными не имеет точного решения, но метод наименьших квадратов решит задачу, представленную в верхней строке выражения (6.2). Для её решения нам нужно будет создать массив матрицы системы уравнений и массив правых частей. Делать это вручную не хотелось бы, поэтому напишем простой скрипт: m = len(height) mat = np.ones((m, 2)) # заготовка матрицы системы mat[:, 0] = height # коэффициенты при a # Для b ничего не делаем, т. к. коэффициенты равны 1 Система составлена, её можно решать. Для решения используем функцию lstsq, которой необходимо передать матрицу системы и массив правых частей. Возвращает функция массив коэффициентов модели, а также дополнительную информацию, включая сумму квадратов погрешностей, ранг матрицы системы и её сингулярные числа. Дополнительная информация нам не нужна, и мы её отбросим. from numpy.linalg import lstsq # метод наименьших квадратов # Решаем переопределенную систему линейных уравнений # методом наименьших квадратов (a, b), *rest = lstsq(mat, weight) a, b Мы получили точно тот же результат, что и с помощью np.polyfit: 0.35500753310601857, 13.004361271905536 181
6.5. Выбор модели регрессии Казалось бы, чем сложнее модель, тем лучше она предсказывает промежуточные значения и осуществляет экстраполяцию. Однако это не так, когда мы имеем дело с зашумленными данными, которые нарушают функциональную зависимость. В блокноте e06_02.py мы это покажем на примере. Прежде всего сгенерируем зашумленные данные. Пусть у нас имеется функция f, требуется сгенерировать n значений функции, к которым добавлены случайные нормально распределённые значения с нулевым средним и среднеквадратическим отклонением s. def get_data(n=200, f=np.sin, ab=(0, 2*np.pi), s=0.3): x = np.random.uniform(ab[0], ab[1],n) y = f(x) + np.random.normal(0, s, n) return x, y Функция возвращает значения независимой переменной, которые случайно распределены по равномерному закону на отрезке ab, и зашумленные значения функции. Это понадобится нам далее для деления данных на обучающие и проверочные. На рисунке 6.10 приводятся сгенерированные зашумленные данные (синие крестики) со среднеквадратическим отклонением 𝑠 и модель — полином третьей степени (красная линия). Рис. 6.10 Зашумленные данные и полиномиальная модель третьей степени Построение полиномиальной модели мы обсуждали ранее, поэтому для её построения напишем функцию: def build_model(x, y, m=1): model = np.polyfit(x, y, m) return np.poly1d(model) 182
Функции передаются данные по осям координат и степень полиномиальной модели, возвращает она функцию, которая будет использоваться при построении линии регрессии. Нам потребуется сравнивать модели между собой, для чего мы воспользуемся функцией, вычисляющей среднеквадратическое отклонения между переданными ей данными и данными, полученными с помощью модели. def std_error(x, y, model): return np.std(y - model(x)) Смысл наших дальнейших действий сводится к разбиению наших данных на обучающую и проверочную последовательности, вычислению коэффициентов модели на обучающей последовательности, а вот погрешности предсказания мы будем вычислять на обеих последовательностях и строить зависимости погрешностей от степени (сложности) модели. Именно этим занимается функция: def test_models(n=200, f=np.sin, ab=(0, 2*np.pi), s=0.3, part=0.5, ms=(1,3,5,7,9,11,13,15,17,19), figsize=(6, 4)): x, y = get_data(n=n, f=f,ab=ab, s=s) # формируем обучающую и проверочную последовательности ip = int(n*part) x_learn, y_learn = x[:ip], y[:ip] x_test, y_test = x[ip:], y[ip:] # Погрешности для моделей errs_learn = [] errs_test = [] for i, m in enumerate(ms): # строим модель степени m model = build_model(x_learn, y_learn, m=m) err = std_error(x_learn, y_learn, model) errs_learn.append(err) err = std_error(x_test, y_test, model) errs_test.append(err) fig = plt.figure(figsize=figsize) plt.plot(ms,errs_learn, 'b-', lw=3, label="обучающие данные") plt.plot(ms,errs_test, 'r-', lw=3, label="проверочные данные") plt.xlim(0.5, 18.5) plt.xlabel('Степень модели') plt.ylabel('Погрешности') plt.yscale('log') plt.legend() plt.title(f"СКО:{s}") return fig Обратите внимание на формирование обучающей и проверочной последовательностей. Благодаря тому, что данные по горизонтальной оси сформированы случайным образом, нам достаточно просто сделать срезы массивов x и y. В заголовке функции для этого задан параметр part, указывающий, какую часть данных следует отнести к обучающей последовательности. Функция возвращает объект рисунка с зависимостями погрешностей на обучающей и проверочных последовательностях от сложности модели (рис. 6.11). 183
Рис. 6.11 Зависимости погрешностей на обучающей и проверочной последовательностях от сложности полиномиальной модели: а — данные без шума; б — СКО шума равно 0,3. На рисунке 6.11а, на котором модели построены на данных без шума, всё ожидаемо: погрешности уменьшаются при усложнении модели, а зависимости погрешностей на обучающей и проверочной последовательностях практически совпадают. Для данных с шумом всё гораздо интереснее (рис. 6.11б). Погрешность на обучающей последовательности снижается, хотя и не так сильно, как на точных данных, а вот погрешность на проверочной последовательности не только не уменьшается, но и увеличивается. Мораль проведённого вычислительного эксперимента состоит в том, что не всегда более сложная модель приводит к лучшим результатам. Нужно, во-первых, строить несколько моделей, во-вторых, сравнивать их на проверочных данных, которые не участвовали в построении модели, а, в-третьих, выбирать из них оптимальную модель. Для нашего случая оптимальной является полиномиальная модель третьей степени. 6.6. Нелинейная аппроксимация Вычисление коэффициентов аппроксимирующих функций сводились к решению переопределённых систем линейных алгебраических уравнений. Что делать, если коэффициенты входят в аппроксимирующую функцию нелинейно? В этом случае необходимо решать задачу оптимизации, минимизируя погрешность между нашими данными и аппроксимирующей функцией. Для этого в экосистеме Python имеется функция scipy.optimize.curve_fit. Этой функции придаются аппроксимирующая функция, массивы данных по осям координат и, при необходимости, начальные значения параметров. Возвращает функция кортеж подобранных параметров, а также некоторые другие данные47. 47 scipy.optimize.curve_fit [Электронный ресурс]. — URL: https://docs.scipy.org/doc//scipy-1.9.3/reference/ generated/scipy.optimize.curve_fit.html (дата обращения 22.04.2026). 184
В качестве примера рассмотрим задачу, которая встречается при обработке оптических спектров, когда необходимо получить параметры нескольких пиков, ∑𝑛𝑖=0 𝑎𝑖 2 (𝑥−𝑥𝑖 ) 2 2𝜎 − описываемых в виде суммы «колоколов»: 𝑒 . Всё можно сделать вручную, когда колокола не перекрывают друг друга, а вот когда они накладываются, то это может вызвать трудности. Начнём с функции для колоколов, которая называется гауссовой функ48 цией (блокнот 06_03.py). def gaussian(x, a, x0, sigma): '''Единичный колокол''' return a * np.exp(-(x-x0)**2/(2*sigma**2)) Параметр a определяет высоту колокола, x0 — центр на оси абсцисс, а sigma — его ширину. Ошибку аппроксимации мы будем вычислять с помощью функции err. def err(f, x, y, c): ''' Вычисление погрешности аппроксимации''' return np.max(np.abs(y-f(x, *c))) Для построения и визуализации аппроксимации подготовлена функция test_approximation. def test_approximation(f, p, n=1000, xminmax=(0., 10), s=0., p0=None, figsize=(6, 4), lw=3): # генерируем данные x = np.linspace(*xminmax, n) y = f(x, *p) + np.random.normal(0, s, n) # ищем коэффициенты аппроксимирующей функции if p0: с = curve_fit(f, x, y, p0=p0) else: с = curve_fit(f, x, y) # аппроксимация ya = f(x, *с[0]) error = err(f, x, y, с[0]) # визуализация fig = plt.figure(figsize=figsize) plt.plot(x, y, 'b-', lw=lw, label='Исходные данные') plt.plot(x, ya, 'r-', lw=lw, label='Аппроксимация') plt.xlabel('x') plt.ylabel('y') plt.legend() return с[0], error, fig Функции передаются: f — функция, используемая для генерации данных и аппроксимации, p — кортеж значений параметров, передаваемый функции f при 48 Гауссова кривая [Электронный ресурс]. — URL: https://www.yaklass.ru/p/algebra/11-klass/nachalnye-svedeniiamatematicheskoi-statistiki-9176/zakon-raspredeleniia-veroiatnostei-zakon-bolshikh-chisel-10288/re-161d12ad-5034-4bae-8bcb204dcd940823 (дата обращения 22.04.2026). 185
генерации данных, n — число точек разбиения отрезка xminmax, s — среднеквадратическое отклонения аддитивного шума, добавляемого к данным, p0 — начальные значения параметров, передаваемые функции curve_fit в качестве дополнительной информации, figsize — размер рисунка, lw– толщина линий, которыми отображаются сгенерированные данные и аппроксимирующая кривая. Функция возвращает кортеж параметров, подобранный curve_fit, погрешность аппроксимации и объект рисунка matplotlib. Начнём мы с определения параметров единичного колокола, для чего сделаем два вызова: test_approximation(gaussian, p=(1, 5, 2)) test_approximation(gaussian, p=(1, 5, 2), s=.3) В первом случае сгенерированы данные при отсутствии шума, во втором случае данные сильно зашумлены. Результаты представлены на рисунке 6.12. Рис. 6.12 Аппроксимируем единичный колокол: а — s = 0; б — s = 0,3. В первом случае ошибка нулевая, аппроксимирующая кривая наложилась на исходную, при этом никакой дополнительной информации curve_fit не передавалось. Попытаемся решить более сложную задачу, восстановить параметры суммы двух колоколов, которые генерируются функцией: def gaussian2(x, a1, x01, sigma1, a2, x02, sigma2): '''Сумма двух колоколов''' return a1 * np.exp(-((x-x01)/sigma1)**2) +\ a2 * np.exp(-((x-x02)/sigma2)**2) В первом случае колокола практически не накладываются друг на друга, но если не задавать начальное приближение для curve_fit, то получим неудовлетворительное решение: только один колокол, а второй колокол не разрешится (рис. 6.13а): test_approximation(gaussian2, p=(1, 3, 2, 1.2, 8, 1)) Задание начального приближения зависит от задачи. В нашем случае два параметра, а именно: a — максимум и x0 — положение максимума по оси абсцисс, можно приблизительно определить из графика, sigma определяется существенно 186
менее точно, например, измерив ширину колокола на середине его высоты. Во всяком случае нужно передавать всю доступную нам информацию о параметрах: test_approximation(gaussian2, p=(1, 3, 2, 1.2, 8, 1), p0=(1.2, 2.5, 1, 1.1, 7,1) Рис. 6.13 Два не накладывающихся друг на друга колокола: а — начальное приближение не задано; б — задано начальное приближение с близкими значениями положений максимумов. После подбора начального приближения мы получили результат с нулевой погрешностью, более того, зашумление данных не слишком повлияло на результат (рис. 6.14): test_approximation(gaussian2, p=(1, 5, 2, 1.2, 7, 1), p0=(1, 5, 1, 0.8, 8, 1)) Рис. 6.14 Два наложенных друг на друга колокола: а — s=0,0; б — s=0,1. _p = np.array([1, 5, 2, 1.2, 7, 1]) _c, _error, _fig, = test_approximation(gaussian2, p=_p, p0=(1, 5, 1, 0.8, 8, 1), s=.3) fn='06_13b.png') err_p = np.max(np.abs(_p - _c)/_p) _fig, err_p Для зашумленных данных относительная погрешность определения параметров не превышает 0,1. 187
6.7. Гистограммы, или раскладываем все по полочкам Вернёмся к набору данных со студентами. Мы вычислили скалярные статистические характеристики, визуализировали распределения, но хотелось бы посмотреть, как распределены студенты по росту и весу. Ответ на этот вопрос дают гистограммы. Кстати, гистограммы уже отображались на рисунках 6.4 и 6.8, но делалось это без нашего участия. Строить гистограмму достаточно просто. Диапазон значений набора данных разделяем на разряды — «ящики», в которые будем раскладывать элементы набора данных. Мы можем равномерно разбить диапазон значений на разряды, а если нужно, объединить разряды, в которых мало данных. Считается, что в разряде должно быть не меньше десяти элементов. После этого рисуем столбчатую диаграмму, в которой высота столбца равна числу элементов в разряде-ящике. Считается удобным пересчитать высоты столбцов так, чтобы площадь под ними равнялась единице, тогда гистограмма становится дискретным аналогом плотности распределения вероятности. Если последовательно складывать высоты столбцов, то получится кумулятивная гистограмма — дискретный аналог закона распределения. Нам удалось уложить алгоритм построения гистограммы в один абзац. Средства matplotlib и seaborn автоматизируют построение гистограмм, но знать, как они строятся, всё-таки нужно, т. к. иногда требуется настройка. В matplotlib для построения гистограмм служит функция hist, которой придаются последовательность данных и, если необходимо, в именованном параметре bins — число или последовательность, определяющая разряды гистограммы. Кроме того, можно передать density = True для нормирования гистограммы и cumulative = True для отображения кумулятивного распределения. На рисунке 6.15 построены простая и кумулятивная гистограмма для роста студентов (см. блокнот e06_04.py). _fig, _axs = plt.subplots(nrows=1, ncols=2, figsize=(12,4)) _axs[0].hist(height, density=True) _axs[0].set_xlabel('Рост') _axs[0].set_ylabel('Частота') _axs[1].hist(height, density=True, cumulative=True) _axs[1].set_xlabel('Рост') _axs[1].set_ylabel('Частота') plt.tight_layout() _fig Рис. 6.15 Гистограммы роста студентов, построенные с помощью matplotlib 188
Точно так же строятся гистограммы для веса студентов (рис. 6.16). Рис. 6.16 Гистограммы matplotlib, построенные для веса студентов Гистограммы seaborn получаются стильными, задание параметра kde=True позволяет отобразить гладкую кривую распределения. Ниже приводится код для построения гистограмм с помощью seaborn. _fig, _axs = plt.subplots(nrows=2, ncols=2, figsize=(12, 8)) sns.histplot(height, kde=True, stat="density", ax=_axs[0, 0]) _axs[0, 0].set_xlabel("Рост") _axs[0, 0].set_ylabel("Частота") sns.histplot(height, cumulative=True, kde=True, stat="density", ax=_axs[0, 1]) _axs[0, 1].set_xlabel("Рост") _axs[0, 1].set_ylabel("Частота") sns.histplot(weight, kde=True, stat="density", ax=_axs[1, 0]) _axs[1, 0].set_xlabel("Вес") _axs[1, 0].set_ylabel("Частота") sns.histplot(height, cumulative=True, kde=True, stat="density", ax=_axs[1, 1]) _axs[1, 1].set_xlabel("Вес") _axs[1, 1].set_ylabel("Частота") plt.tight_layout() _fig Результаты построения гистограмм для веса и роста студентов приведены на рисунке 6.17. Несложно построить двумерную гистограмму в seaborn для роста и веса студентов: height_weight = pd.DataFrame({'рост':height, 'вес':weight}) _fig = plt.figure(figsize=(7, 6)) sns.histplot(data=height_weight, x='рост', y='вес', cmap='viridis',cbar=True, stat="density") 189
Рис. 6.17 Гистограммы seaborn, построенные для роста и веса студентов Результат представлен на рисунке 6.18. В matplotlib 90% процентов задач решаются просто, а вот с остальными приходится повозиться, именно так обстоит с построением трёхмерных гистограмм49. Рис. 6.18 Двумерная гистограмма seaborn, построенная для роста и веса студентов 49 3D гистограмма в Matplotlib [Электронный ресурс]. — URL: https://labex.io/ru/tutorials/python-matplotlib-3d-histogram-48768 (дата обращения 22.04.2026). 190
Трёхмерная гистограмма в matplotlib строится так (рис. 6.19): _fig = plt.figure(figsize=(11, 6)) _ax = _fig.add_subplot(111, projection='3d') hist, xedges, yedges = np.histogram2d(height, weight, density=True) ## Создаем массивы для мест размещения столбиков xpos, ypos = np.meshgrid(xedges[:-1] + 0.25, yedges[:-1] + 0.25, indexing="ij") xpos = xpos.ravel() ypos = ypos.ravel() zpos = 0 ## Создаем массивы с размерами 16 столбиков. dx = dy = 0.5 * np.ones_like(zpos) dz = hist.ravel() _ax.bar3d(xpos, ypos, zpos, dx, dy, dz, zsort='average') _ax.set_xlabel('Рост') _ax.set_ylabel('Вес') _ax.set_zlabel('Частота') plt.tight_layout() _fig Рис. 6.19 Трёхмерная гистограмма matplotlib, построенная для роста и веса студентов 191
Пример с ростом и весом студентов не слишком хорош тем, что в нем всего по двадцать элементов данных, гораздо интереснее, когда в наборах тысячи и более элементов, их статистические характеристики становятся устойчивыми. Для освоения приёмов построения гистограмм, в частности, выбора числа разрядов гистограмм, мы написали интерактивное приложение marimo, исходный текст представлен в блокноте e06_05.py. пользовательский интерфейс представлен на рисунке 6.20. Основным его назначением является научить подбирать число разрядов гистограммы для различных наборов данных. Рис. 6.20 Интерактивное учебное приложение marimo для построения гистограмм Нам давно хотелось посмотреть, как часто в текстах встречаются символы русского алфавита, для этого мы скачали в текстовом формате (см. блокнот e06_06.py) «Войну и мир»50 Л. Н. Толстого и словарь существительных русского языка51: def read_text(fn): with open(fn,encoding="utf8") as f: return f.read().lower() text1 = read_text(r"texts\Tolstoy_Lev_Voyna_i_mir_1-2.txt") +\ read_text(r"texts\Tolstoy_Lev_Voyna_i_mir_3-4.txt") text2 = read_text(r"texts\russian_nouns.txt") Далее мы преобразовали тексты в списки символов и исключили символы, не входящие в русский алфавит, т. к. в «Войне и мире» много текстов на французском 50 Война и мир. [Электронный ресурс]. — URL: https://kindbook.net/kniga/voyna-i-mir-tolstoy (дата обращения 22.04.2026). 51 Russian nouns. [Электронный ресурс]. — URL: https://github.com/Harrix/Russian-Nouns/blob/main/dist/ russian_nouns_without_filter.tx (дата обращения 22.04.2026). 192
языке. Кроме того, в допустимые символы русского алфавита мы добавили пробел и «ё». def ru_symbols(text, blank_e=True): # формируем русский алфавит a = ord('а') alphabet = [chr(i) for i in range(a,a+32)] if blank_e: alphabet.insert(0, ' ') alphabet.insert(7, 'ё') symbols = [s for s in text if s in alphabet] return symbols, alphabet symbs_tolstoi, _ = ru_symbols(text1) symbs_nouns_dict, _ = ru_symbols(text2) В «Войне и мире» оказалось 2 985 989 символов русского алфавита, включая пробелы, в словаре существительных русского языка — только 519 053. После этого мы на одном рисунке 6.21 построили гистограммы распределения символов в обоих наборах данных. alpha = 0.8 _fig,_ax = plt.subplots(nrows=1, ncols=1, figsize=(6, 4)) sns.histplot(symbs_tolstoi, stat="density", ax=_ax, alpha=alpha, label="Война и мир ") sns.histplot(symbs_nouns_dict, stat="density", ax=_ax, alpha=alpha, label="Словарь существительных русского языка") _ax.set_xlabel('Символы русского алфавита') _ax.set_ylabel('Частота') plt.grid() plt.legend() _fig Рис. 6.21 Гистограммы частоты вхождения символов русского алфавита в «Войну и мир» и словарь существительных русского языка 193
Как оказалось, наиболее часто встречающимся символом в романе Л. Н. Толстого является пробел, а вот словаре существительных русского языка он не встречается, т. к. слова в нем разделены символом новой строки Символ «ё» встречается только в словаре русского языка. А наиболее часто встречаются символы «а», «о», «и» и «е», причем их частоты не слишком сильно отличаются друг от друга. Практически ту же информацию, но в более компактной форме, предоставляют «скрипичные» графики, в которых ширина закрашенной части пропорциональна частоте, с которой данный элемент входит в набор данных. Вот так это можно сделать для романа Л. Н. Толстого и словаря существительных русского языка. _fig, _ax = plt.subplots(nrows=1, ncols=2, figsize=(6, 6)) sns.violinplot(symbs_tolstoi, ax=_ax[0]) _ax[0].set_xlabel("Война и мир") sns.violinplot(symbs_nouns_dict, ax=_ax[1]) _ax[1].set_xlabel('Словарь существительных') _fig Результат представлен на рисунке 6.21. Рис. 6.22 «Скрипичные» графики для «Войны и мира» и словаря существительных русского языка 194
Более обобщенную (свернутую) информацию можно получить с помощью «ящиков с усами» (рис. 6.23). _fig,_ax = plt.subplots(nrows=1, ncols=2, figsize=(6, 6)) sns.boxplot(symbs_tolstoi, ax=_ax[0]) _ax[0].set_xlabel("Война и мир") sns.boxplot(symbs_nouns_dict, ax=_ax[1]) _ax[1].set_xlabel('Словарь существительных') _fig Рис. 6.23 «Ящики с усами» для «Войны и мира» и словаря существительных русского языка Данный тип графиков нуждается в пояснения52. Нам придётся ввести термин квартиль — область, включающая в себя 25% набора данных. Квартилей четыре: Q1–Q4. Квартилю Q1 соответствует область от верхней горизонтальной черты до закрашенной области, Q2 — верхняя часть закрашенной области до горизонтальной линии, обозначающей медиану. Q3 — нижняя часть закрашенной 52 Boxplot, он же ящик с усами. [Электронный ресурс]. — URL: https://habr.com/ru/articles/879840/ (дата обращения 22.04.2025). 195
области, Q4 — область от нижней границы закраски до нижней горизонтальной черты, в вот круглыми маркерами обозначены данные, которые boxplot посчитал выбросами. Напомним, что seaborn кроме визуализации выполняет иногда нетривиальную статистическую обработку данных. Рассмотренные наборы данных можно использовать для криптоанализа русских текстов, зашифрованных заменой символов, по мотивам рассказа Э. По «Золотой жук». База данных, изготовленная из словаря существительных русского языка, пригодилась при разработке читерской программы для угадывания слов в игре Wordle53. Кстати, супруга одного из авторов отказалось её использовать, ссылаясь на приверженность правилам честной игры. 6.8. Интерполяция и экстраполяция А теперь поговорим об интерполяции — случае, когда линия проходит через точки данных, и используем для этого интерполяцию сплайнами. Сплайном54 (от англ. spline) называли гибкую металлическую линейку — универсальное лекало, которое английские и не только корабелы использовали для разметки деталей кораблей, прокладывая эту линейку между забитыми в деревянную заготовку гвоздями, затем обводя получившуюся гладкую кривую. Опорные гвозди делят кривую на участки, кривая на каждом участке описывается полиномом третьей степени, участки плавно переходят от одного к другому, на границах участков равны не только функции, но и первые и вторые производные 55. Сплайны получили широкое распространение в компьютерной графике и не только. Как оказалось, для сплайнов можно использовать полиномы не только третьей степени, а также решать задачи аппроксимации и экстраполяции [34]. В блокноте e06_07.py представлена интерполяция, аппроксимация, экстраполяция сплайнами различных степеней для набора данных, заданного двумя массивами: # Исходные данные x = np.array([1.0, 2.0, 3.0, 4.5, 5.0, 6.1, 7.0, 8.0, 8.9, 10.0, 11.0, 12.0]) y = np.array([ 4.0, 5.0, 7.0, 9.0, 6.0, 7.0, 11.0, 24.0, 34.0, 40.0, 37.0, 47.0]) Мы воспользуемся классом UnivariateSpline, он позволяет решать задачи интерполяции, аппроксимации, сглаживания и экстраполяции. Для работы с ним необходимо создать экземпляр класса, который используется как функция при решении всех перечисленных задач. При инициализации экземпляра класса конструктору необходимо передать исходные данные — две последовательности одинакового размера x и y. Степень полинома задаётся именованным параметром k. По умолчанию используется сплайн третьей степени. Именованный параметр s задаёт тип решаемой нами задачи. Если задать s = 0, то производится 53 Wordle. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Wordle (дата обращения 22.04.2026). Сплайн. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Сплайн (дата обращения 22.04.2026). 55 Это касается только кубических сплайнов. Для сплайнов других степеней используются другие условия сопряжения. 54 196
интерполяция — кривая проводится через все точки, заданные массивами x и y. По умолчанию s = None, при s>0 производится аппроксимация, чем больше s, тем более гладкой получается аппроксимирующая кривая. Как уже говорилось выше, экземпляр класса может быть вызван, ему передаётся координата точки по x, а он возвращает координату по y. На рисунке 6.24 рассмотрены различные ситуации использования сплайновой интерполяции, аппроксимации и экстраполяции. Ниже приводится исходный текст решения задачи интерполяции сплайнами различных степеней (рис. 6.24а). # Исходные данные from scipy.interpolate import UnivariateSpline as UVS # точки, где будет проводиться интерполяция _n = 1000 _x = np.linspace(1, 12, _n) _fig = plt.figure(figsize=(6, 4)) plt.plot(x, y, 'ko', ms=5, label="исходные данные") for _k in (1, 3, 5): # _k - степень сплайна _spl = UVS(x, y, k=_k, s=0) # интерполяция, s=0 _y = _spl(_x) plt.plot(_x, _y, label=f"k={_k}") plt.xlabel('x') plt.ylabel('y(x)') plt.legend() _fig Обратите внимание, что для интерполяции необходимо явно задавать s = 0. Чтобы не перегружать рисунок, экстраполяцию за пределы области исходных данных мы провели только для сплайнов первой и третьей степеней (рис. 6.24б). Интуитивно ожидаемой, на наш взгляд, является экстраполяция линейными сплайнами. На рисунке 6.24в, г представлены различные случаи аппроксимации линейными и кубическими сплайнами. При увеличении фактора сглаживания аппроксимация линейными сплайнами вырождается в прямую, а кубическими — в параболу. В классе UnvariateSpline имеется метод derivatives, которому передаётся значение координаты x, возвращает метод кортеж значения сплайна в этой точке и его производных. Это позволяет легко вычислить коэффициенты сплайна и показать, как выглядят элементы сплайна за пределами своего участка: def chunks(x, y, figsize=(6,4)): spl = UVS(x, y, k=3, s=0) # цвета для вычерчивания участков сплайна colors=['b','r','g','c','m', 'k', 'y'] n = len(x) xx = np.linspace(x[0],x[-1], 1000) fig = plt.figure(figsize=figsize) # кривая, полученная с помощью интерполяции plt.plot(xx, spl(xx), 'k-', lw=4) nc = len(colors) 197
for i in range(n): k = i%nc c = colors[k] d = spl.derivatives(x[i]) ii = max(i-3, 0) jj = min(i+3, n-1) xc = np.linspace(x[ii], x[jj], 200) xx = xc - x[i] # вычисляем и рисуем сплайн за пределами его участка yc = d[0] + d[1]*xx + d[2]/2*xx**2 + d[3]/6*xx**3 plt.plot(xc, yc, c+'-', lw=2, label=f"{i=}") plt.ylim(0, 50) plt.xlabel('x') plt.ylabel('y(x)') plt.legend() return fig chunks(x, y) а б в г Рис. 6.24 Интерполяция, экстраполяция и аппроксимация с помощью UnivatiateSpline: а — интерполяция сплайнами различных степеней; б — экстраполяция линейными и кубическими сплайнами; в — аппроксимация линейными сплайнами с различной степенью сглаживания; г — аппроксимация кубическими сплайнами с различной степенью сглаживания. Результат представлен на рисунке 6.25. 198
Рис. 6.25 Все кубические параболы, полученные при сплайн-интерполяции На рисунке 6.25 показано, как можно стереть лишнее, дорисовать недостающее и получить некое произведение изобразительного искусства с четким математическим смыслом (см. рис. 6.26). Рис. 6.26 Ветка дерева с плодами сплайн-интерполяции 6.9. От таблицы к функции Как получить интерполяционную функцию двух аргументов? Отвлечемся и вспомним сюжет одного старого польского фильма «Гангстеры и филантропы». Одного человека уволили с работы из-за того, что он по халатности устроил взрыв по месту службы в химической лаборатории. Он заходит в ресторан 199
и с горя заказывает себе стакан водки и закуску — селедку с хлебом (рис. 6.27). В печальных раздумьях этот химик машинально вытаскивает из нагрудного кармана пиджака ареометр и начинает им помешивать водку в стакане. Персонал думает, что это контролер, проверяющий крепость напитка (не разбавляют ли водку водой), и даёт ему взятку. Безработный химик догадывается, что к чему и начинает так «химичить» и в других ресторанах Варшавы. Рис. 6.27 Кадр из фильма «Гангстеры и филантропы» Ареометр — это один из подручных измерительных приборов химикованалитиков. Прибор представляет собой стеклянный поплавок со шкалой погружения. Ареометром можно измерить плотность раствора, а затем по ней определить его концентрацию. Автовладельцы старшего поколения сразу вспомнят свинцовые аккумуляторы с залитым в них раствором серной кислоты. Её концентрацию нужно было периодически проверять специальным ареометром и подливать в аккумулятор при необходимости дистиллированную воду56, которая со временем испарялась, что требовало новой проверки. Если шкалу ареометра разметить в градусах (процентах) спиртных напитков, то он будет называться градусником, вернее, спиртометром. Градусник — это совсем другой измерительный прибор, который тоже нужно опустить в раствор. Только мольная концентрация не зависит от температуры (отношение количества растворённого вещества к массе растворителя). Давайте оценим теплофизические параметры водных растворов спирта [4] в экосистеме Python. Если в интернете сделать запрос по контексту «Плотность водных растворов этанола», то будет выдано много информации, включая и таблицу, показанную на рисунке 6.28. Она отсканирована из книги Рабиновича В. А. и Хавина З. Я. «Краткий химический справочник» (3-е изд., СПб, Химия, 1991 г.). 56 В те времена один из авторов был аспирантом кафедры «Технологии воды и топлива» МЭИ, которая снабжала автомобилистов всего института дистиллированной водой. 200
Там же можно найти и другие такие таблицы из других бумажных справочников, сравнить их и убедиться, что они практически совпадают. Да, в интернете часто публикуется довольно сомнительная информация, которую всегда нужно проверять и перепроверять. Но и в бумажных справочниках, прошедших строгое рецензирование и редактирование, увы, также встречаются ошибки и опечатки. Так, в таблице на рисунке 6.28 указано, что плотность измеряется в г/см вместо правильной размерности г/см3. Рис. 6.28 Таблица плотности водных растворов этилового спирта Во время командировки в США один из авторов посетил Институт стандартов и технологий НИСТ (www.nist.gov). В компьютерном классе института попарно у мониторов сидели молодые люди и что-то там делали, шепчась и печатая на клавиатуре. Автор удивился и спросил у американских коллег, чем заняты эти студенты. Ему объяснили, что это студенты местного университета, подрабатывающие в НИСТ. Они вводили в компьютер информацию, подобную той, которая показана на рисунке 6.28. Первый студент диктует числа из бумажной книги или научного журнала, а второй вводит их в компьютер. При этом первый студент дополнительно контролирует правильность ввода, попеременно поглядывая то в справочник, то на монитор компьютера. В НИСТ пытались было сканировать такие таблицы и оцифровывать их автоматически. Но плохое качество печатного оригинала приводило к тому, что в итоговой оцифрованной таблице оказывалось много опечаток, которые приходилось выявлять и исправлять вручную. Проще и надежнее было сразу нанять студентов, дав им заодно возможность подработать. То же самое было сделано с таблицей на рисунке 6.28. # Весовой процент спирта ps = np.array([0., 4., 8., 12., 16., 20., 24., 28., 32., 36., 40., 44., 48., 52., 56., 60., 64., 68., 72., 76, 80., 84., 88., 92., 96., 100.]) # Температура в градусах Цельсия 201
ts = np.array([10., 15., 20., 40.]) # Весовой процент спирта pr = np.array([ [0.99973, 0.99913, 0.99823, [0.99218, 0.99105, 0.99103, [0.98660, 0.98584, 0.98478, [0.98145, 0.98041, 0.97910, [0.97692, 0.97552, 0.97387, [0.97252, 0.97068, 0.96864, [0.96787, 0.96558, 0.96312, [0.96268, 0.95996, 0.95710, [0.95655, 0.95357, 0.95038, [0.94986, 0.94650, 0.94306, [0.94238, 0.93882, 0.93518, [0.93433, 0.93062, 0.92685, [0.92593, 0.92211, 0.91823, [0.91723, 0.91333, 0.90936, [0.90831, 0.90433, 0.90031, [0.89927, 0.89523, 0.89113, [0.89006, 0.88597, 0.88183, [0.88074, 0.87660, 0.87241, [0.87127, 0.86710, 0.86287, [0.86168, 0.85747, 0.85322, [0.85197, 0.84772, 0.84344, [0.84203, 0.83777, 0.83348, [0.83181, 0.82754, 0.82323, [0.82114, 0.81688, 0.81257, [0.80991, 0.80566, 0.80138, [0.79784, 0.79360, 0.78934, ]) 0.99225], 0.98485], 0.97808], 0.97150], 0.96512], 0.95856], 0.95168], 0.94438], 0.93662], 0.92843], 0.91992], 0.91108], 0.90207], 0.89288], 0.88335], 0.87417], 0.86466], 0.85507], 0.84540], 0.83564], 0.82588], 0.81576], 0.80552], 0.79491], 0.78388], 0.77203], #0 #4 #8 #12 #16 #20 #24 #28 #32 #36 #40 #44 #48 #52 #56 #60 #64 #68 #72 #76 #80 #84 #88 #92 #96 #100 Двумерная сплайновая интерполяция осуществляется с помощью класса RectBivariateSpline. При создании класса конструктору передаются данные по осям координат, таблица данных и степени полиномов kx, ky. По умолчанию используются кубические сплайны. Как и в одномерном случае, степень сглаживания управляется параметром s с нулевым значением по умолчанию, что предполагает интерполяцию. Созданный экземпляр вызывается как функция для вычисления значений в точке или массиве интерполяции. Создадим интерполятор для водных растворов спирта: from scipy.interpolate import RectBivariateSpline as RBS spl2d = RBS(ps, ts, pr, s=0, kx=3, ky=3) Для проверки вызовем интерполятор для крайних точек таблицы данных: print(spl2d(0, 10), spl2d(100, 40)) Получим ожидаемые результаты: [[0.99973]] [[0.77203]] Обратите внимание, что интерполятор возвращает не скалярное значение, а двумерный массив NumPy с единственным элементом. 202
Естественно, возникает вопрос о визуализации. Давайте вычислим значения плотности водных растворов спирта на двумерной сетке: tts = np.linspace(-10., 60., 101)# расширяем диапазон температур pps = np.linspace(0., 100., 101) # Вычисляем значения весового процента и температуры на сетке: Pr = spl2d(pps, tts, grid=True) # собственно интерполяция Из-за того, что оба параметра — массивы, приходится явно указывать, что вычисления проводятся на сетке (grid=True). Собственно визуализация выполняется функцией visualize3d: def visualize3d(x, y, Z, azim=-60, elev=30, roll=0, shrink=0.5, alpha=1, cmap='rainbow', figsize=(10,7), fn=None): ''' Визуализация трехмерной таблицы Параметры: x, y - данные по осям координат z - двумерный визуализируемый массив данных azim. elev, roll - углы поворота shrink - параметр, масштабирующий colorbar соответствие между цветами и значениями по оси z alpha - непрозрачность поверхности cmap - палитра matplotlib figsize - размер рисунка fn -путь к файлу, в котором сохраняется рисунок Функция возвращает: fig - объект рисунка ''' fig = plt.figure(figsize=figsize) ax = plt.subplot(111, projection='3d') X, Y = np.meshgrid(x, y) cb = ax.plot_surface(X, Y, Z, cmap=cmap, alpha=alpha) fig.colorbar(cb, shrink=shrink) # вращение рисунка ax.view_init(azim=azim, elev=elev, roll=roll) ax.set_xlabel(r'$Температура,\ ^oС$') ax.set_ylabel(r'$Весовой\ процент\ спирта$') ax.set_zlabel(r'$Плотность,\ г/см^3$') plt.tight_layout() if fn: plt.savefig(fn, dpi=300, facecolor="white") return fig Как оказалось, параметры отображения двумерной поверхности по умолчанию в трёхмерном пространстве дают не слишком удовлетворительные результаты, поэтому в функцию введены три параметра для вращения картинки относительно осей координат, более того, в блокноте e06_07.py сделано так, чтобы рисунок можно было бы интерактивно вращать вокруг осей с помощью слайдеров. На рисунке 6.29 представлена двумерная поверхность с двух различных точек зрения. 203
а б Рис. 6.29 Визуализация плотности водных растворов спирта в зависимости от температуры и весового процента спирта: a — azim = 120; б — azim = –180. Нам осталось посмотреть одномерные сечения двумерной поверхности — зависимости плотности от температуры и весового процента спирта, а заодно выяснить, как осуществляется экстраполяция (рис. 6.30). Зависимости от температуры рисуются следующим образом: _t = np.linspace(-10, 60, 100) _fig = plt.figure(figsize=(6, 4)) _p = spl2d(50, _t).flatten() plt.plot(_t, _p, lw=3, label=r"$50.0\ ^oC$") _p = spl2d(36.6, _t).flatten() plt.plot(_t, _p, lw=3, label=r"$36.6\ ^oC$") plt.xlabel(r'$Температура,\ ^oС$') plt.ylabel(r'$Плотность,\ г/см^3$') plt.grid() plt.legend() _fig а б Рис. 6.30 Одномерные зависимости плотности водных растворов спирта от температуры и весового процента спирта: a — зависимости от температуры; б — зависимости от весового процента спирта. 204
Мы расширили диапазон температур до [–10, 60] градусов Цельсия. Из-за особенностей того, что интерполятор возвращает двумерные массивы, их приходится превращать в одномерные с помощью функции flatten. Зависимости от температуры практически линейные, а вот в зависимости от весового процента присутствует нелинейность. Обратите внимание, что экстраполяция осуществляется значениями на границах прямоугольника, в котором задана интерполируемая функция. Задания читателям 1. Доработайте расчёт, добавив в таблицу о росте и весе студентов data третью строку, учитывающую важность элемента данных (число от нуля до единицы). Как это повлияет на статистические характеристики набора данных? 2. Введите в расчёт рост и размер обуви студентов (школьников) вашей учебной группы (класса), проведите регрессионный анализ, связав рост, вес и размер обуви, вычислите корреляционную матрицу. 3. Проверьте, можно ли добавлять в data не только студентов (студенток), но и школьников (школьниц)? 3. Поищите в интернете библиотеки для проведения статистических расчётов и их визуализации. Стоит ли их применять в вашей практической деятельности? 4. Выполните расчёты, визуализированные на рисунке 6.25 для различных значений фактора сглаживания s. 5. Найдите в интернете данные по годам жизни великих и выдающихся физиков, писателей, художников и других деятелей науки и искусства и постройте соответствующие гистограммы числа прожитых ими лет. 205
ГЛАВА 7 ЗАНЯТИЯ 13, 14 И 15. ПРОВЕРИТЬ АЛГЕБРОЙ ГЕОМЕТРИЮ, А ЗАОДНО И ГАРМОНИЮ Ремесло Поставил я подножием искусству: Я сделался ремесленник: перстам Придал послушную, сухую беглость И верность уху. Звуки умертвив, Музыку я разъял, как труп. Поверил Я алгеброй гармонию. Тогда Уже дерзнул, в науке искушенный, Предаться неге творческой мечты. А. С. Пушкин, «Моцарт и Сальери» Авторы восхищаются рисунками и гравюрами М. К. Эшера57, его умению совместить несовместимое, извлечь в отличие от Сальери (см. цитату в заголовке) из гармонии алгебру. Как оказалось, что даже нейронные сети дают возможность сгенерировать что-то отдаленно похожее на его рисунки и гравюры. Именно такой рисунок помещен в заголовок занятий. Тех, кто заинтересовался творчеством М. К. Эшера, отсылаем к электронным ресурсам и книгам [36–41]. Нас же будут интересовать простые вопросы: можно ли из простых фигур (отрезков прямых и простеньких кривых) рисовать рисунки стиле Эшера и сделать из них нетривиальную мозаику, посмотреть, как геометрия на плоскости связана с алгеброй, а заодно напомнить, что четвёртый символ в одном из сокращённых названий используемого нами подхода STEAM означает «искусство» (A — ART). При этом, как это принято в экосистеме Python, максимально использовать подготовленные для нас сообществом инструменты, а не класть на решение задачи всю оставшуюся жизнь. Начнём мы с простого и нарисуем с помощью matplotlib заглавную букву «Г». Почему matplotlib? Оказывается, в неё к кроме средств научной визуализации встроены возможности по описанию рисунков и их преобразованию. 7.1. Рисуем и заполняем заглавную букву Г Нарисовать букву «Г» достаточно легко, но мы напишем функцию для решения более общей задачи — рисования контура, который к тому же можно закрасить. Необходимым условием корректной закраски является замкнутость контура, координаты начальной и конечной точек контура должны совпадать (пример e07_01.py). def draw_contour(x, y, fill="b", axis=False, fig=None, figsize=(3,3), alpha=0.3, box=True): 57 Эшер, Мауриц Корнелис. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Эшер,_Мауриц_Корнелис (дата обращения 20.04.2026). 206
# создаем, если необходимо объект рисунка if fig is None: figsize = figsize if figsize else (3,3) fig = plt.figure(figsize=figsize) plt.plot(x, y, 'k-') # заполнение внутренности контура if fill: plt.fill(x, y, fill, alpha=alpha) # отключаем отображение осей и оцифровки if not axis: plt.axis("off") # задаем одинаковые масштабы по осям координат plt.gca().set_aspect('equal') if box: # рисуем охватывающий прямоугольник xmin, xmax = np.min(x), np.max(x) ymin, ymax = np.min(y), np.max(y) plt.plot([xmin, xmax], [ymin, ymin], 'k--', plt.plot([xmin, xmax], [ymax, ymax], 'k--', plt.plot([xmin, xmin], [ymin, ymax], 'k--', plt.plot([xmax, xmax], [ymin, ymax], 'k--', return fig lw=0.5) lw=0.5) lw=0.5) lw=0.5) # координаты контура Г x = np.array([0., 0., 0.7, 0.7, 0.1, 0.1, 0]) y = np.array([0., 1., 1.0, 0.9, 0.9, 0.0, 0]) draw_contour(x, y, axis=True) Рис. 7.1 Закрашенная заглавная буква «Г» Логика работа функции достаточно очевидна, нам нужно пояснить только назначение параметров: x, y — координаты контура по горизонтальной и вертикальным осям на плоскости; fill — индикатор закраски, закраска контура осуществляется, 207
когда он задан, цвет закраски (по умолчанию закраска осуществляется синим цветом); axis — индикатор отображения осей координат и оцифровки, по умолчанию они не отображаются. Функции может передаваться объект рисунка fig, делается это для того, чтобы рисовать несколько контуров на одном рисунке; при необходимости объект рисунка будет создан в функции, дополнительно в параметре figsize можно задать размер рисунка в дюймах; alpha — непрозрачность закраски, box — признак необходимости нарисовать прямоугольник, охватывающий контур. Функция возвращает объект рисунка, который непосредственно отображается в блокноте marimo. Для рисования нам приходится задать координаты контура по осям координат. Наш контур составлен из отрезков прямых, его рисование с помощью matplotlib не вызывает трудностей. 7.2. Аффинные преобразования плоскости Аффинное преобразование58 (от лат. affinis «соприкасающийся, близкий, смежный») — отображение плоскости в себя, при котором параллельные прямые переходят в параллельные прямые, пересекающиеся — в пересекающиеся. На языке алгебры такие преобразования можно записать так: 𝑥 𝑥𝑡 α β 𝑥′ ( ′) = ( ) ∙ (𝑦) + (𝑦 ). (7.1) 𝑦 γ δ 𝑡 α β От матрицы ( ) требуется, чтобы она имела обратную. В формуле (7.1) γ δ 𝑥 𝑥′ (𝑦) — исходные координаты точки, ( ′ ) — преобразованные координаты. 𝑦 Формулу (7.1) можно переписать, чтобы оставить только матричное умно𝑥 жение, дополнив вектор (𝑦) третьей постоянной координатой: 𝑥 α β 𝑥𝑡 𝑥′ (𝑦 ′ ) = ( γ δ 𝑦𝑡 ) ∙ (𝑦). (7.2) 1 1 0 0 1 Непосредственной проверкой можно показать, что формулы (7.1) и (7.2) дают одинаковый результат, если отбросить постоянную третью составляющую. Функция affine_transformation выполняет аффинное преобразование либо над парой чисел x, y, либо над двумя одномерными массивами одинаковой длины: def affine_transformation(x,y, coeffs=np.array([[1., 0., 0.],[0.,1.,0.]])): # конструируем матрицу преобразования a = np.zeros((3,3)) a[:2] = coeffs a[-1,-1] = 1. # конструируем массив координат x_shape = 1 if isinstance(x, int) or isinstance(x, float) else x.shape[0] 58 Что за зверь — аффинные преобразования? [Электронный ресурс]. — URL: https://habr.com/ru/articles/539420/ (дата обращения 20.04.2026). 208
xy = np.ones((3, x_shape)) xy[0, :] = x xy[1, :] = y # выполняем преобразование r = a @ xy return r[:-1] Функции придаются либо два числа x, y, либо два массива координат одинаковой размерности, а также массив коэффициентов преобразования coeff = α β 𝑥𝑡 =( ). По умолчанию задано тождественное преобразование γ 𝛿 𝑦𝑡 1. 0. 0. ( ). 0. 1. 0. С помощью аффинных преобразований можно выполнить: • перенос фигуры на 𝑥𝑡 по горизонтали и на 𝑦𝑡 по вертикали; этому соот1 0 𝑥𝑡 ветствует массив коэффициентов ( ); 0 1 𝑦𝑡 • масштабирование в 𝑘𝑥 раз по оси абсцисс и 𝑘𝑦 раз по оси ординат, мас𝑘𝑥 0 0 сив коэффициентов имеет вид: ( ; 0 𝑘𝑦 0) • поворот фигуры по часовой стрелки на угол θ в радианах относительно cos θ sin θ 0 оси, проходящей через точку (0,0): массив коэффициентов: ( ); −sin θ cos θ 0 • поворот фигуры против часовой стрелки на угол 𝜃 в радианах относительно оси, проходящей через точку (0,0): массив коэффициентов: cos θ −sin θ 0 ( ); sin θ cos θ 0 1. 0. 0. • зеркальное отражение относительно оси абсцисс: ( ); 0. −1. 0. • наконец, зеркальное отражение относительно оси ординат: −1. 0. 0. ( ). 0. 1. 0. Рис. 7.2 Аффинные преобразования буквы «Г» 209
Ниже приводится исходный текст примера, иллюстрирующий перечисленные выше элементарные преобразования (рис. 7.2): # исходная буква Г fig = draw_contour(x, y, figsize=(8,3),fig=None, axis=True) # трансляция (перенос) xyt = affine_transformation(x, y, np.array([[1.,0.,1.5], [0.,1., 1]])) draw_contour(xyt[0], xyt[1], fig=fig, axis=True) # изменение масштаба c одновременной трансляцией kx, ky = 2., 0.5 xyk =affine_transformation(x, y, np.array([[kx,0.,2.5], [0.,ky, 0]])) draw_contour(xyk[0], xyk[1], fig=fig, axis=True) # поворот вокруг оси в точке (0, 0) по часовой стрелке angle = 60*np.pi/180 # угол поворота в радианах cos60, sin60 = np.cos(angle), np.sin(angle) xyr = affine_transformation(x, y, np.array([[cos60,sin60,0.], [-sin60,cos60, 0]])) draw_contour(xyr[0], xyr[1], fig=fig, axis=True) # поворот вокруг оси в точке (0, 0) против часовой стрелки xyr2 = affine_transformation(x, y, np.array([[cos60,-sin60,0.], [sin60,cos60, 0]])) draw_contour(xyr2[0], xyr2[1], fig=fig, axis=True) # зеркальное отражение относительно оси ординат (x=0) xyv = affine_transformation(x, y, np.array([[1.,0.,0.], [0.,-1., 0]])) draw_contour(xyv[0], xyv[1], fig=fig, axis=True) # зеркальное отражение относительно оси абсцисс (y=0) xyh = affine_transformation(x, y, np.array([[-1.,0.,0.], [0.,1., 0]])) draw_contour(xyh[0], xyh[1], fig=fig, axis=True) # композиция отображений, одновременное отражение относительно # осей абсцисс и ординат xyhv = affine_transformation(x, y, np.array([[-1.,0.,0.], [0.,-1., 0]])) draw_contour(xyhv[0], xyhv[1], fig=fig, axis=True) fig Аффинные преобразования можно комбинировать. Например, комбинация (композиция) двух поворотов на 30 градусов — это поворот на 60 градусов. Композиция преобразований сводится к умножению матриц (7.2), поэтому следует помнить, что результат композиции зависит от порядка применения их преобразований. 210
Покажем это на примерах, предварительно написав функцию для композиции аффинных преобразований: def composition(*coefs): nc = len(coefs) # число отображений c = np.ones((3,3)) # заготовка результирующего отображения c[:-1] = coefs[0] c[-1,:-1] = 0. for _ in range(1, nc): # формируем очередную полную матрицу преобразования cx = np.ones((3,3)) cx[:-1] = coefs[_] cx[-1,:-1] = 0. # собственно композиция осуществляется умножением матриц c = c @ cx return c[:-1] Функции передаётся последовательность матриц отображений. Именно для этого перед параметром coefs поставлена звёздочка. Работа функции начинается с формирования заготовки полной матрицы преобразования (7.2) и переноса в неё нулевого члена последовательности параметров. Далее в цикле формируется полная матрица для очередного преобразования и осуществляется перемножение матриц. Функция возвращает массив коэффициентов результирующего преобразования. Ниже мы создадим преобразования поворота на 30 градусов по часовой стрелке rot30, переноса по оси x trx, зеркального отражения от оси абсцисс и ординат flip_x, flip_y соответственно. angle30 = 30*np.pi/180 cos30, sin30 = np.cos(angle30), np.sin(angle30) rot30 = np.array([[cos30, sin30, 0], [-sin30, cos30,0]]) tr = 1.5 trx = np.array([[1.0, 0, tr], [0., 1.,0.]]) flip_x = np.array([[1., 0., 0.], [0., -1.,0.]]) flip_y = np.array([[-1., 0., 0.], [0., 1.,0.]]) Выполняем композицию переносов, вращений и отражений, подготовив преобразования tr1–tr4: fig_c = draw_contour(x, y, figsize=(8, 3), fig=None, axis=True) tr1 = composition(trx, rot30) xy1 = affine_transformation(x, y, tr1) draw_contour(xy1[0], xy1[1], fig=fig_c, axis=True) tr2 = composition(trx, trx, rot30, flip_x) xy2 = affine_transformation(x, y, tr2) draw_contour(xy2[0], xy2[1], fig=fig_c, axis=True) tr3 = composition(trx, trx, trx, rot30, rot30, flip_x, flip_y) xy3 = affine_transformation(x, y, tr3) draw_contour(xy3[0], xy3[1], fig=fig_c, axis=True) tr4 = composition( 211
trx, trx, trx, trx, rot30, flip_y, flip_x, rot30, rot30, rot30 ) xy4 = affine_transformation(x, y, tr4) draw_contour(xy4[0], xy4[1], fig=fig_c, axis=True) fig_c Исходное изображение и три композиции представлены на рисунке 7.3. Рис. 7.3 Исходное изображение и три преобразованных изображения Всё существенно меняется, если добавить преобразование tr5, поменяв порядок преобразований в tr4 на обратный (рис. 7.4): tr5 = composition(rot30, rot30, rot30, flip_x, flip_y, rot30, trx, trx, trx,trx) xy5 = affine_transformation(x, y, tr5) draw_contour(xy5[0], xy5[1], fig=fig_c, axis=True) fig_c Рис. 7.4 Добавляем преобразование tr5 212
До сих пор мы работали с фигурами, образованными отрезками прямых. Давайте посмотрим, как добавить кривые, к которым можно применять рассмотренные выше преобразования. 7.3. Кривые Безье Нам необходима конструкция, которая позволяла бы рисовать гладкие кривые, сопрягать их с другими кривыми, а также преобразовывать их точно так же, как мы это делали с ломаными линиями. Этим требованиям удовлетворяют кривые Безье [42]. Кривую Безье можно задать с помощью от одной до четырёх точек — 𝑝0 = (𝑥0 , 𝑦0 ), 𝑝1 = (𝑥1 , 𝑦1 ), 𝑝2 = = (𝑥2 , 𝑦2 ), 𝑝3 = (𝑥3 , 𝑦3 ) — и параметра 𝑡 ∈ [0, 1] формулами (7.3): 𝐵𝑜 (𝑡) = 𝑝0 ; 𝐵1 (𝑡) = 𝑝0 (1 − 𝑡) + 𝑝1 𝑡; (7.3) 𝐵2 (𝑡) = 𝑝0 (1 − 𝑡)2 + 2𝑝1 𝑡(1 − 𝑡) + 𝑝2 𝑡 2 ; 𝐵3 (𝑡) = 𝑝0 (1 − 𝑡)3 + 3𝑝1 𝑡𝑝0 (1 − 𝑡)2 + 3𝑡 2 (1 − 𝑡)𝑝2 + 𝑝3 𝑡 3 . Легко видеть, что 𝐵𝑜 (𝑡) задаёт точку, 𝐵1 (𝑡) — отрезок прямой между точками 𝑝0 и 𝑝1 , а 𝐵2 (𝑡), 𝐵3 (𝑡) — параболы второй и третьей степени от 𝑡 соответственно. Общим для них является то, что они проходят через крайние точки, а вот промежуточные точки могут не находиться на кривой. Кривые Безье мы будем рисовать с помощью функции besier, реализующей формулу (7.3). Было принято решение о возможности рисовать кривые, заданные более чем четырьмя точками, с помощью разбиения на сегменты. Сегменты содержат от одной до четырёх точек, конечная точка предыдущего сегмента становится начальной точкой следующего сегмента. Именно это позволяет рисовать непрерывные кривые, содержащие произвольное число точек. Функции передаются массивы точек x, y и nt число дополнительных точек на каждом сегменте, используемых для рисования кривых Безье (блокнот e07_02.py). Функция возвращает массивы xr, yr, используемые для рисования кривых, а также число сегментов segments: def besier(x, y, nt=100): # делим кривую на сегменты n_p = len(x) # число точек кривой segments = [] while n_p > 0: if n_p>4: segments.append((x[:4], y[:4])) x = x[3:] y = y[3:] n_p -= 3 else: segments.append((x, y)) n_p = 0 break n_s = len(segments) # число сегментов кривой t = np.linspace(0., 1, nt) # массив значений параметра 213
# массивы координат кривой Безье xr, yr = np.zeros(n_s*nt), np.zeros(n_s*nt) for i in range(n_s): # строим кривую по сегментам p_s = segments[i] # опорные точки сегмента кривой x_s = p_s[0] y_s = p_s[1] ls = len(x_s) if ls==1: # реализуем формулу (3) xr[i*nt:(i+1)*nt] = x_s[0] yr[i*nt:(i+1)*nt] = x_s[0] elif ls==2: xr[i*nt:(i+1)*nt] = x_s[0]*(1-t) + x_s[1]*t yr[i*nt:(i+1)*nt] = y_s[0]*(1-t) + y_s[1]*t elif ls==3: xr[i*nt:(i+1)*nt] = x_s[0]*(1-t)**2 + \ x_s[1]*2*t*(1-t) + x_s[2]*t**2 yr[i*nt:(i+1)*nt] = y_s[0]*(1-t)**2 + \ y_s[1]*2*t*(1-t) + y_s[2]*t**2 else: xr[i*nt:(i+1)*nt] = x_s[0]*(1-t)**3 + \ x_s[1]*3*t*(1-t)**2 + x_s[2]*3*t**2*(1-t) +\ x_s[3]*t**3 yr[i*nt:(i+1)*nt] = y_s[0]*(1-t)**3 + \ y_s[1]*3*t*(1-t)**2 + y_s[2]*3*t**2*(1-t) + y_s[3]*t**3 return xr, yr, segments Прежде всего массивы точек, задающих кривую, разбиваются на фрагменты и помещаются в список segments. Начальные сегменты содержат по четыре точки, конечный сегмент может содержать от одной до четырёх точек, а граничные точки принадлежат соседним сегментам, обеспечивая непрерывность кривой. Для каждого сегмента по формуле (7.3) рассчитываются nt координат кривой для рисования, которые помещаются в массивы xr, yr. Наглядно представление кривой можно получить с помощью функции draw_besier, которой передаются координаты опорных точек исходной кривой x, y, число разбиений каждого сегмента nt, объект рисунка fig (используется для рисования нескольких кривых на одном рисунке), размер рисунка в дюймах figsize, ms — размер маркеров, которыми помечаются исходные точки кривой, и lw — толщина линии для отрисовки кривой. Функция возвращает объект рисунка. def draw_besier(x, y, nt=100, fig=None, figsize=(3,3), ms=10, lw=3): xr, yr, segments = besier(x, y, nt=100) if fig is None: fig = plt.figure(figsize=figsize) if figsize plt.figure() plt.plot(xr, yr, 'k-', lw=3) plt.plot(x, y, 'b*', ms=ms) plt.xlabel('x') plt.ylabel('y') # соединяем опорные точки с лежащими на кривой for segment in segments: 214 else
xs, ys = segment ns = len(xs) if ns>=3: plt.plot([xs[0],xs[1]], [ys[0], ys[1]], 'k--', lw=0.5) plt.plot([xs[-2],xs[-1]], [ys[-2], ys[-1]], 'k--', lw=0.5) # задаем одинаковые масштабы по осям координат plt.gca().set_aspect("equal") return fig Обратите внимание, что мы задаём одинаковый масштаб по обеим осям координат (предпоследняя строка исходного кода функции). Для демонстрации возможностей не только рисования, но и преобразования кривых Безье написано простенькое приложение marimo (рис. 7.5, блокнот e07_02.py). Для рисования необходимо в текстовых областях x, y задать координаты точек кривой числами, разделёнными запятыми, и нажать кнопку Запустить приложение. Кроме того, в текстовой области coefs задаются параметры аффинного преобразования. По умолчанию осуществляется тождественное преобразование. На рисунке 7.5 показана кривая, заданная в семи точках. Преобразование осуществляет зеркальное отображение кривой относительно оси x. Обратите внимание, что исходные точки кривой помечены синими звёздочками. Рис. 7.5 Интерактивное приложение marimo для рисования и преобразования кривых Безье До сих пор мы обходились собственными силами, изобретая велосипеды для рисования кривых и аффинных преобразований. Давайте посмотрим, как это можно сделать индустриальными методами. 215
7.4. Кодирование, рисование и преобразование векторных изображений в matplotlib Начнём мы с кодирования наших рисунков. Использовать, как мы делали ранее, длинные массивы координат точек не хотелось бы. Поэтому используем подход, основанный командах «черепашьей» графики и чем-то напоминающий Scalable Vector Graphics (SVG — масштабируемая векторная графика) [43]. Изображения будут рисоваться с помощью всего трёх команд: 1) M x, y — переместиться в точку с координатами x, y без рисования59; 2) L x, y — нарисовать отрезок прямой из предыдущей точки в точку x, y; 3) С x2, y2, x3, y3, x4, y4 — нарисовать кривую Безье, заданную четырьмя точками; первая точка берется из предыдущей команды. Такой набор команд позволяет рисовать фигуры, составленные из отрезков прямых и кривых Безье. Если необходимо разорвать непрерывную линию, то достаточно воспользоваться командой M. Команды либо отделяются друг от друга пробелами, либо вводятся на новой строке. Естественно, в таком виде фигуру нарисовать можно только от руки, и то, если в ней отсутствую кривые Безье. Для начала необходимо преобразовать команды к виду, который может нарисовать matplotlib. Для этого установим библиотеку svgpath2mpl с помощью команд: uv add svgpath2mpl или pip install svgpath2mpl Если установлен проект примеров книги, то это уже сделано. В блокноте e07_03.py выполним необходимый импорт: import marimo as mo import numpy as np import matplotlib.pyplot as plt from matplotlib.path import Path import matplotlib.patches as patches from matplotlib.patches import PathPatch, Rectangle from matplotlib.transforms import Affine2D as T from svgpath2mpl import parse_path В следующей ячейке введём последовательность команд: # ячейка 1 curve=''' M 1.,1. C 0.78,0.95 0.78,0.95 0.58,0.97 C 0.38,0.80 0.38,0.80 0.25,0.73 L 0.,1. ''' Рассматриваемая фигура состоит из двух кривых Безье, начинающихся в точке (1, 1), и отрезка прямой. Преобразуем последовательность команд curve в нечто, понятное matplotlib: # ячейка 2 curve_path = parse_path(curve) 59 x, y либо целые числа, либо числа с плавающей точкой. 216
curve_path В области вывода ячейки marimo мы увидим: Path(array([[1. , 1. ], [0.78, 0.95], [0.78, 0.95], [0.58, 0.97], [0.38, 0.8 ], [0.38, 0.8 ], [0.25, 0.73], [0. , 1. ]]), array([1, 4, 4, 4, 4, 4, 4, 2], dtype=uint8)) Мы получили экземпляр объекта Path, содержащего два атрибута: vertices, — массивы координат вершин фигуры curve_path и codes — цифровые коды команд. Выведем оба атрибута в третьей ячейке: # ячейка 3 print(f"{curve_path.vertices=}\n{curve_path.codes=}") Результат выполнения ячейки: curve_path.vertices=array([[1. , 1. ], [0.78, 0.95], [0.78, 0.95], [0.58, 0.97], [0.38, 0.8 ], [0.38, 0.8 ], [0.25, 0.73], [0. , 1. ]]) curve_path.codes=array([1, 4, 4, 4, 4, 4, 4, 2], dtype=uint8) Координаты вершин те же самые, что и в curve, а вот коды в атрибуте codes точно соответствую командам: 1 — М, 2 — L, 4 — C. Коды команд соответствуют каждой точке фигуры60. Объект Path можно нарисовать средствами matplotlib, благо там имеется специальные объекты — «заплатки» (patches). # ячейка 4 _fig, _ax = plt.subplots(figsize=(3,3)) _patch = PathPatch(curve_path, fill=True, color="purple", alpha=0.4) _ax.add_patch(_patch) _ax В четвёртой ячейке создаётся объект рисунка _ax, размер рисунка три на три дюйма. Заплатка создаётся как экземпляр класса PathPatch, при этом указывается, что её необходимо закрасить (fill=True) полупрозрачным пурпурным цветом. Для рисования фигура добавляется к рисунку (рис. 7.6). 60 Path Tutorial. [Электронный ресурс]. — URL: https://matplotlib.org/stable/users/explain/artists/paths.html (дата обращения 20.04.2026). 217
Рис. 7.6 Рисуем фигуру с помощью «заплатки» У нас есть всё, что необходимо для создания редактора фигур — блокнот e07_04.py. Он аналогичен редактору кривых Безье. Как и ранее, мы сверстали раскладку приложения на сетке (см. пункт меню редактора marimo Present as | Grid). Перед выходом из редактора раскладок необходимо обязательно сохранить блокнот в файловой системе. Раскладка пользовательского интерфейса приложения сохранится в файле layouts/e07_04.json. Пример работы с редактором представлен на рисунке 7.7. Редактор можно использовать для подготовки фигур, используемых в качестве исходных при создании рисунков и мозаик. Рис. 7.7 Работа с редактором фигур 218
Нашей задачей является создать однородные мозаики, когда берется однаединственная фигура, копии которой после аффинных преобразований укладываются на плоскости, до этого нам предстоит проделать вспомогательную работу. 7.5. Вспомогательные функции для рисования мозаик Вспомогательные функции мы разместим в блокноте marimo e07_05.py. Эти функции нам понадобятся в других блокнотах, следовательно, необходимо обеспечить их экспорт. Для экспорта функций из блокнотов marimo достаточно, во-первых, сосредоточить весь импорт внешних объектов в нулевой ячейке, вовторых, настроить блокнот для экспорта, а в-третьих, все экспортируемые функции должны располагаться в отдельных ячейках блокнота. Настройка блокнота для экспорта осуществляется очень просто: нажать меню редактора (кнопка с тремя горизонтальными чёрточками в верхнем правом углу окна редактора) и выбрать в меню Add setup cell (добавить ячейку настройки) (рис. 7.8). Рис. 7.8 Ячейка настройки блокнота marimo (прямоугольными рамками выделены кнопка меню редактора и признак ячейки настройки) После этого ячейки с экспортируемыми функциями будут помечены как reusable — повторно используемые (рис. 7.9). Рис. 7.9 Повторно используемая функция в блокноте marimo 219
Рассмотрение вспомогательных функций мы начнём с функции рисования draw, т. к. нам придётся рисовать много фигур по одной и несколько на одном рисунке. Функции передаются следующие параметры: • paths — список с описаниями отображаемых фигур; • box — флажок, управляющий отображением единичного прямоугольника; • fill — флажок заполнения отображаемой фигуры цветом; • axis — флажок отображения осей и оцифровки по осям; • equal — флажок, задающий одинаковые масштабы по горизонтальной и вертикальной осям; • nrows — число рисунков по вертикальной оси; • ncols — число рисунков по горизонтальной оси; • figsize — размер холста, на котором отображаются рисунки; • lims — признак принудительного задания границ рисунка по осям координат в виде (xmin, ymin, xmax, ymax). Функция возвращает объект figure matplotlib с одним или несколькими рисунками. Исходный текст функции приводится ниже: def draw(*paths, box=True, fill=False, axis=True, equal=True, nrows=1, ncols=1, figsize =(3,3), lims=None): l = len(paths) # число рисунков fig, axs = plt.subplots(figsize=figsize,ncols=ncols, nrows=nrows) # subplots возвращает различные объекты при задании # nrows, ncols, # поэтому приходится по-разному извлекать объект рисунка for k in range(l): # цикл по рисункам i = k // ncols j = k % ncols k = i*ncols + j # номер рисунка if nrows==1 and ncols==1: ax = axs path = paths[0] elif ncols==1: path = paths[i] ax = axs[i] elif nrows==1: path = paths[j] ax = axs[j] else: # общий случай path = paths[k] ax = axs[i, j] # отображаем фигуру if path: ax.add_patch(PathPatch(path, fill = fill)) 220
if box: # отображаем единичный квадрат r = Rectangle((0,0), 1, 1, fill = False, linestyle = 'dotted') ax.add_patch(r) if equal: # равенство масштабов по осям координат ax.axis('equal') if not axis: # отображение осей и оцифровки ax.axis('off') plt.axis('off') if lims: # границы рисунка xmin, ymin, xmax, ymax = lims ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) else: # подавляем оси и оцифровку для отсутствующего рисунка ax.axis('off') plt.tight_layout() return fig Мы можем нарисовать произвольное число фигур, например, выведем пять фигур, представляющих собой символ Г в два ряда по три рисунка в ряду (рис. 7.10): # ячейка 4 def get_G(): # буква Г g = ''' M 0.0, 0.0 L 0.0, 1.0 L 0.7, 1.0 L 0.7, 0.9 L 0.1, 0.9 L 0.1, 0.0 L 0.0, 0.0 ''' return parse_path(g) # ячейка 5 # тестируем функцию draw _G = get_G() draw(_G, _G,_G,_G,_G, figsize=(8,3),axis=False, box=False, equal=True, lims=None, ncols=3, nrows=2 ) Рис. 7.10 Вывод последовательности фигур с помощью функции draw 221
Аналогично подготовим функции для рисования других фигур: get_blank() — пустая фигура, get_edge() — фрагмент для рисования контура рыбы (контур мы соберем позже), get_F — перевернутый символ F, get_fish — рыбка и, наконец, треугольник — функция get_triangle. Все фигуры представлены на рисунке 7.11, рисунок нарисован с помощью вызова: draw(get_G(), get_blank(), get_edge(), get_F(), get_fish(), get_triangle(), axis=False, box=True, fill=True, ncols=3, nrows=2, figsize=(5, 5)) Рис. 7.11 Отображаем подготовленные в виде функций фигуры (задана закраска фигур и отображение единичного квадрата) Для дальнейшего нам понадобятся преобразования и компоновка фигур средствами matplotlib. 7.6. Преобразование и компоновка фигур В блокноте e07_05.py реализован набор функций аффинных преобразований и компоновки фигур средствами matplotlib. Таблица 7.1 Функции преобразования и компоновки фигур Функция Пояснения transform(f, transformation) union(*fs) Применение к фигуре f преобразования transformation. Функция возвращает преобразованную фигуру Объединение последовательности фигур fs. Функция возвращает фигуру, созданную с помощью объединения фигур translate(f, dx, dy) Перемещение фигуры f параллельно самой себе на dx вправо и на dy вверх. Функция возвращает преобразованную фигуру scale(f, kx, ky) Масштабирование фигуры f по осям: kx — коэффициент преобразования по горизонтальной оси; ky — коэффициент преобразования по вертикальной оси. Функция возвращает преобразованную фигуру 222
Продолжение табл. 7.1 Функция Пояснения def flip_y(f) Отражение фигуры f относительно вертикальной оси с переносом на 1 влево. Функция возвращает преобразованную фигуру flip_x(f) Отражение фигуры f относительно горизонтальной оси с переносом вверх на единицу. Функция возвращает преобразованную фигуру rot_90(f) rot_45(f) Поворот фигуры f на 90 градусов против часовой стрелки с переносом на единицу вправо. Функция возвращает преобразованную фигуру Поворот фигуры f на 45 градусов против часовой стрелки с масштабированием и переносом на 0,5 вправо и вверх. Функция возвращает преобразованную фигуру Вращение фигуры f вокруг точки x, y на deg градусов проrotate_deg_around(f, тив часовой стрелки. Функция возвращает преобразованx=0, y=0, deg=90) ную фигуру Размещение двух фигур f1, f2 рядом с друг другом с масbeside(f1, f2, n1=1, штабированием по x в n1 и по y в n2 раз для размещения в n2=1) единичном квадрате. Функция возвращает преобразованную фигуру above(f1, f2, n1=1, n2=1) Размещение двух фигур f1, f2 друг над другом с масштабированием по оси y для размещения в единичном квадрате. Функция возвращает преобразованную фигуру four(a, b, c, d) Размещение четырёх фигур a, b, c, d по углам единичного квадрата. Функция возвращает преобразованную фигуру nine(p1, p2, p3, p4, Укладываем девять фигур p1-p9 на сетке 3 на 3. Функция p5, p6, p7, p8, p9) возвращает преобразованную фигуру Теперь давайте на одном рисунке покажем результаты вызова всех перечисленных в таблице 7.1 функций (рис. 7.12). В тексте примера присутствует разметка по рядам рисунка. # первый ряд рисунка # объединение, перенос _f1 = union(get_G(), translate(get_G(), 2,1)) # объединение, масштабирование _f2 = union(get_G(), translate(scale(get_G(), 2,3),2, 1)) # flip_y _f3 = union(get_G(), flip_y(get_G())) # flip_x _f4 = union(get_G(), flip_x(get_G())) # второй ряд рисунка # rot_90 _f5 = union(get_G(), rot_90(get_G())) # rot_45 _f6 = union(get_G(), rot_45(get_G())) 223
# rotate_deg_around _f7 = union(get_G(), rotate_deg_around(get_G(),deg=-30), rotate_deg_around(get_G(),deg=-60), rotate_deg_around(get_G(),deg=-150) ) # beside _f8 = beside(get_G(), get_G()) # третий ряд рисунка # above _f9 = above(get_G(), get_G()) # four _G = get_G() _f10 = four(_G, flip_y(_G), flip_x(_G), flip_x(flip_y(_G))) # nine _Gs = [get_G()]*9 _Gs[4] = get_blank() _f11 = nine(*_Gs) draw(_f1, _f2, _f3, _f4, _f5, _f6, _f7, _f8, _f9, _f10, _f11, axis=False, box=True, fill=False, ncols=4, nrows=3, figsize=(12, 9)) Рис. 7.12 Собираем на одном рисунке применение всех перечисленных в таблице 7.1 преобразований 224
Следующей задачей является сборка из фигуры edge (справа в первом ряду сверху на рис. 7.11) контура рыбки. Процесс сборки контура представлен на рисунке 7.13. Рис. 7.13 Этапы сборы контура рыбки Контур рыбки состоит из четырёх частей. В первом ряду представлены преобразования исходной фигуры edge (первый рис. слева в верхнем ряду). Первая часть контура получается из исходной с помощью применения функции flip_x (второй рис.). Второй элемент контура рыбки можно получить, повернув первый на 90 градусов и перенеся результат влево на единицу. Третий элемент получается из первого поворотом на 45 градусов и отражением относительно вертикальной оси. Наконец, четвёртый элемент контура получается из третьего последовательным применением отражения относительно вертикальной, а затем горизонтальной оси. Таким образом, в верхнем ряду рисунка 7.13 приводятся исходный элемент и четыре элемента контура. Во втором ряду осуществляется постепенное объединение элементов в контур рыбы. Готовый контур дополнительно отражён относительно горизонтальной оси, чтобы он был ориентирован так, как и фигура рыбы (правый рис. в нижнем ряду на рис. 7.13). Исходный текст функции для фигуры контура рыбки приводится ниже. def get_fish_outline(): ''' Компоновка контура рыбки из фигуры edge ''' p0 = get_edge() p1 = flip_x(p0) p2 = translate(rot_90(p1), -1, 0) p3 = flip_y(rot_45(p1)) p4 = flip_x(flip_y(p3)) return flip_x(union(p1, p2, p3, p4)) При сборке контура были задействованы практически все инструменты, упомянутые в данном разделе. У нас всё готово для сборки фигуры по мотивам квадратного предела М. К. Эшера [46–52]. 225
7.7. Сборка Квадратного предела Нашей задачей является сборка мозаики. В качестве неё мы задействуем все фигуры, подготовленные в предыдущем разделе. Казалось бы, ничего сложного в сборке мозаики нет. Действительно, это так, если элементы мозаики квадратные. Сконструируем для этого функцию tiles, которой будет передаваться объект фигуры, преобразование, которому подвергается очередная плитка, и число рядов и колонок для укладки мозаики. Мы это сделаем в блокноте e07_06.py, а необходимые инструменты будем импортировать из e07_05.py. Функция tiles «складывает» прямоугольную мозаику из nrows рядов и ncols столбцов в фигуру f, которая подвергается преобразованиям с помощью функции transform. Преобразование зависит от места элемента в мозаике. Функция возвращает объект Path matplotlib, который в свою очередь позволяет отображать мозаики с помощью функции draw. def tiles(f, transform, nrows=4, ncols=4): result = None for i in range(nrows): for j in range(ncols): if i==0 and j==0: # инициализация мозаики el = f() result = el else: _f =transform(f, i, j) el = translate(_f, i, j) # сборка мозаикию result = union(result, el) return result На рисунке 7.14 представлены несколько простых мозаик для различных порождающих фигур и правил преобразования. Для первых двух рисунков в качестве порождающей фигуры использована рыбка, для третьего и четвёртого — контур рыбки, для пятого — перевернутый символ F, для шестого — треугольник. Во всех случаях для преобразований использовались отражения относительно осей координат в различных сочетаниях. Рисунок 7.14 был нарисован вызовом функции draw: _fig = draw(t1, t2, t3, t4, t5, t6, axis=False, equal=True, box=False, figsize=(15,12), ncols=3, nrows=2, fill=True ) _fig Все мозаики на рисунке 7.14 периодичны по обеим координатным осям. При рисовании Квадратного предела усложним ситуацию, уменьшая масштаб фигур по мере удаления от центра рисунка. Сделаем это в блокноте e07_07.py постепенно, небольшими шагами. При этом собирать Квадратный предел будем не из одной, а из четырёх порождающих фигур, представленных на рисунке 7.15. 226
Рис. 7.14 Простые мозаики Рис. 7.15 Порождающие фигуры для квадратного предела Перед началом рисования немного доработаем порождающие фигуры, выполнив их отражение от горизонтальной оси: fish = flip_x(get_fish()) fish_outline = flip_x(get_fish_outline()) triangle = flip_x(get_triangle()) G = flip_x(get_G()) _fig = draw(fish, fish_outline, triangle, G, ncols=4, axis=False, figsize=(12,3) ) _fig Результат приведён на рисунке 7.16. 227
Рис. 7.16 Преобразованные порождающие фигуры Рисовать Квадратный предел мы начнём с центральной части, которую соберем по частям. 7.7.1. Центральная часть Квадратного предела Центральная часть состоит из четырёх порождающих фигур, повернутых относительно друг друга. Постепенно соберем её, начав с двух фигур. Мы будем придерживаться тактики оформления каждого шага в виде функции, что позволит легко демонстрировать процесс построения для все порождающих фигур. Для построения отразим порождающую фигуру относительно горизонтальной оси, её копию развернём на 180 градусов и объединим. Эти действия оформим в виде функции two_side_by_side: def two_side_by_side(f): fs = flip_x(f) return union(fs,rot_90(rot_90(fs))) Результат для всех порождающих фигур показан на рисунке 7.17. Рис. 7.17 Центральная часть Квадратного предела из двух порождающих фигур Нам предстоит добавить к центральной части две уменьшенные порождающие фигуры, но перед этим напишем функцию small_figure для создания уменьшенной фигуры: def small_figure(f): return flip_y(rot_45(f)) Данная операция для всех четырёх порождающих фигур проиллюстрирована на рисунке 7.18. Теперь мы сможем скомпоновать центральную часть Квадратного предела из четырёх уменьшенных порождающих фигур с помощью функции four_small_figures: 228
def four_small_figures(f): sm = small_figure(f) return union(sm, rot_90(sm), rot_90(rot_90(sm)), rot_90(rot_90(rot_90(sm)))) Рис. 7.18 Уменьшенные порождающие фигуры Результат преобразование представлен на рисунке 7.19. Рис. 7.19 Центральная часть Квадратного предела для четырёх порождающих фигур 7.7.2. Компонуем основную часть квадратного предела Идея состоит в том, чтобы окружить центральную часть слоями уменьшающихся изображений, составленных из порождающих фигур, но для этого нам нужна будет ещё одна фигура, составленная из основной и двух уменьшенных фигур: def figure_two_small_figures(f): # компоновка порождающей фигуры с двумя маленькими фигурами sm = small_figure(f) return union(f, sm, rot_90(rot_90(rot_90(sm)))) Результат представлен на рисунке 7.20. Рис. 7.20 Фигуры, составленные из основной и двух уменьшенных порождающих фигур Окружение центральной части формируется рекурсивно с помощью функции side. Благодаря тому, что уменьшенное изображение строится с помощью 229
функции rot_45, в которой предусмотрено уменьшение масштаба, то по мере удаления от центра размер фигур будет уменьшаться в √2 раз от слоя к слою. def side(n, t): ''' Рисуем основную часть с помощью рекурсии ''' if n == 0: return get_blank() else: return four(side(n-1,t), side(n-1,t), rot_90(t), t) На нулевом уровне рекурсии отображается пустая фигура, на остальных уровнях рекурсии результирующая фигура составляется из четырёх в два слоя: на нижнем слое фигуры формируются с помощью side, но с уровнем рекурсии n–1, а верхний уровень из исходной фигуры, повернутой на 90 градусов, и исходной фигуры. Уменьшение уровня рекурсии на единицу позволяет избежать зацикливания. Наиболее наглядно построение слоев можно проследить при выборе в качестве порождающей фигуры треугольника (рис. 7.21): Рис. 7.21 Формирование слоев для уровней рекурсии 1,2, 3, 4 для треугольника в качестве порождающей фигуры На рисунке 7.21 видно, что число слоев соответствует уровню рекурсии. Функция side позволяет нарастить центральную часть сверху, снизу, справа и слева, но у нас остаются незаполненными углы Квадратного предела. Для покрытия лакун напишем две функции: def corner(n, t, u): ''' Рекурсивное определение угла ''' if n == 0: return get_blank() else: return four(corner(n-1,u,t), side(n-1,t), rot_90(side(n-1,t)), u) def f_corner(n, f): ''' Рисуем уголок из одной фигуры ''' f3 = figure_two_small_figures(f) f4 = four_small_figures(f) return corner(n, f3, f4) 230
Построение угла осуществляется, как и в предыдущем случае, рекурсивно. На каждом уровне рекурсии угол составляется из четырёх фигур: нижний слой составлен из угла на предыдущем уровне рекурсии и слоя (side) на том же уровне рекурсии; верхний слой состоит из повернутого на 90 градусов слоя и первой фигуры. Угол строится из двух фигур, чтобы сохранить привычный интерфейс с одной порождающей фигурой, написана функция f_corner, из которой видно, что уголок строится из двух и четырёх уменьшенных фигур. Как и в предыдущем случае, проиллюстрируем процесс рисования уголка на треугольнике, в качестве порождающей фигуры для уровней рекурсии от единицы до четырёх. Рис. 7.22 Уголок, порождённый треугольником, для четырёх уровней рекурсии Из рисунка 7.22 видно, что уголок перекрывает единичный квадрат только при уровне рекурсии больше 4. У нас всё готово для окончательной сборки Квадратного предела. 7.7.3. Сборка Квадратного предела Мы установили, что Квадратный предел состоит из центральной части, слоев, окружающих центральную часть слева, справа, сверху и снизу, а также четырёх углов. Слои и углы формируются рекурсивно. Сборка осуществляется на основе функции nine, позволяющей составить фигуру, и девяти других фигур. def f_square_limit(n, f): ''' Рекурсивно конструируем квадратный предел n - уровень рекурсии f - порождающая фигура ''' t = figure_two_small_figures(f) u = four_small_figures(f) return nine( corner(n, t, u), side(n, t), rot_90(rot_90(rot_90(corner(n,t,u)))), rot_90(side(n,t)), u, rot_90(rot_90(rot_90(side(n,t)))), rot_90(corner(n, t, u)), rot_90(rot_90(side(n,t))), rot_90(rot_90(corner(n,t,u)))) 231
Давайте посмотрим, как осуществляется формирование квадратного предела для уровней рекурсии от нуля до четырёх на примере треугольника (рис. 7.23). Рис. 7.23 Квадратный предел для треугольника в качестве порождающей фигуры для уровней рекурсии от 0 до 4 Нам осталось продемонстрировать Квадратные пределы для рассмотренных выше порождающих фигур без закраски (рис. 7.24) и с закраской (рис. 7.25). Рис. 7.24 Квадратный предел для различных порождающих фигур без закраски 232
Рис. 7.25 Квадратные пределы для различных порождающих фигур с закраской М. К. Эшер, по его собственному признанию [53], был не слишком силен в математике. Глядя на его рисунки и гравюры, поверить в это трудно. Кроме того, они привлекают вниманием и завораживают. Не знаем, как читателей, но нас Квадратный предел заставляет задуматься о связи алгебры и геометрии, мозаиках, покрывающих плоскость, рекурсии и даже гиперболической геометрии, когда бесконечная мозаика, покрывающая плоскость, умещается в единичном квадрате. И всё это нарисовано с помощью несложных скриптов, притом что авторы нарисовать руками это не могут. А именно это и нужно для применения технологий МИТ в учебном процессе! 7.8. А теперь нечто совсем иное61… 7.8.1. Круглый предел М. К. Эшер неоднократно использовал сюжет Квадратного предела. У него есть и Круглый предел. Давайте последуем его примеру и преобразуем Квадратный предел в Круглый. Оказывается, сделать это не так уж сложно, нужно только 61 Отсылка к фильму скетч-группы Монти Пайтон 1971 года “And Now for Something Completely Different”. 233
квадрат отобразить на круг. Сделать это можно несколькими различными способами, самым известным из которых является конформное отображение, сохраняющее углы у пересекающихся прямых и гладких кривых, но для этого нам бы пришлось работать эллиптическими функциями sn(𝑧, 𝑘), cn(𝑥, 𝑘), 0 ≤ 𝑘 ≤ 1 на комплексной плоскости. Значение 𝑘 определяется соотношением высоты и ширины прямоугольника, отображаемого на круг, при 𝑘 = 0 эллиптические функции вырождаются в обычные синус и косинус, но это соответствует бесконечной высоте прямоугольника, что нас не устраивает. Воспользуемся отображением, приведённым в [54]. Точки 𝑥, 𝑦 в прямоугольнике преобразуются в точки 𝑢, 𝑣 в круге с помощью формул: 𝑥2 𝑢 = sign(𝑥) , для 𝑥 2 ≥ 𝑦 2 ; √𝑥 2 + 𝑦 2 𝑥∙𝑦 𝑢 = sign(𝑦) , для 𝑥 2 < 𝑦 2 ; √𝑥 2 + 𝑦 2 𝑥∙𝑦 𝑣 = sign(𝑥) , для 𝑥 2 ≥ 𝑦 2 ; (7.4) √𝑥 2 + 𝑦 2 𝑦2 𝑣 = sign(𝑦) , для 𝑥 2 < 𝑦 2 ; √𝑥 2 + 𝑦 2 1, для 𝑡 > 0, sign(𝑡) = { 0, для 𝑡 = 0, −1, для 𝑡 < 0. Реализуется отображение квадрата на круг c помощью функции square_2_circle(x, y) (блокнот e07_08.py): def square_2_circle(x, y): xx = 2*x - 1 yy = 2*y - 1 x2 = xx**2 y2 = yy**2 r = np.sqrt(x2 + y2) r = np.where(r<=1e-6, np.inf, r) u = np.where(x2>=y2, np.sign(xx)*x2/r, np.sign(yy)*xx*yy/r) v = np.where(x2>=y2, np.sign(xx)*xx*yy/r, np.sign(yy)*y2/r) return u, v Это универсальная функция NumPy. Это означает, что она одинаково хорошо работает как со скалярными значениями координат, так и с массивами. Обратите внимание, как в этой функции обрабатывается деление на нулевое значение r. На рисунке 7.26 показано, как осуществляется отображение единичного квадрата с сеткой 50 на 50 на круг. Для преобразования Квадратного предела на Круглый достаточно преобразовать вершины его описания в терминах объекта Path matplotlib. Делается это с помощью функции circle_limit. def circle_limit(f, n): ''' Отображение квадратного предела, порожденного фигурой f с уровнем рекурсии n 234
на окружность ''' s = f_square_limit(f=f, n=n) x, y = s.vertices[:, 0], s.vertices[:, 1] u, v = square_2_circle(x, y) s.vertices[:, 0], s.vertices[:, 1] = u, v return s Рис. 7.26 Отображение единичного квадрата на круг по формуле (7.4) С помощью этой функции преобразовываем Квадратные пределы для рассмотренных порождающих функций (рис. 7.25) в Круглые пределы (рис. 7.27). Рисуются они с помощью той же функции draw, которая использовалась ранее. На заметку При отображениях отрезки прямых переходят в отрезки кривых. Matplotlib ничего об этом «не знает», поэтому желательно разбивать отрезки прямых и кривых описаний фигур на фрагменты, чтобы преобразованные фигуры выглядели более естественными. Рис. 7.27 (Начало) Круглые пределы для различных порождающих фигур с закраской 235
Рис. 7.27 (Окончание) Круглые пределы для различных порождающих фигур с закраской Но это ещё не всё. Давайте попытаемся нарисовать Квадратный предел на поверхностях в трёхмерном пространстве. 7.8.2. Натягиваем Квадратный и Круглый пределы на цилиндр, тор и ленту Мебиуса Эту задачу мы решим в блокноте e07_09.py. Мы не сможем напрямую использовать инструменты трёхмерного рисования matplotlib, т. к. работаем не с линиями и поверхностями, а с «заплатками», поэтому вернёмся к первому курсу ВУЗа и вспомним о черчении и проекциях трёхмерных фигур на плоскость. Мы не будем использовать инструменты начертательной геометрии, а сразу же применим для работы диметрическую проекцию и линейную алгебру [55]. Начнём с того, что нарисуем три оси координат на плоскости. Пусть у нас имеется точка в трёхмерном пространстве 𝑢 = (𝑥, 𝑦, 𝑧). Можно было бы рассмотреть её проекцию на плоскость 𝑥0𝑧, но тогда будет потеряна вся информация от данных по оси y. Давайте выполним повороты относительно двух осей в трёхмерном пространстве так, чтобы изменения масштабов по двум осям из трёх были бы одинаковыми при проектировании на плоскость. Такое преобразование называется диметрическим [55]. Ниже приводится функция dimet, осуществляющая диметрическое преобразование. def dimet(u): '''Диметрическое отображение точки u в трехмерном пространстве''' D = np.array([ [ 0.935414346693485, 0., 0.353553390593274], [ 0.133630620956212, 0.925820099772551, 0.353553390593274], [-0.327326835353989, 0.377964473009227, 0.866025403784439] ]) return D @ np.array(u) Функция dimet позволяет работать с точками, заданными как кортежами и списками, так и массивами NumPy. 236
Проекцию на плоскость можно осуществить тремя различными способами, приравнивая к нулю координату по одной из координатных осей. Для этого написана функция proj: def proj(u, axis='x'): ''' Диметрическая проекция точки u в трехмерном пространстве на плоскости y0z, x0z, x0y ''' v = dimet(u) if axis=='x': return np.array([v[1], v[2]]),'Проекция на y0z' elif axis=='y': return np.array([v[0], v[2]]),'Проекция на x0z' else: return np.array([v[0], v[1]]),'Проекция на x0y' А вот теперь нарисуем диметрические проекции трёх осей на плоскость: def three_axis(ax, axis='x'): vs = np.eye(3) for i in range(3): v = proj(vs[i,:],axis) ax.plot([0, v[0][0]],[0, v[0][1]], 'k-') ax.set_title(v[1]) return ax Выводим все три варианта диметрических проекций на один рисунок (рис. 7.28). def three_dimet_proj(f=three_axis, size=3): ''' Рисуем три диметрические проекции координатных осей size - высота картинки ''' axises = ('x', 'y', 'z') fig = plt.figure(figsize=(size*3, size)) for i, axis in enumerate(axises): ax = plt.subplot(1,3, i+1) ax = f(ax, axis=axis) plt.tight_layout() return fig Рис. 7.28 Три варианта диметрических проекций 237
Мы научились строить проекции трёхмерного пространства на плоскость, теперь нам необходимо определиться с двумерными поверхностями в трёхмерном пространстве, остановимся на цилиндре, торе и ленте Мебиуса: def cylinder(x, y, r=.5): # отображение единичного квадрата на цилиндр: (u, v)-> x, y, z return [r*np.cos(2*np.pi*x), r*np.sin(2*np.pi*x), y] def thor(x, y,r=.5, R=1): # отображение единичного квадрата на тор: (u, v)-> x, y, z x = 2*np.pi*x y = 2*np.pi*(y - 0.5) return [(R + r*np.cos(y))*np.cos(x), (R + r*np.cos(y))*np.sin(x), r*np.sin(y)] def mebius(u, v): # отображение единичного квадрата на ленту Мебиуса: # (u, v)-> x, y, z u *= 2*np.pi return np.array([(1+v/2*np.cos(u/2))*np.cos(u), (1+v/2*np.cos(u/2))*np.sin(u), v/2*np.sin(u/2)]) Эти поверхности легко изготовить бумаги и клея: для изготовления цилиндра достаточно свернуть лист в трубку и склеить его, тор изготавливается из цилиндра сгибанием и склейкой его концов. Для изготовления ленты Мебиуса достаточно перекрутить лист бумаги вдоль его длинной стороны и склеить. Во всех трёх случаях нужно выбирать эластичный материал. Рис. 7.29 Цилиндр, тор и лента Мебиуса Рисунок 7.29 выполнен средствами трёхмерной графики matplotlib, которые не распространяются на работу с заплатками, поэтому нам придётся выполнить преобразования и работу с проекциями своими руками, благо это все достаточно просто и делается с помощью двух функций: def apply_func(f, func, axis='x'): ''' Отображение (натягивание) фигуры, заданной matplotlib Path на поверхность, заданной функцией отображения f - фигура func - функция отображения axis - задание проекции 238
''' v = f.vertices n, m = v.shape for i in range(n): xy = func(v[i,0], v[i,1]) v[i,:] = proj(xy,axis)[0] f.vertices = v return f def fig_on_surface(fig=get_fish_outline(), map=f_square_limit, surface=cylinder, n=0, axis=False, fill=False, prj='x', figsize=(3, 3)): ''' Отображение фигуры на плоскость в трехмерном пространстве fig - порождающая фигура, map - преобразование порождающей фигуры, surface - поверхность, на которую отображается фигура n - уровень рекурсии при преобразовании порождающей фигуры, axis - признак отображения координатных осей и оцифровки, fill - признак закраски фигуры при рисовании, prj - вид проекции, figsize - размер рисунка Функция возвращает изображение ''' # создаем сложную фигуру из порождающей cf = map(n, fig) f = apply_func(cf, surface, axis=prj) return draw(f, fill=fill, axis=axis, box=False, figsize=figsize, ncols=1, nrows=1) Первая функция применяет отображение к фигуре, а вторая выполняет рисование фигуры на поверхности в трёхмерном пространстве в одной из диметрических проекций. На рисунках 7.30–7.33 квадратные пределы, составленные из символов Г и рыбок, отображается на цилиндре, торе, ленте Мебиуса. Рис. 7.30 Квадратный предел из символов Г на цилиндре, уровень рекурсии 4 в различных диметрических проекциях 239
Рис. 7.31 Квадратный предел из рыбок на цилиндре, уровень рекурсии 1 в различных диметрических проекциях На торе квадратный предел из рыбок выглядит, как представлено на рисунке 7.32. Рис. 7.32 Квадратный предел из рыбок на торе, уровень рекурсии 1 в различных диметрических проекциях Рис. 7.33 Квадратный предел из символов Г на ленте Мебиуса, уровень рекурсии 1 в различных диметрических проекциях Фигуры не замкнуты, т. к. построение квадратного предела заполняет не весь единичный квадрат, это хорошо видно при уровне рекурсии 1. Из-за использования прямых при построении символа Г отображения на ленте Мебиуса не везде гладкие. Именно поэтому мы решили посмотреть, что будет, если рисовать порождающие фигуры с помощью только кривых Безье на примере символа Ж. Как и ранее, фигура оформлена в виде функции getJ, которая возвращает объект для отображения функцией draw. На рисунке 7.34 показан этот символ, а также его отображение на круг. 240
Рис. 7.34 Символ Ж, составленный из кривых Безье, и его отображение на круг а б в г Рис. 7.35 Квадратные и круглые пределы, порождённые символом Ж: а — квадратный предел с уровнем рекурсии 0; б — квадратный предел с уровнем рекурсии 0; в — квадратный предел из символа Ж, который первоначально был отображён на окружность, с уровнем рекурсии 0; д — квадратный предел из символа Ж, который первоначально был отображён на окружность, с уровнем рекурсии 1. На этом рисунке видно, что при отображении на круг прямые преобразуются в кривые. Из символа Ж можно соорудить квадратные и круглые пределы, как показано на рисунке 7.35. Мы не отказали в себе в удовольствии перенести фигуры 7.36 на цилиндр, тор и ленту Мебиуса. 241
а б в г Рис. 7.36 Квадратные пределы, порождённые символом Ж, на цилиндре, торе и ленте Мебиуса: а — круглый предел на цилиндре с уровнем рекурсии 0; б — круглый предел на цилиндре с уровнем рекурсии 1; в — квадратный предел из символа Ж на торе с уровнем рекурсии 1; г — квадратный предел из символа Ж на ленте Мебиуса с уровнем рекурсии 1. Однострочники для построения фигур на рисунках 7.35, 7.36 приведены в блокноте e07_09.py. На этом закончим сюжет о построении мозаик на плоскости и других поверхностях, оставив за рамками построения мозаик Пенроуза [56–58] и, наверное, самую интересную, но и сложную тему об автоматизации построения мозаик. Задания читателям 1. Воспроизведите выкладки этих занятий в блокноте Jupyter или marimo. 2. Попытайтесь самостоятельно воспроизвести структуры данных и процедуры для описания простых геометрических фигур, их аффинных преобразований и визуализации. 3. В интерактивном редакторе попытайтесь собрать окружность из двух, трёх и четырёх кривых Безье. 4. Подумайте, можно ли для описания фигур использовать формат SVG [43]. Есть ли библиотеки для работы с SVG? Какие придётся вносить изменения в приложения? 5. Является ли набор преобразований, состоящий из параллельного переноса, поворотов и отражений, полным? Можно ли удалить из этого набора, например, отражения? 242
6. На занятиях мы использовали возможности библиотеки matplotlib, попробуйте решить задачи с помощью других библиотек, например, plotly. 7. Попытайтесь построить квадратный и круглый пределы на поверхности сферы. 8. При отображении на поверхности в трёхмерном пространстве желательно использовать описания фигур, состоящие исключительно из кривых Безье. Напишите скрипт, преобразующий отрезки прямых в описании фигур в кривые Безье. 9. Постройте квадратный и круглый пределы на поверхности параболоида вращения. 10. Напишите интерактивное приложение в блокноте Jupyter или marimo для рисования квадратных и круглых пределов, включающее в себя редактор порождающих фигур, построение квадратного и круглого предела для различных уровней рекурсии с включенным и отключенным закрашиванием. Предусмотрите преобразования порождающей фигуры, включая повороты, отражения и их комбинации. 243
ГЛАВА 8 ЗАНЯТИЕ 16. ПУТЕШЕСТВИЕ В МИР НАУКИ И ИСКУССТВА НА СТОПОХОДЯЩЕЙ МАШИНЕ ЧЕБЫШЁВА Села да поехала. В вагоне покойно, толчков нет. Ф. М. Достоевский, «Игрок» Полтора века назад путешествие по свету могли позволить себе только очень богатые и физически здоровые люди. Но с появлением современных транспортных средств такое удовольствие стало доступно очень многим. Сел в автомобиль, самолет или на поезд — и за короткое время с комфортом добрался практически до любого уголка Земли, если, конечно, нет визовых и прочих ограничений. Что-то подобное можно сказать и о математике. Раньше в её дебри могли забираться только избранные — люди с особыми математическими способностями и имеющие математическое образование. Но в настоящее время круг таких избранных существенно расширился за счёт появления компьютерных программ, которые очень облегчают путешествие в мир математики. Для чего изучают математику в школе и вузе? Во-первых, для того, чтобы можно было освоить другие учебные дисциплины: физику, химию, информатику, теоретическую механику и др. Поэтомуто математику преподают в школе с первого класса, а в вузе с первого курса. Во-вторых, математика — это гимнастика для ума. Изучая математику, мы развиваем свои умственные способности, которые пригодятся нам для решения не только математических, но и производственных и житейских задач. В-третьих, изучение математики — это само по себе интересное и увлекательное дело, которым можно заниматься в своё удовольствие. В данной книге эти тезисы иллюстрируются на несложных и занимательных примерах. Давайте рассмотрим ещё один пример. В Политехническом музее Москвы хранится устройство, которое имеет прямое отношение к путешествиям и «по свету», и «по математике». Это стопоходящая машина Чебышёва, преобразующая вращательное движение в поступательное (см. рис. 8.1). Рисунок 8.1 взят с интереснейшего сайта математических этюдов (http://www.etudes.ru/ru/etudes/chebyshev-plantigrade-machine), где можно увидеть анимацию движения этой машины, а также подробное описание её устройства. Центральным звеном этого экзотического транспортного средства является узел, схематично показанный на рисунке 8.2. Он называется λ-механизмом, у которого точки 1 и 2 неподвижны, точка 3 делает полные обороты по окружности вокруг точки 2 (седок крутит педали, отклоняя их от горизонтали на угол α), точка 0 движется по дуге окружности, а точка 4 — по замкнутой кривой, нижняя часть которой 244
должна быть близка к прямой горизонтальной линии. Движение точки 4 через дополнительные связи передаётся четырём стопам машины, показанной на рисунке 8.1. Всё это детально с анимацией показано на вышеотмеченном сайте. Рис. 8.1 Стопоходящая машина Чебышёва Подобные механические устройства делали и до Чебышёва, подбирая опытным путём радиус r и длины звеньев так, чтобы стопоходящая машина шла без ощутимых вертикальных толчков. Но только наш великий соотечественник сделал это не на ощупь, а применил высшую математику для проектирования подобных устройств [60]. Давайте последуем его примеру с помощью экосистемы Python. Рис. 8.2 Схема центрального узла стопоходящей машины Чебышёва 245
Суть расчёта стопоходящей машины в следующем: нужно рассчитать геометрию этого механического устройства так, чтобы точка 4 на нижнем участке своей траектории (орбиты) двигалась почти по прямой горизонтальной линии, а сама стопоходящая машина с таким центральным узлом шла бы без ощутимых вертикальных колебаний. Точки 1 и 2 неподвижны, а точки 0, 3 и 4 — находятся в движении. Координаты этих точек (абсциссы и ординаты) хранятся в пользовательских переменных с именами x0, y0, x1, y1, x2, y2, x3, y3, x4 и y4. Положение точки 0 (центральной точки нашего узла) определяется системой двух нелинейных уравнений, представляющей собой запись теоремы Пифагора, относительно x0, y0. (𝑥1 − 𝑥0)2 + (𝑦1 − 𝑦0)2 = 𝐿2 ; (8.1) (𝑥3 − 𝑥0)2 + (𝑦3 − 𝑦0)2 = 𝐿2 . Решить систему (8.1) можно как аналитически с помощью библиотеки SymPy, так и численно. Аналитическое решение представлено в блокноте e08_01.py62. При этом будем считать, что 𝐿01 = 𝐿03 = 𝐿. Нам нужно получить координаты точки 0 (x0, y0) как функции поворота точки 3 от угла alpha. Как обычно начнём с импорта необходимых библиотек: import import import import marimo as mo numpy as np matplotlib.pyplot as plt sympy as smp Далее напишем функцию для решения системы, предварительно объявив символьные переменные: x0, y0, alpha = smp.symbols("x0, y0, alpha") def cheb01(alpha, r=15, L12=40, L=35): # точка 1 x1, y1 = -L12, 0. # точка 2 x2, y2 = 0., 0. # точка 3 x3, y3 = r*smp.cos(alpha), r*smp.sin(alpha) # уравнения системы 8.1 eq1 = (x0 - x1)**2 + (y0 - y1)**2 - L**2 eq2 = (x0 - x3)**2 + (y0 - y3)**2 - L**2 # решение системы solution = smp.solve([eq1, eq2], [x0, y0]) return solution sol = cheb01(alpha) Как и следовало ожидать, мы получили два решения, так как (8.1) — уравнение второй степени. Проверить это достаточно легко: тип результата, полученный с помощью type(sol), — список, длина этого списка len(sol) равна двум. 62 Следует отметить, что выполнение блокнота из-за построения анимаций может занять несколько минут. 246
Получить аналитические выражения для формул координат точек достаточно просто: # аналитическое решение системы x01, y01 = sol[0][0], sol[0][1] # первое решение x02, y02 = sol[1][0], sol[1][1] # второе решение На рисунке 8.3 приводятся аналитические выражения для первого решения: а б Рис. 8.3 Первое решение: а —формула для горизонтальной координаты; б — формула для вертикальной координаты нулевой точки. Из формул видно, что координаты точки 0 зависят только от угла 𝛼, но с такими сложными формулами работать не очень удобно, поэтому мы преобразуем эти формулы в универсальные функции NymPy. x01p y01p x02p y02p = = = = smp.lambdify(alpha, smp.lambdify(alpha, smp.lambdify(alpha, smp.lambdify(alpha, x01, y01, x02, y02, 'numpy') 'numpy') 'numpy') 'numpy') Это позволит нам определить координаты всех остальных точек не только для отдельных значений углов, но и массивов значений. def coords(n, x0p, y0p, r=15, L12=40, L=35): angles = np.linspace(0, 2*np.pi, n) x0, y0 = x0p(angles), y0p(angles) x1, y1 = -L12, 0. x2, y2 = 0., 0. x3, y3 = r*np.cos(angles), r*np.sin(angles) x4, y4 = 2*x0 - x3, 2*y0 - y3 return x0, y0, x1, y1, x2, y2, x3, y3, x4, y4 Так как мы хотим получить не координаты точек для конкретного значения угла α, а массивы углов, то функции передаётся n — число разбиений отрезка [0, 2π] значений углов, что понадобится нам для визуализации работы стопоходящего механизма. Кроме того, функции передаются полученные нами ранее функции для вычисления координат точки 0, а именно: x0p, y0p. Сделали мы так, чтобы была возможность визуализировать оба решения. На рисунке 8.4 приводятся траектории точки 4 для обоих решений. 247
Рис. 8.4 Траектории перемещения точки 4 Легко видеть, что траектории точки 4 зеркально симметричны относительно горизонтальной оси координат. Одновременно вычисление координат точек для массива углов 𝛼 позволяет легко построить набор рисунков, иллюстрирующих работу механизма для различных значений углов (рис. 8.5). Рис. 8.5 Визуализация работы механизма, изображённого на рисунке 8.2 248
Построение анимации также не вызывает никаких трудностей, для чего используется библиотека gif (см. приложение 5): @gif.frame def play1(i): ''' i-ый кадр анимации''' fig, ax = plt.subplots(figsize=figsize) alpha = beta * i # угол ....# рисование кадра mech(ax, 500, x01p, y01p, a=alpha) return fig # формируем последовательность кадров frames1 = [play1(i) for i in range(nframes)] # сохраняем анимацию gif.save(frames1, "anim.gif", duration=50) Вставка анимации в блокнот marimo осуществляется в одну строчку: mo.image("anim.gif") Мы построили анимации для обоих решений, на рисунке 8.6 представлены кадры для них. Рис. 8.6 Кадры анимации работы механизма для обоих решений На рисунке 8.6 всё здорово, за исключением того, что перемещение шагающего механизма осуществляется не по горизонтальной линии. Нам нужно изменить описание стопоходящего механизма и подобрать его параметры так, чтобы перемещение стоп происходило по кривой, приближающейся к горизонтальной линии. Решение первой подзадачи просто, пусть радиус окружности 𝑟 остается постоянным, а длины 𝐿01, 𝐿03, 𝐿12, 𝐿04 можно было бы менять. Это чуть-чуть усложнит задачу (см. блокнот e08_02.py), зато существенно повысит время вывода аналитических формул для траектории точки 0. На домашнем компьютере одного из авторов это заняло больше минуты, а сами аналитические 249
выражения получились такой величины, что их вывод на печать оставляем читателям. Функция для определения траектории точки 0 выглядит так: # объявляем символьные переменные x0, y0, alpha, L01, L03, L12, L04 = \ smp.symbols("x0, y0, alpha, L01, L03, L12, L04") def cheb04(alpha, L01=L01, L03=L03, L12=L12, L04=L04, r=15): # точка 1 x1, y1 = -L12, 0. # точка 2 x2, y2 = 0., 0. # точка 3 x3, y3 = r*smp.cos(alpha), r*smp.sin(alpha) # уравнения системы 8.1 eq1 = (x0 - x1)**2 + (y0 - y1)**2 - L01**2 eq2 = (x0 - x3)**2 + (y0 - y3)**2 - L03**2 # решение системы solution = smp.solve([eq1, eq2], [x0, y0]) return solution Отметим, что траектория зависит не только от угла alpha, но и длин элементов механизма. Впечатлённые скоростью аналитических расчётов, преобразуем их в универсальные функции NumPy: def get_x0(sol, alpha, L01, L03, L12, L04): # аналитическое решение системы x01, y01 = sol[0][0], sol[0][1] # первое решение x02, y02 = sol[1][0], sol[1][1] # второе решение # решения x01p = smp.lambdify((alpha, L01, L03, L12, L04), x01, y01p = smp.lambdify((alpha, L01, L03, L12, L04), y01, x02p = smp.lambdify((alpha, L01, L03, L12, L04), x02, y02p = smp.lambdify((alpha, L01, L03, L12, L04), y02, return x01p, y01p, x02p, y02p 'numpy') 'numpy') 'numpy') 'numpy') У системы два решения, так что, как и раньше, функция возвращает функции для определения горизонтальной и вертикальной координат траектории нулевой точки. В конечном итоге нас интересуют численные значения координат всех четырёх точек, которые представим двумерным массивом xy с десятью строками и 𝑛 столбцами. Здесь 𝑛 — число точек траектории, точек у нас пять (от 9 до 4), а каждая траектория включает в себя горизонтальную и вертикальную составляющие. def coords(n, x0, y0, r=15, L01=25, L03=20, L12=20., L04=20.): angles = np.linspace(np.pi, 3*np.pi, n) xy = np.zeros((10, n)) xy[0, :] = x0(angles, L01, L03, L12, L04) # x0 xy[1, :] = y0(angles, L01, L03, L12, L04) # y0 xy[2, :] = -L12 # x1 xy[3, :] = 0. # y1 xy[4, :] = 0. # x2 xy[5, :] = 0. # y2 xy[6, :] = r*np.cos(angles) # x3 250
xy[7, :] = r*np.sin(angles) # y3 xy[8, :] = (xy[0, :]*(L03 + L04) - L04*xy[6, :])/L03 xy[9, :] = (xy[1, :]*(L03 + L04) - L04*xy[7, :])/L03 return xy # x4 # y4 В функции coords все параметры численные, а не символьные. Кроме числа точек траектории 𝑛 функции, две функции — 𝑥0, 𝑦0, вычисляющие траекторию точки 0. Это позволяет нам работать с обоими решениями задачи. Функции передаются параметры механизма. После вызова рассмотренных нами функций у нас всё готово, чтобы нарисовать траекторию точки 4 —перемещения стопы. На рисунке 8.7 приведены траектории для трёх сочетаний параметров стопоходящего механизма. Сделано это с помощью функции motion_path4: def motion_path4(ax, n, x0, y0, r=15, n1 = 0, n2=-1, L01=25, L03=20, L12=20., L04=20, figsize=(4, 4)): xy = coords(n, x0, y0, r, L01, L03, L12, L04) ax.plot(xy[8, :], xy[9, :], 'k-') ax.plot(xy[8, n1:n2], xy[9, n1:n2], 'r-', lw=3) n = 1000 # создаем рисунки на холсте fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(12, 4)) motion_path4(ax[0], n, x02p, y02p, n1=150, n2=850, L01=25, L03=20, L12=20, L04=20) motion_path4(ax[1], n, x02p, y02p, n1=150, n2=850, L01=45, L03=45, L12=40., L04=45) motion_path4(ax[2], n, x02p, y02p, n1=150, n2=850, L01=39.4, L03=39.4, L12=31.23, L04=39.4) for _ in range(3): ax[_].set_xlabel('x') ax[_].set_ylabel('y') plt.tight_layout() fig Рис. 8.7 Траектории перемещений точки 4 при значениях параметров: а — L01 = 25, L03 = 20, L12 = 20, L04 = 20; б — L01 = 45, L03 = 45, L12 = 40, L04 = 45; в — L01 = 39,4, L03 = 39,4, L12 = 31,23, L04 = 39,4. 251
Нашей основной задачей является получение из траектории 8.7а или 8.7б траектории с плоским участком 8.7в. Кстати, сделать это, подбирая параметры методом грубой силы, у авторов не получилось, читателям предлагается сделать это самим за разумное время. Если посмотреть анимацию, то видно, что движение точки 4 неравномерно. Это подтверждается на рисунке 8.7, где мы красным выделили 2/3 точек траектории. Это показывает, что стопа быстро проходит верхнюю часть траектории (рис. 8.7в) и основную часть времени движется по горизонтальной линии. Нам нужно средство для подбора оптимальных значений параметров механизма, переводящих начальную траекторию к траектории, показанной на рисунке 8.7в. Попытаемся подогнать то, что у нас имеется, под стандартную хорошо решаемую задачу, а именно: задачу оптимизации, т. е. поиска значений параметров, минимизирующих (максимизирующих63) некую функцию goal(u), где 𝑢 = (L01, L03, L12, L04). Основная сложность для нас — сконструировать целевую функцию, реализующую наше представление о плоскости траектории. Прежде чем читать дальше, подумайте, как сопоставить рисунок 8.7 с целевой функцией. Признаемся, что у нас это заняло некоторое время… Воспользуемся ранее рассмотренным наблюдением о неравномерности точек траектории. В оптимальном случае большинство точек траектории находится на горизонтальной прямой. Выделим их с помощью медианы64 — функции NumPy median. Так как в оптимальном случае большинство точек находится на горизонтальной линии, то и значение медианы вертикальных значений траектории будет находиться вблизи горизонтальной линии, а положение вертикальной координаты горизонтальной линии можно определить как минимальное значение вертикальных координат траектории, если горизонтальная линия находится внизу, и максимальное значение, если горизонтальный участок вверху. Таким образом, мы достаточно просто описали желаемый результат — целевую функцию: def goal(u): n=1000 # число точек траектории # распаковываем параметры L01, L03, L12, L04 = u # вычисляем значение координат xy = coords(n, x02p, y02p, r=15, L01=L01, L03=L03, L12=L12, L04=L04) miny = np.min(xy[9]) # нижняя точка траектории medy = np.median(xy[9]) # медиана return abs(medy - miny) Дальше достаточно набрать в поисковике «python минимизировать функцию», и в первой же строке поисковой выдачи найдём ссылку на описание функции scipy.optimize.minimize. Эта функция позволяет решать задачи оптимизации как без ограничений, так и с ограничениями, реализуя достаточно широкий набор методов. 63 Перейти от задачи минимизации к задаче максимизации очень просто. Достаточно вместо целевой функции goal(u) использовать — goal(u). 64 Медиана – это значение, которое делит массив данных на две равные части так, что одна половина находится слева, а вторая справа от нее. 252
В простейшем случае функции передаются следующие параметры: goal — целевая функция, initial_guess — последовательность начальных значений параметров, и метод — применяемый метод оптимизации. В документации имеются ссылки на описания методов65. Возвращает функция объект, для нас будут интересны следующие его атрибуты: success — признак успешного завершения процедуры минимизации, x — конечные значения параметров, fun — конечное значение целевой функции. Обычно следует попробовать использовать несколько методов, в нашем случае конструкция целевой функции вызывает серьезные сомнения относительно её дифференцируемости, поэтому воспользуемся симплекс-методом66 (nelder-mead), не требующим вычисления производных. Рис. 8.8 Результаты минимизации целевой функции при различных начальных значениях параметров стопоходящего механизма 65 Может оказаться необходимым передать функции дополнительные параметры, специфичные для выбранного метода. 66 Метод оптимизации Нелдера — Мида. Пример реализации на Python. [Электронный ресурс]. — URL: https://habr.com/ru/articles/332092/ (дата обращения 19.04.2026). 253
_initial_guess = 39.4, 39.4, 25, 39.4 minimize(goal, _ initial_guess, method='nelder-mead' Мы получим следующий результат: message: Optimization terminated successfully. success: True status: 0 fun: 0.11888908575088664 x: [ 3.821e+01 3.817e+01 3.046e+01 3.838e+01] nit: 119 nfev: 209 inal_simplex: (array([[ 3.821e+01, 3.817e+01, 3.046e+01, 3.838e+01], [ 3.821e+01, 3.817e+01, 3.046e+01, 3.838e+01], ..., [ 3.821e+01, 3.817e+01, 3.046e+01, 3.838e+01], [ 3.821e+01, 3.817e+01, 3.046e+01, 3.838e+01]], shape=(5, 4)), array([ 1.189e-01, 1.189e-01, 1.189e-01, 1.189e-01, 1.189e-01])) Можно провести оптимизацию параметров стопоходящего механизма для различных начальных приближений. Результаты представлены на рисунке 8.8. Над рисунками указаны начальные значения параметров. На стопоходящей машине можно путешествовать не только в мир науки, но и в мир искусства. С появлением компьютерной графики возникли новые формы изобразительного искусства, доступные многим [62–64]. Можно предложить ещё одну форму подобного творчества, связанную с механическими устройствами. Сможет ли читатель создать рисунки, приложения и/или анимации), показанные ниже? На рисунке 8.9 представлено, как рисуется лимон с помощью кинематического устройства (блокнот e08_03.py). Рис. 8.9 Моделирование кинематического устройства, рисующего лимон и не только В этом механизме точки 1 и 3 перемещаются по окружности и квадрату вокруг точек 0 и 2. Скорость перемещения можно задавать с помощью параметров 254
alpha и beta. Рисование осуществляет перо, размещенное в точке 4. Длины рычагов l02, l13, l34 задаются в пользовательском интерфейсе с помощью слайдеров. По умолчанию точки 1 и 3 поворачиваются синхронно, но можно изменить скорость их перемещения, как об этом говорилось выше. Кроме того, можно задать «запаздывание (сдвиг по фазе)» вращения точки 3 вокруг точки 2 с помощью параметра fi. Траектории точек 1 и 3 чертятся синим пунктиром, а точки 4 — толстой красной линией. Работу кинематического механизма можно наблюдать в реальном времени перемещая движок слайдера t0. а в б г Рис. 8.10 Траектории перемещения точки 4 при различных значениях параметров кинематического механизма На рисунке 8.10 приведены фигуры, которые можно нарисовать с помощью механизма. Так, на 8.10a была увеличена длина рычага l34, и у «лимона» появились острые выступы, изменение сдвига по фазе на 30 градусов превращает «лимон» в «банан» (рис. 8.10б), увеличение скорости перемещения по окружности с одного до двух порождает петли на траектории точки 4 (рис. 8.10в), ну 255
а увеличение скорости перемещения точки 3 по квадрату приводит к вычерчиванию сложной и непредсказуемой кривой (рис. 8.10г). Моделирование механизма не вызывает сложностей. Отметим только, каким образом перемещение по окружности преобразуется в перемещение по периметру квадрата с помощью функции np.where NumPy. Первым параметром этой функции передаётся условие, вторым — действия, если условие истинно, третьим — действия при ложности условия. Обратите внимание, как строится цепочка условий, действие осуществляется только над частью массива, для которого условие истинно, остальная часть массива остается без изменения. Данная функция является заменой условного оператора Python при работе с массивами NumPy. def circle(a, r=10): # окружность return r* np.cos(a), r*np.sin(a) def square(alpha, r=1): # квадрат # 0<=a<=8 a = alpha/(2*np.pi)*8 a = a % 8 x, y = np.asarray(a), np.asarray(a) #x[:], y[:] = -5, -5 x = np.where(a<=1, r, x) y = np.where(a<=1, r*a, y) x = np.where((a>1) & (a<=3), r - r*(a-1), x) y = np.where((a>1) & (a<=3), r, y) x = np.where((a>3) & (a<=5), -r, x) y = np.where((a>3) & (a<=5), r - r*(a - 3), y) x = np.where((a>5) & (a<=7), y = np.where((a>5) & (a<=7), -r + r*(a -5), x) -r, y) x = np.where(a>7, r, x) y = np.where(a>7, -r + r*(a -7) , y) return x, y Для моделирования кинематического механизма не нужно даже решать систему алгебраических уравнений, как для стопоходящего механизма, достаточно вспомнить школьные геометрию и тригонометрию. def mech(t0=30, fi=30, n=1000,alpha=1, beta=1, r1=10, r2=10, l02=25., l14=20., l34=20.): ind = int(t0/180*n)%n x0, y0 = 0., 0. # точка 0 t = np.linspace(0, 2*np.pi, n) # точка 1 x1, y1 = r1*np.cos(alpha*t), r1*np.sin(alpha*t) x1t, y1t = x1[ind], y1[ind] x2, y2 = l02, 0. # точка 2 # точка 3 256
x3, y3 = (x2 + r2*np.cos(beta*t + fi/180*np.pi), r2*np.sin(beta*t + fi/180*np.pi)) x3, y3 = square(beta*t + fi/180*np.pi, r=r2) x3 += x2 x3t, y3t = x3[ind], y3[ind] l13 = np.sqrt((x1 - x3)**2 + (y1 - y3)**2) # координата точки 4 x4s = (l13**2 - l34**2 + l14**2)/(2*l13) y4s = np.sqrt(np.abs(l14**2 - x4s**2)) gamma = np.arctan2(y3-y1, x3-x1) delta = np.arctan2(y4s, x4s) nu = gamma - delta x4, y4 = x1 + l14*np.cos(nu), y1 + l14*np.sin(nu) #x4, y4 = x1 + l14*np.sin(gamma), y1 + l14*np.cos(gamma) x4t, y4t = x4[ind], y4[ind] l14s = np.sqrt((x1t - x4t)**2 + (y1t - y4t)**2) l34s = np.sqrt((x3t - x4t)**2 + (y3t - y4t)**2) return (x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x1t, y1t, x3t, y3t, x4t, y4t) Процедура рисования кинематического механизма реализована функцией paint_mech, приведённой в блокноте e08_03.py, там же можно посмотреть, как работает приложение на рисунке 8.9. Рассмотрим ещё одно устройство для рисования, называемое спирограф [65], а заодно, посмотрим, как в Python удобно работать с комплексными числами. В этом устройстве по внутренней поверхности круга радиуса R движется круг радиуса r, R > r (рис. 8.11). Рис. 8.11 Схема функционирования спирографа 257
Каждый момент времени круги соприкасаются, не проскальзывая друг относительно друга. В реальном устройстве это осуществляется за счёт зубчатого зацепления. Во внутреннем круге на расстоянии 𝜌𝑞 ≤ 𝑟, 𝑞 = 0,1, … от его центра встроен один или несколько пишущих инструментов, перемещающихся вместе с ним. Эти инструменты рисуют кривые на листе бумаги, на котором размещается спирограф. Рисование кривых обычно осуществляется различными цветами. При перемещении внутреннего круга по часовой стрелке его центр перемещается по окружности радиусом R–r (пунктирная линия на рис. 8.11). При перемещении центра внутреннего круга на угол α по часовой стрелке путь, пройденный по внешнему кругу, будет равен αR. В то же время внутренний круг повернётся на угол β против часовой стрелки, а его центр переместится на угол β по часовой стрелке. Так как проскальзывание отсутствует, перемещения по внешнему и внутреннему кругу равны: (8.2) α𝑅 = (α − β)𝑟. Используя (8.2), можно выразить угол поворота внутреннего круга относительно его центра. Можно показать, что 𝑅−𝑟 (8.3) 𝑤(𝑡) = (𝑅 − 𝑟) ∙ 𝑒 1𝑗∙𝑡 + 𝑟𝑒 −1𝑗∙ 𝑟 𝑡 , где 0 ≤ 𝑡 ≤ 2π, а 1𝑗 — мнимая единица. Формула (8.3) описывает перемещение точки на поверхности внутреннего круга, для пишущих инструментов, находящихся на расстоянии ρ𝑞 ∙ 𝑟 от центра внутреннего круга, 0 < ρ𝑞 < 1, 𝑞 = 0, 1, 2, …, (8.3) можно переписать следующим образом: 𝑤(𝑡) = (𝑅 − ρ𝑞 ∙ 𝑟) ∙ 𝑒 1𝑗∙𝑡 + 𝑟𝑒 −1𝑗∙ 𝑅−𝑟 𝑡 𝑟 . (8.3) Спирограф реализуется с помощью одноименной функции (блокнот e08_04.py): def spirograph(ax, R=1., r=0.75, ρs=(1.25, 1., .75, 0.5, .25), T=10, n = 1000, lw=3 ): a = (R-r, r/R) b = (1, -R/r+1) t = np.linspace(0, 2*np.pi*T, n) m = len(ρs) z = np.zeros((n, m), dtype=np.complex128) for i in range(m): z[:, i] = a[0]*np.exp(1j*b[0]*t) +\ a[1]*ρs[i]*np.exp(1j*b[1]*t) ax.set_aspect(1) for i in range(m): ax.plot(z[:, i].real, z[:,i].imag, lw=lw, label=f'ρ={ρs[i]:5.3f}') 258
ax.legend() ax.set_title(f"{R=}, {r=}") ax.axis('off') Функции передаются объект рисунка rs, радиусы окружностей спирографа R и r, радиусы рисующих инструментов rs, число периодов T, число разбиений n и толщина линии lw. Рис. 8.12 Кривые, нарисованные спирографом На рисунке 8.12 приводится несколько рисунков, сделанных спирографом для различных значений r. Задания читателям 1. Постройте графики изменения скорости (полной, а также горизонтальной и вертикальной проекций) стопоходящей машины с оптимальными геометрическими размерами в зависимости от времени. 259
2. Нарисуйте c помощью Python узоры, как это сделано, например, в четвёртой главе книги [65]. 3. Модифицируйте механизм на рисунке 8.9, заменив окружность и квадрат на более сложные замкнутые кривые. 4. Подумайте, можно ли сконструировать механизм, который вычерчивает кривую, как угодно близкую к заданной. 260
ПРИЛОЖЕНИЯ Приложение 1 Cоздаём рабочую среду с помощью менеджера пакетов uv В любом хорошем классическом триллере67 герои примерно половину экранного времени посвящают подготовке своих свершений. Последуем их примеру, но постараемся сделать это гораздо быстрее. Для работы с блокнотами книги, как мы договорились, нам понадобится свежая установка Python, библиотеки для решения задач и среда разработки. При работе под управлением Linux Python уже есть, но его использует операционная система. Чтобы ничего не испортить, создадим виртуальное окружение, независимое от системной установки. Точно так же мы поступим и на Windows. Для этого нам понадобится менеджер пакетов, позволяющий выполнить создание виртуального окружения, установку Python нужной версии и библиотек, используемых для решения задач. Таким менеджером является uv, написанный на Rust, который достаточно универсален и к тому же делает свою работу очень быстро. Если Python уже установлен в системе, то в терминале Linux или окне командной строки Windows достаточно ввести: pip install uv68 Если же необходимо установить uv с нуля, то в Linux необходимо выполнить команду: curl -LsSf https://astral.sh/uv/install.sh | sh Под Windows необходимо открыть меню Пуск, выбрать Windows PowerShell и щелкнуть на ней мышью. В появившемся окне нужно выполнить команду: Set-ExecutionPolicy RemoteSigned -scope CurrentUser На вопрос системы нужно ответить Y. Это позволит второй командой установить подписанное приложение uv: irm https://astral.sh/uv/install.ps1 |iex После установки может потребоваться добавить путь к uv в переменные окружения (система сообщит об этом): $env:Path="path_to_uv;env:Path" Здесь path_to_uv — путь к исполняемому файлу uv, в случае авторов: PS C:\Users\ai\.local\bin 67 «Семь самураев»: как фильм Акиры Куросавы изменил кинематограф. [Электронный ресурс]. — URL: https://wink.ru/blog/sem-samuraev-kak-film-akiry-kurosavy-izmenil-kinematograf (дата обращения 14.04.2026). 68 Может оказаться, что в Linux необходимо вместо pip ввести pip3. 261
Проверить установку можно, введя команду: uv version В нашем случае было выведено: uv 0.5.29 (ca73c4754 2025-02-05) У вас, скорее всего, будет установлена более поздняя версия. Создаём рабочую среду Теперь нашей задачей является создать рабочую среду (виртуальное окружение). Хорошим тоном является создание рабочей среды под каждый проект. Создадим проект для книги, хотя логичнее было бы создавать отдельные проекты для каждой главы, но мы используем практически одинаковый набор библиотек, поэтому не хотелось бы занимать лишнее место, тем более что виртуальное окружение книги занимает около 1 Гб дискового пространства. Запустим терминал или окно командной строки, перейдём туда, где будем создавать наш проект. Покажем, как это будет выглядеть под Windows: D:\ Здесь мы перешли на диск D:, где у нас будет создан проект. Создаём папку проекта: mkdir 16etudes Переходим в папку проекта: cd 16etudes Теперь настала очередь создания виртуальной среды с отдельной от всего остального установкой Python 3.12. Мы остановились на этой версии, так как в процессе работы не все библиотеки, используемые в книге, были готовы для работы с более поздними версиями Python. Виртуальное окружение создаётся менеджером пакетов uv с помощью команды venv. В этой команде нужно указать, где будет установлены системные библиотеки, кроме того, можно задать устанавливаемую версию Python: uv venv .venv -p 3.12 В данном случае виртуальное окружение будет создано в папке .venv. Перед началом работы виртуальное окружение требует активации. В Windows необходимо выполнить команду: .venv\Scripts\activate После этого поменяется префикс командной строки, так, в Windows PowerShell он станет выглядеть: (.venv) PS D:\16etudes> В Linux активация виртуального окружения осуществляется несколько подругому, командой: source .venv/bin/activate Теперь нужно попробовать то, что у нас установлено, выяснив версию Python. Для этого в командной строке введём: python --version В нашем случае ответом будет: Python 3.12.8 При выполнении примеров книги версия может измениться в сторону увеличения. В данный момент в папке D:\16etudes имеется единственная папка виртуальной среды .venv. 262
Теперь настал момент создания проекта — воспроизводимой среды для работы с примерами книги. Делается это командой: uv init . С помощью этой команды осуществляется инициализация проекта. Точка означает, что проект будет создан в текущей папке. После выполнения команды uv сообщит: Initialized project `16etudes` at `D:\16etudes` Содержимое папки 16etudes изменится: 16etudes |- .venv |- .python-version |- hello.py |- pyproject.toml |- README.MD Добавлены следующие файлы: .python-version, где указана версия Python, hello.py для проверки работоспособности, pyproject.toml — файл конфигурации проекта, чтобы менеджер пакетов uv смог отслеживать установленные библиотеки; README.MD — файл Markdown, в который помешается краткое описание проекта. Проверку работоспособности выполним запуском на выполнение hello.py: (.venv) PS D:\16etudes> python hello.py Привет из 16etudes!69 Структура файла проекта достаточно проста: [project] name = "16etudes" version = "0.1.0" description = "Примеры к книге ..." readme = "README.md" requires-python = ">=3.12" dependencies = [] В нем указано имя проекта (name), текущая версия (version), описание (description), файл описания проекта readme, список установленных библиотек (dependences), пока что он пуст. Теперь настало время для установки библиотек, которые нам понадобятся: uv add black cryptography fastapi gif jinja2 marimo[sql] matplotlib numpy pandas plotly pooch ruff schemdraw scipy seaborn stats sympy toml typing uncertainties uv, в отличие от pip и poetry, осуществляет установку библиотек очень быстро, используя многопоточную загрузку. Теперь файл проекта выглядит следующим образом: [project] name = "16etudes" version = "0.1.1" description = "Примеры к книге 16 занятий..." readme = "README.md" requires-python = ">=3.12" 69 Мы перевели на русский язык сгенерированный uv текст. 263
dependencies = [ "fastapi>=0.118.3", "gif>=23.3.0", "jinja2>=3.1.6", "marimo[sql]>=0.16.5", "matplotlib>=3.10.6", "numpy>=2.3.3", "pandas>=2.3.3", "plotly>=6.3.1", "pooch>=1.8.2", "ruff>=0.14.0", "schemdraw>=0.21", "scipy>=1.16.2", "seaborn>=0.13.2", "stats>=0.1.2a0", "sympy>=1.14.0", "toml>=0.10.2", "typing>=3.10.0.0", "uncertainties>=3.2.3", ] Наверное, необходимо дать пояснения относительно набора установленных библиотек. C помощью fastapi мы объединим рассмотренные в книге примеры в виде библиотеки, numpy, pandas, scipy и scikit-image используются для решения задач, matplotlib, plotly — для визуализации. Основной наш инструмент marimo мы рассмотрим в следующем разделе. Как создать виртуальное окружение по файлу проекта Можно поступить ещё проще. Создаём новую папку проекта, это можно сделать на другом компьютере, копируем в неё файл проекта project.toml и запускаем команду: uv sync Виртуальное окружение готово. У нас всё готово для начала работы. Нужно только не забывать активировать виртуальное окружение. Приложение 2 Marimo — не только украшение аквариума70 После установки в виртуальном окружении присутствует интерпретатор Python, с которым можно работать в окне командной строки, набор библиотек для решения научно-технических задач, но это не совсем соответствует целям МИТ. Было бы желательно, чтобы условия задач вместе с форматированным текстом, 70 Эгагропила Линнея. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Эгагропила_Линнея (дата обращения 16.04.2026). 264
формулами, картинками и видео можно было бы прочитать и проанализировать перед началом работы, после чего написать код для решения задачи, выполнить его, внести необходимые исправления, провести вычислительный эксперимент, представить результаты в наглядной форме, т. е. с изображениями и анимацией, обсудить их, сделать необходимые выводы. Кроме то, желательно иметь возможность опубликовать сделанное, сделав его доступным другим, построить пользовательский интерфейс интерактивных приложений, чтобы в реальном времени вводить данные и получать результаты… Уф! Нам необходимо что-то пофункциональнее школьной тетради и шариковой ручки. Самое интересное, что всё это доступно бесплатно. То, о чем мы говорили выше, придумано Дональдом Кнутом71, когда он шестьдесят лет назад начал работать над своим фундаментальным трудом по информатике «Искусство программирования72». Реализованы его идеи в виде вычислительных документов (блокнотов), которые уже более 30 лет используются в математических системах, например, Matlab, Mathcad, SMath, Mathematica, Maple, Octave, SciLab и многих других. В той или иной мере они являются метафорой школьной тетради на экране ноутбука, планшета, а иногда и смартфона, позволяя выполнить все перечисленные выше этапы решения задач. В экосистеме Python наиболее популярным является проект Jupyter73. Здесь же отметим, что название происходит не от имени римского бога Юпитера, а от сокращённых названий трёх языков программирования Julia, Python и R74. Именно поэтому третьим символом идет y, а не i. На самом деле это набор совместно используемых стандартов, инструментов разработки и служб, включая среды разработки JupyterLab (JL), Jupyter Notebook (JN), средства работы с Python, включённые непосредственно в веб-браузер, под названием JupyterLite, элементы пользовательского интерфейса Jupyter Notebook, Voila — возможность работы с блокнотами как с веб-приложениями без отображения исходного кода, JupyterHub — сервер многопользовательской работы с блокнотами JN, JL и многое другое. Очень привлекательной чертой проекта являются языковые процессоры, которые позволяют в одном и том же блокноте работать с разными языками программирования. В настоящее время, кроме перечисленных выше, поддерживается более 30 языков программирования, включая Javascript, Ruby, C, Octave. JN, JL работают как веб-приложения в браузере, блокноты представляют собой текстовые файлы в формате JSON75, так что читать их не особенно удобно. Состоят блокноты из ячеек двух типов: документных и вычислительных. Ячейки первого типа могут работать с Markdown76 — простым языком разметки для 71 Кнут, Дональд Эрвин. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Кнут,_Дональд_Эрвин (дата обращения 17.04.2026)). 72 Искусство программирования. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Искусство_программирования?ysclid=mo2ltkve5p269138477 (дата обращения 17.04.2026). 73 Try Jupyter. Use our tools without installing anything. [Электронный ресурс]. — URL: https://jupyter.org/try (дата обращения 17.04.2026). 74 Project Jupyter. [Электронный ресурс]. — URL: https://jupyter.org/ (дата обращения 17.04.2026). 75 JSON. [Электронный ресурс]. — URL https://ru.ruwiki.ru/wiki/JSON?ysclid=mo2m7c5834948177725 (дата обращения 17.04.2026). 76 Markdown. [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Markdown?ysclid=mo2mqfdbnm124761465 (дата обращения 17.04.2026). 265
вставки в блокнот форматированного текста, таблиц, рисунков и видео. Допускается использование HTML. Для работы с формулами предназначен LaTeX 77. Работать с Markdown и LaTeX достаточно просто78, мы будем использовать их при работе с примерами к книге. Другим типом ячеек являются вычислительные, именно в них помещается исходный код. Ячейки могут находиться в двух состояниях: редактирования и выполнения. Для выполнения достаточно на нажать кнопку Run (Выполнить) на панели инструментов блокнота. При этом ячейка будет выполнена, в области вывода под ячейкой отображаются системные сообщения, сообщения об ошибках. Там же размещается и вывод, сформированный кодом ячейки, включая результаты визуализации, полученныю с помощью matplotlib или plotly. Отметим две особенности вычислительных ячеек. Во-первых, обычно забывают о том, что последняя строка ячейки особенная. её значение не только вычисляется, но и выводится, например, если ячейка выглядят так: # ячейка 1 a = 42 a то под ячейкой будет выведено: 42 Во-вторых, областью видимости переменных в ячейках является весь блокнот. В связи с этим возникает так называемая проблема скрытого состояния. Сводится она к следующему. Пусть в некоторой ячейке мы присвоили значение переменной, а в следующих ячейках использовали эту переменную для вычисления других переменных. Затем по каким-либо причинам мы удалили ячейку с определением переменной, так как мы можем редактировать и выполнять ячейки в произвольном порядке. Ничего не изменится, переменная останется в оперативной памяти, и мы сможем выполнять ячейки, в которых используется удаленная переменная. Всё меняется, когда мы сохраним блокнот и запустим его в следующий раз. Мы получим сообщение, что значение переменной, удаленной в предыдущем сеансе работы с блокнотом, не определено. Это очень неприятно. Результаты выполнения блокнота из-за скрытого состояния становятся невоспроизводимыми. Идеи, заложенные в Jupyter, оказались настолько удачными, что у JL и JN появилось большое число аналогов. Мы назовем всего несколько79: Google Colab, nteract, Microsoft Azure Notebooks, Engee. Все они развивают возможности JN, JL в том или ином направлении, например, Google Colab позволяет даже в бесплатной версии проводить вычисления с использованием графических процессоров, а отечественная разработка Engee80 кроме работы с блокнотами Julia и Python даёт возможность наглядного построения динамических моделей по аналогии с Matlab Simulink. 77 LaTeX. [Электронный ресурс]. — URL: https://ru.wikipedia.org/wiki/LaTeX (дата обращения 17.04.2026). Короткие шпаргалка по работе с Markdown и LaTeX приведены в Приложениях 3 и 4 соответственно. Jupyter Notebook & 10 Alternatives: Data Notebook Review [Электронный ресурс]. — URL: https://www.saashub.com/alternatives/post-lakefs-2023-09-11-jupyter-notebook-and-10-alternatives-data-notebook-review-2023 (дата обращения 17.04.2026). 80 Engee. Российская платформа математических вычислений и динамического моделирования [Электронный ресурс]. — URL: http://start.engee.com/ (дата обращения 17.04.2026). 78 79 266
Мы же используем в книге сравнительно новый проект marimo. Его название связано с декоративной аквариумной водорослью, логотип проекта представлен в заголовке главы. Необходимо сказать несколько слов в своё оправдание, почему мы используем marimo. Прежде всего нас привлекла простая технология встраивания приложений marimo в обычные веб-приложения без всякой доработки и необходимости подключения дополнительных приложений. Дело в том, что небольшие веб-приложения удобно использовать в виде небольших лабораторных работ, в которых требуется решить задачу проектирования предметной области. Заданием являются ограничения на входные и выходные параметры, а само приложение рассчитывает значения выходных параметров по входным параметрам. Как оказалось, если параметров в приложении больше трёх, то решить такую задачу без знания взаимосвязей между параметрами методом грубой силы практически невозможно. Вопрос только в том, насколько просто изготавливать и применять в учебном процессе такие приложения. Так вот, marimo позволяет всё это делать достаточно просто и в процессе подготовки к занятиям. В marimo встроена так называемая реактивность. Под реактивностью понимается следующее. Если в одной из ячеек определена некоторая переменная, например, a, то при изменении её значения автоматически будут пересчитаны все ячейки, в которых используется эта переменная. Менять значение переменной можно только в той ячейке, где она была определена. После изменения marimo сделает пересчёт автоматически, а заодно проследит, чтобы не было зацикливаний. Все обычные переменные Python в ячейках marimo рассматриваются как реактивные. Если необходимо работать с нереактивными переменными, их идентификаторы должны начинаться с подчёркивания или «скрываться» в функциях. Областью видимости таких переменных является ячейка или, во втором случае, функция. Автоматическое отслеживание последовательности вычислений позволяет устранить скрытые состояния и обеспечить воспроизводимость выполнения блокнотов. На наш взгляд, такое решение не слишком удачно, можно было бы явно объявлять реактивные переменные, как это сделано, например, в Solara81. В marimo встроен достаточно объёмный и расширяемый набор элементов пользовательского интерфейса, а процесс построения приложений с пользовательским интерфейсом осуществляется даже проще, чем в JN, JL. Блокноты marimo — это обычные файлы Python, никакого JSON в них нет, а разметка ячеек осуществляется с помощью специальных комментариев. Это, в свою очередь, позволять импортировать объекты блокнотов marimo в другие блокноты или даже в модули Python. В marimo достаточно удобные средства для работы с данными. Так, например, имеется специальный тип ячеек для работы с SQL82. При этом запросы можно делать не только к таблицам реляционной базы данных, но и к другим 81 Build high-quality web applications in pure Python [Электронный ресурс]. — URL: https://solara.dev/ (дата обращения 17.04.2026). 82 Using SQL [Электронный ресурс]. — URL: https://docs.marimo.io/guides/working_with_data/sql/#usingsql (дата обращения 17.04.2026). 267
квазитабличным объектам, например, фреймам данных библиотек pandas и polars. Также встроены достаточно удобные компоненты для представления и визуализации объектов данных. Теперь об отличиях. Блокноты marimo в отличие от JN, JL могут работать только с Python, Markdown и LaTeX, зато в документных ячейках можно отображать данные из вычислительных ячеек, а Markdown можно использовать при построении пользовательских интерфейсов. Вывод вычислительной ячейки располагается над ячейкой, а не под ячейкой, как в JN, JL. В то же время сообщения об ошибках и системный вывод осуществляется под ячейкой, что достаточно непривычно. Следует иметь в виду, что по умолчанию при запуске блокнота marimo ячейки запускаются на выполнение. Естественно, в настройках можно заблокировать автоматическое выполнение ячеек. При запуске JN, JL можно открыть одновременно несколько блокнотов. При работе с блокнотами marimo для каждого блокнота необходимо запускать отдельное приложение. Начнём теперь работу с блокнотом marimo, для чего в окне командной строки активируем окружение и запустим блокнот marimo на редактирование: d:\16etudes>.venv\scripts\activate (.venv) d:\16etudes>marimo edit e01_01.py Мы будем придерживаться правила нумерации примеров по главам. Указание расширения в имени файла блокнота обязательно. При первом запуске блокнот будет автоматически создан. На заметку Кстати, высокая производительность uv позволяет в экстренных случаях запускать блокноты marimo на лету. Для этого нужен только установленный uv и доступ в Интернет. Для запуска блокнота нужно только ввести команду: uvx marimo edit указатель_ресурса_или_путь_к_блокноту П.2.1. Пользовательский интерфейс блокнота marimo Данный раздел носит справочный характер, и его можно пропустить при первом чтении, возвращаясь к нему по мере необходимости. При запуске в браузере откроется заготовка блокнота (рис. П2.1). Разработчики не стали использовать меню, а связали функционал с многочисленными кнопками. Ниже приводятся пояснения по элементам управления блокнота marimo. Сразу отметим, что осваивать их назначение можно постепенно, возвращаясь к приведённым ниже пояснениям. Следует также иметь в виду, что проект marimo интенсивно разрабатывается, некоторые элементы пользовательского интерфейса и функции фреймворка могут меняться по сравнению с тем, что описаны в книге. 1. В блокноте всегда отображается путь к редактируемому блокноту marimo. 2. Кнопка, открывающая меню с доступом к основному функционалу блокнота (рис. П2.2). 268
Рис. П2.1 Пользовательский интерфейс блокнота marimo Рис. П2.2 Основное меню управления блокнотом 269
2.1. Элемент Share (Совместное использование) открывает ещё одно меню, которое позволяет опубликовать блокнот в веб. 2.2. Элемент Download (Загрузить) позволяет сохранить блокнот в различных форматах: как HTML-страницу, HTML-страницу без исходного кода, как документ Markdown, исходный код Python, как PDF-документ и изображение PNG. 2.3. Helper panel (панель дополнительных функций) позволяет открыть панель с файлами в папке с блокнотом, создать файл или папку, скопировать в эту папку файл, открыть или закрыть открытую папку; открыть панель, в которой можно просмотреть тип, значение переменных, локализацию их в ячейках блокнота; панель источников данных, включая фреймы, данных для их просмотра, панель зависимостей (связей между пакетами и модулями); панель пакетов, где можно установить требуемые пакеты и модули с помощью uv или pip; панель оглавления, создаваемого с помощью заголовков Markdown; панель журнала вывода в каналы stdout и stderr: журнал, где отображается последовательность запусков ячеек, в том числе и в графической форме; панель с набором примеров исходного кода (Snippets); панель Scratchpad для выполнения небольших фрагментов исходного кода, в которой доступны переменные блокнота; панель ошибок, где отображаются сообщения об ошибках. 2.4. Элемент меню «Представить в виде» (Present as) предназначен для переключения представления блокнота. Так, первый элемент «Переключить представление» (Toggle app view) предназначен для переключения из режима редактирования в рабочий режим, когда исходный текст в ячейках не отображается и блокнот выглядит как страница приложения; кроме того, ячейки можно расположить по вертикали (Vertical), распределить ячейки блокнота в ячейки прямоугольной таблицы (Grid), перелистывать ячейки блокнота как слайды презентации (Slides). Последнее представление также предполагает удаление исходного текста. В любой момент можно переключиться к исходному представлению для продолжения редактирования ячеек блокнота. 2.5. «Сделать копию блокнота» (Create notebook copy). Данный элемент меню даёт возможность сохранить копию текущего содержимого блокнота. 2.6. «Скопировать исходный текст блокнота в буфер обмена» (Copy code to clipboard). При выборе данного элемента меню в буфере обмена появляется копия текущего содержимого блокнота. 2.7. «Отключить выполнение ячеек блокнота» (Disable all cells). Ранее уже говорилось, что по умолчанию при открытии блокнота все ячейки автоматически выполняются. Данный элемент меню позволяет отключить выполнение ячеек. данное поведение можно восстановить, выбрав элемент меню «Включить выполнение ячеек блокнота» (Enable all cells). 2.8. «Очистить все области вывода» (Clear all outputs). После выбора этого элемента меню все области вывода будут очищены и схлопнутся. 2.9. «Выполнить все ячейки» (Re-run all cells). Все ячейки будут выполнены, если они не заблокированы для выполнения. 2.10. Панель команд (Command palette). При выборе данного элемента меню открываются доступ ко всем возможностям, имеющимся в меню 2. 270
2.11. Пользовательские настройки (User Settings). При выборе открывается многостраничная панель для настройки редактора кода, отображения блокнота, выбора менеджера пакетов, времени выполнения формирования подсказок ИИ, экспериментальных возможностей marimo. Здесь мы рассмотрим только настройки редактора, отображения блокнота и времени выполнения. Для редактора можно задать время, через которое будет осуществляться автосохранение блокнота, максимальную длину строк исходного текста, форматирование при сохранении. Для отображения блокнота можно задать ширину ячеек блокнота, тему оформления, размер шрифта, размещение области вывода: над ячейкой (по умолчанию) или под ячейкой. Наконец, на странице настройки времени выполнения, задаются режимы выполнения ячеек (ячейки запускаются на выполнение при изменении состояния ячейки по умолчанию, что можно отключить) и автоматической загрузки импортируемых модулей при их изменении. 2.12. Назначение клавиш клавиатуры (Keyboard shortcuts). Выбор элемента меню приводит к открытию прокручиваемой панели, где описано назначение используемых в marimo сочетаний клавиш. 2.13. Выбор элемента меню «Документация» (Open documentation) приводит к открытию в отдельной вкладке браузера сайта документации marimo. 2.14. Перезапуск ядра (Restart kernel) приводит к прекращению выполнения блокнота и его перезапуску. 3. Нажатие кнопки «Настройка» (Settings) приводит к открытию панели, где можно выполнить настройки приложения, указав ширину, занимаемую ячейками блокнота из набора: medium (средняя — значение по умолчанию), compact — узкая, full — всё окно браузера; задать заголовок приложения, относительный путь к файлам каскадных таблиц стилей, добавляемых в блокнот, файла HTML, вставляемого в заголовочную часть HTML-представления блокнота, добавить автосохранение блокнота в форматах HTML или ipynb, установив соответствующие флажки, отрыть панель с пользовательским настройками, упомянутую в п. 2.11. 4. Нажатие на эту кнопку (Shutdown) приводит к завершению работы с блокнотом. 5. При создании блокнот отображается с единственной ячейкой, в которой имеется подсказка о необходимости начать работу с блокнотом с импорта в него marimo. 5. Под последней ячейкой находятся три кнопки для добавления ячеек трёх типов, нажатие самой левой из них приводит к созданию новой ячейки Python. 7. Нажатие этой кнопки приводит к созданию документной ячейки. 8. В ячейке данного типа можно делать запросы SQL как базам данных, так и фреймам данных. 9. Кнопка предназначена для формирования подсказок с помощью систем ИИ. По умолчанию данная кнопка заблокирована, для её активации необходимо произвести настройку (см. п. 2.11). 10. На правом краю блокнота расположена панель кнопок 10–14, верхняя из них предназначена для сохранения блокнота в файловой системе компьютера. 271
11. Кнопка Toggle app view осуществляет переключение между режимом редактирования и выполнения блокнота (см. п. 2.4). 12. Нажатие кнопки Show command palette приводит к отображению панели команд (см. п. 2.10). 13. Кнопка Stop (interrupt) execution позволяет прервать выполнение ячеек блокнота. 14. Кнопка Run all state cells позволяет запустить на выполнение все ячейки блокнота, в которых либо определены, либо используются реактивные переменные. 15. Панель с кнопками 15–26 во многом дублирует возможности меню 2. Верхняя кнопка «Просмотр файлов» (View Files) приводит к открытию панели для работы с папками и файлами (см. п. 2.3). 16. Исследование переменных (Explore variables). При нажатии кнопки открывается панель с таблицей реактивных (общих, глобальных для блокнота) переменных. Кроме того, отображаются типы, значения переменных, а также их локализация с точностью до ячейки, как это показано на рисунке П2.3. Рис. П2.3 Панель Исследование переменных (Explore variables) 17. Исследование источников данных (Explore data sources). В качестве примера на рисунке П2.4 приводится структура сконструированного в ячейке блокнота фрейма данных pandas. 272
Рис. П2.4 Исследование источников данных (Explore data sources) 18. Исследование зависимостей (Explore dependencies). В панели, открывающейся по этой кнопке, в наглядной форме демонстрируются зависимости между ячейками блокнота и переменными. 19. Управление пакетами (Manage packages). По этой кнопке открывается панель с перечнем установленных в виртуальном окружении пакетов. Здесь же можно установить дополнительные пакеты. 20. Оглавление блокнота (View Outline). По нажатию кнопки в панели отображаются заголовки Markdown и Html. 21. Просмотреть живую документацию (View live docs). Если открыть по этой кнопке панель Documentation, то, щелкнув мышью на объекте или методе marimo, можно просмотреть по нему документацию. 22. Журналы блокнота (Notebooks logs). В этой панели можно посмотреть вывод в каналы stdout и stderr с локализацией с точностью до ячейки. 23. Отслеживание (Tracing). В этой панели можно отследить последовательность запусков ячеек (рис. П2.5). Рис. П2.5 Панель Отслеживание (Tracing) 273
24. Эта кнопка приводит к открытию панели Фрагменты кода (Snippets), содержащей большое число примеров, которые можно просмотреть и вставить в блокнот (рис. П2.6). 25. Блокнот (Scratchpad). Панель для проведения вычислений в реальном времени с доступом к переменным блокнота. 26. Отправить отзыв (Send feedback!). Позволяет связаться с разработчиками. 27. На нижней панели блокнота расположено несколько виджетов. Мы их перечислим слева направо. Сообщения об ошибках (View errors). Нажатие кнопки приводит к открытию панели с сообщениями об ошибках. 28. При запуске блокнота (on startup). Этот переключатель позволяет задавать поведение блокнота при его запуске. По умолчанию все ячейки будут выполнены. Щелчок по переключателю переведёт блокнот в режим «ленивого» выполнения, как это имеет место быть в JN, JL. Рис. П2.6 Панель Фрагменты кода (Snippets) 274
29. Переключатель режима выполнения ячеек при изменении их содержимого (on cell change). Режимом по умолчанию является автоматический запуск (autorun). В этом режиме при изменении значения реактивной переменной будут пересчитаны все ячейки, где на неё есть ссылки. При переключении в «ленивый» (lazy) режим пересчёт придётся осуществлять вручную. 30. По умолчанию внесение изменений в модули, импортируемые в блокноте, приводит к их перезагрузке. Переключатель при изменении модуля (on module change) позволяет изменить этот режим (autorun), либо отключив перезагрузку (режим off), либо загружая изменённые модули по мере необходимости (режим lazy). 31. Задержав указатель мыши на этом информационном указателе, можно получить информацию об используемой памяти (рис. П2.7). Рис. П2.7 Информация об оперативной памяти, памяти, используемой сервером marimo и ядром 32. Информационный указатель загруженности процессора. 33. Кнопка, открывающая панель пользовательских настроек (User Settings) для подключения к системам ИИ (см. также п. 2.11). 34. Индикатор подключения блокнота к серверу marimo. Как оказалось, интерфейс блокнота marimo достаточно нагружен. Перейдём теперь непосредственно к работе с ним. П.2.2. Начинаем работать с блокнотом marimo Как уже говорилось ранее, при создании блокнота marimo в нем имеется единственная ячейка для кода Python (рис. П2.1) c подсказкой о необходимости импорта marimo, что мы и сделаем. На рисунке П2.8 представлена структура ячейки блокнота. Рис. П2.8 Структура ячейки блокнота marimo 275
В ячейке определена переменная a, её значения выведено с помощью функции print в область вывода под ячейкой (помечено цифрой 8 на рис. П2.8), куда выводятся текстовые сообщения. Значение последней строки ячейки, в нашем случае переменной a, выводится в область вывода над ячейкой (отмечено цифрой 1). Строки в ячейке по умолчанию нумеруются. Для выполнения ячейки необходимо нажать копку 2. Если ячейка выполнена, то кнопка серая, если в исходный код ячейки внесены изменения, то кнопка подсвечивается жёлтым. Нажатие кнопки 3 позволяет прервать выполнение ячейки, а кнопка 4 приводит к появлению меню с перечнем действий, которые можно произвести с ячейкой (рис. П2.9). Перечисляем эти действия сверху вниз: Рис. П2.9 Меню действий с ячейкой 276
Name — можно присвоить имя ячейке для ссылок на неё, например, в панели журнала. Run cell — запуск ячейки на выполнение. Split cell — расщепление ячейки на две. Расщепление проводится в месте нахождения курсора мыши. Export to png — сохранение области вывода в виде изображения в формате png. Format cell — форматирование исходного кода в ячейке. Hide code — скрытие исходного кода в ячейке, работает как переключатель, если код скрыт, то в меню появляется пункт Show code — отобразить код. Reactive execution — переключатель, позволяющий включить/выключить реактивное выполнение ячейки. Clear output — очистка областей вывода над и под ячейкой. Три следующих пункта меню позволяют изменить тип ячейки: Convert to Markdown — преобразовать ячейку в документную. Convert to SQL — изменить тип ячейки на SQL. Toggle to Python — преобразовать тип ячейки в Python. Можно создать новые ячейки и изменить их положение в блокноте: Create cell above — создать ячейку над данной. Create cell below– создать ячейку под данной. Move cell up — переместить ячейку вверх. Move cell down — переместить ячейку вверх. Send to top — переместить ячейку в начало блокнота. Send to bottom — переместить ячейку в конец блокнота. Выше говорилось о том, что в блокноте marimo могут быть ячейки различных типов. П.2.3. Типы ячеек блокнотов marimo Ячейки Python По умолчанию создаётся ячейка, в которой можно редактировать и выполнять исходный код Python. Создадим ещё один блокнот a02_02.py, нулевая ячейка которого выглядит следующим образом: # ячейка 0 import marimo as mo a = 42 mo.md(''' # Типы ячеек блокнота `marimo` Это ячейка `Python, но в ней мы можем в ней работать с языком разметки `Markdown` ''' ) В ячейке мы не только присвоили переменной a значение, но и вызвали функцию md, которой передаётся исходный код языка разметки Markdown. Так как вызов функции является последним в ячейке, то возвращаемый ею результат окажется в области вывода ячейки (рис. П2.10). 277
Рис. П2.10 Работаем с Markdown в ячейке Python Все возможности Markdown к нашим услугам, о них мы поговорим далее в отдельном разделе. Мы можем выводить в Markdown результаты вычислений. Рис. П2.11 Выводим значения переменной a в Markdown Ячейки Markdown Если нет необходимости выводить изменяющиеся значения переменных, то можно сделать это в ячейке Markdown. В этом случае не нужно кодировать вызов функции mo.md(). В Markdown можно погружать конструкции LaTeX83, тем самым решая вопрос с отображением математических формул. Такая же возможность имеется в JN и JL. В примере a02_03.py и на рисунке П2.12 показана ячейка типа Markdown. Рис. П2.12 Ячейка Markdown 83 Конструкции LaTeX окаймляются символами доллара или \[ и \], если формулу необходимо включить в строку, и $$ и $$ или \[\[…\]\] для вывода посередине новой строки. 278
Работаем со структурным языком запросов (SQL) непосредственно в ячейке Последним рассмотренным нами типом ячеек является SQL — структурированный язык запросов. Создание SQL-ячейки осуществляется нажатием кнопки SQL (кнопка 8 на рис. П2.1). Сразу отметим, что для работы с SQL необходима установка дополнительных модулей: pip install "marimo[sql]" или uv add "marimo[sql]" Заодно установится система управления реляционными базами данных DuckDB84. После установки можно делать запросы не только к реляционным базам данных, но и к фреймам данных. В примере a02_04.py загрузим набор данных об ирисах во фрейм данных: import marimo as mo from plotly.express.data import iris import pandas data = iris() С помощью атрибутов pandas columns или info можно посмотреть, что же находится во фрейме данных. Нажав кнопку SQL под текущей ячейкой, можем сделать запрос на выборку данных: SELECT sepal_length, petal_width FROM data Рис. П2.13 Результат выполнения SQL-запроса Результат, как обычно отобразится над ячейкой (рис. П2.13). 84 DuckDB is a fast in-process database system [Электронный ресурс]. — URL: https://duckdb.org/ (дата обращения 18.04.2026). 279
П.2.4. Расширения Markdown В marimo язык разметки легко встраивается в ячейки Python c помощью функции mo.md. Это почти универсальный контейнер, позволяющий работать с форматированным текстом, формулами, интегрировать в него результаты вычислений, исходный текст программ, диаграммы и даже элементы пользовательского интерфейса. Перечисленные в разделе возможности отсутствуют в JN, JL. Данный раздел носит справочный характер и его можно пропустить при первом чтении. Заметки в Markdown Начнём с выделений текста (пример a02_05.py). Выделение начинаются и заканчиваются тройной косой чертой на отдельной строке и могут быть нескольких типов, первым из которых являются развёртываемые заметки: /// details | Развертываемая заметка Дополнительное описание заметки, отображаемое при развертывании. /// /// details | Развертываемая заметка типа info type: info Дополнительное описание заметки типа info, отображаемое при развертывании. /// /// details | Развертываемая заметка типа warn type: warn Дополнительное описание заметки типа warn, отображаемое при развертывании. /// /// details | Развертываемая заметка типа danger type: danger Дополнительное описание заметки типа danger, отображаемое при развертывании. /// Как видно из исходного текста, развёртываемые заметки могут быть без типа, информационные (info), предупреждения (warn), сообщения об опасности (danger). Типы замечаний отличаются друг от друга цветом. Развёртывание/свертывание заметки осуществляется щелчком мышью по шеврону (рис. П2.14). Следующим типом являются заметка для привлечения внимания (рис. П2.15): /// attention | Внимание! Это замечание является важным, уделите ему внимание! /// 280
Рис. П2.14 Разворачиваемые замечания в Markdown Рис. П2.15 Заметка для привлечения внимания Наконец, рассмотрим заметку-совет или напоминание (рис. П2.16). /// admonition | Совет Эта заметка может быть советом, напоминанием или предупреждением. /// Рис. П2.16 Заметка-совет 281
Исходный текст программ в Markdown В Markdown можно погружать исходный текст на языках программирования (пример a02_06.py, рис. П2.17). при этом конструкции языка будут подсвечиваться. Для этого исходный текст заключается в тройные обратные апострофы, после которых следует указать язык, например, sql: ```sql select column1, column2 from df ``` Аналогично кодируется исходный текст JavaScript и Python: ```python import plotly.express as px vv = np.linspace(-np.pi, 3 * np.pi, 300) xtr, ytr, ztr = trefoil(vv, vv, r=5) figtr = px.line_3d(x=xtr, y=ytr, z=ztr) figtr.update_traces(line={"width": 15}) plotly_fig = mo.ui.plotly(figtr) ``` Рис. П2.17 Исходный текст SQL и Python в Markdown Эта возможность нам настолько понравилась, что мы написали простенький скрипт source_code.py, который позволяет отображать исходные тексты на перечисленных выше языках. Запускается он как скрипт marimo: marimo run source_code.py -- --p путь_к_файлу_с_исходным_текстом В этом примере показано, как передавать данные функциям marimo через командную строку. Ниже на рисунке П2.18 выводится фрагмент исходного текста скрипта a02_source_code.py 282
Рис. П2.18 Скрипт, выводящий собственный исходный текст Мультимедийные объекты в Markdown Изображения вставляются в статический Mardown с помощью конструкции: ![Текст, выводимый, если изображение отсутствует](путь_к_изображению) Важно! Мультимедийные объекты, отображаемые в Markdown необходимо размещать в папке public, расположенной рядом с файлом блокнота, или ссылаться на ресурс, расположенный в веб. В примере a02_07.py приведён пример вставки такого изображения в ячейку Markdown: ## Выводим изображение средствами Markdown ![Логотип marimo](public/marimo_logo.png) 283
Для вставки аудио и видео приходится использовать возможности HTML: ## Выводим аудио средствами HTML <audio controls autoplay muted> <source src="public/audio_700KB.mp3" type="audio/mp3"> Ваш браузер не поддерживает вывод аудио. </audio> Вставка видео осуществляется так: ## Выводим видео средствами HTML <video width="320" height="240" controls> <source src="public/attr.mp4" autoplay type="video/mp4"> Ваш браузер не поддерживает проигрывание видео. </video> Видео странного аттрактора, вставленного с помощью HTML, приводится на рисунке П2.19. Рис. П2.19 Видео, вставленное с помощью HTML Для вставки объектов мультимедиа в ячейках Python можно воспользоваться функциями mo.image и mo.video. Первым параметром передаётся ссылка на объект в файловой системе или Интернете, кроме того, для изображения и видео можно указать ширину (width) и высоту (height). Для видео можно дополнительно задать запуск после загрузки (autoplay=True) и зацикливание произведения (loop=True). На заметку Следует иметь в виду, что видео полностью загружается в оперативную память, и возможно, придётся перед запуском блокнота изменить объем отводимой для этого памяти. Так, для Linux необходимо ввести в терминале: export MARIMO_OUTPUT_MAX_BYTES=30_000_000 В Windows это делается так: set MARIMO_OUTPUT_MAX_BYTES=30_000_000 284
Звуковой файл встраивается в блокнот с помощью функции mo.audio(), которой передаётся единственный параметр — ссылка для файла аудио. В блокноте a02_07.py мы создали три объекта мультимедиа и отобразили их, перечислив в последней строке ячейки (рис. П2.20). logo = mo.image("public/marimo_logo.png", width="50%") audio = mo.audio("public/audio_700KB.mp3") video = mo.video('public/hd.mp4', width="50%", autoplay=True, loop=True) logo, audio, video Рис. П2.20 Объекты мультимедиа в ячейке marimo Эти же объекты могут быть погружены в html по отдельности или вместе и выведены с помощью функции mo.as_html: mo.as_html((logo, audio, video)) Диаграммы в Markdown В блокнотах marimo можно использовать библиотеку Mermaid85 для рисования диаграмм и блок-схем (см. блокнот a02_08.py). На рисунках П2.21–П2.23 приводятся различные типы диаграмм, нарисованные с помощью Русалочки86. 85 Mermaid. Diagramming and charting tool [Электронный ресурс]. — URL: https://mermaid.js.org/ (дата обращения 18.04.2026). 86 Mermaid – это Русалочка по-русски. 285
Рис. П2.21 Пример блок-схемы Рис. П2.22 Диаграмма Ганта С помощью Mermaid можно рисовать даже интеллект-карты, которые удобно использовать для наглядного отображения, например, планов занятий (рис. П2.23). 286
Рис. П2.23 Фрагмент интеллект-карты, использованной при написании приложения Этим не ограничиваются возможности Mermaid, с её помощью можно рисовать диаграммы UML87. Естественно, диаграммы Русалочки легко встраиваются в Markdown marimo. П.2.5. Визуализация в marimo Работаем с matplotlib «Из коробки» в блокнотах marimo поддерживаются следующие библиотеки научной визуализации: altair, matplotlib и plotly. В книге мы будем работать только с двумя последними. Изображение, сформированное matplotlib, может быть непосредственно вставлено в блокнот, как это показано в примере a02_09.py и на рисунке П2.24. Обратите внимание, что для вывода используется объект изображения. Это удобно, когда требуется задать размеры изображения и пространственное разрешение. 87 Что находится между идеей и кодом? Обзор 14 диаграмм UML [Электронный ресурс]. — URL: https://habr. https://habr.com/ru/articles/508710/com/ru/articles/508710/ (дата обращения 18.04.2026). 287
Рис. П2.24 Выводим график с помощью matplotlib Вставку рисунков matplotlib в Markdown необходимо осуществлять с помощью функции as_html: mo.md(f''' ## Интегрируем рисунок matplotlib в Markdown {mo.as_html(sinx_x(a=5))} ''') Использование библиотеки plotly в marimo Работа с рисунками plotly осуществляется столь же просто (пример a02_10.py). На рисунке П2.25 показано, как выводится график с помощью маркеров. Интеграция графиков plotly в Markdown осуществляется, как и в случае matplotlib: mo.md(f"{mo.as_html(fig)}") В примере a02_10.py определена функция trefoil_curve_surface() для построения трёхмерных кривой и поверхности трилистника. Их отображение представлено на рисунках П2.26 и П2.27 соответственно. 288
Рис. П2.25 Выводим график plotly с помощью маркеров Рис. П2.26 Трёхмерная кривая трилистника 289
Рис. П2.27 Трёхмерная поверхность трилистника Рассмотрев виды ячеек блокнотов marimo и работу с научной визуализацией, перейдём к наиболее важному свойству marimo — к реактивности переменных. П.2.6. Реактивность переменных marimo Под реактивностью в marimo понимается возможность определения в одной ячейке блокнота переменной, изменение её значения приведёт к перевычислению всех выражений и/или вызову функций, в которых есть ссылки на переменную. Вычисления в блокноте marimo организованы в виде ациклического направленного графа и осуществляются автоматически. Использование ссылок 290
на реактивные переменные может быть в ячейках, расположенных как выше, так и ниже ячейки с её определением. На рисунке П2.28 представлен блокнот (a02_11.py) с реактивной переменной a. Рис. П2.28 Реактивная переменная a в блокноте marimo Если изменить значение переменной a в ячейке 2, то автоматически изменится и значение, выводимое в ячейке 1. Переопределять реактивные переменные в других ячейках, кроме той, в которой она была определена, нельзя. Например, если в ячейке 3 попытаться изменить значение a, то получим следующую ошибку (рис. П2.29): Рис. П2.29 Ошибка при переопределении (изменении значения) реактивной переменной Таким образом, переопределять значения реактивных переменных можно только в тех ячейках, где они определены. 291
На заметку Все переменные с областью видимости блокнота marimo являются реактивными. Сделать их обычными переменными Python можно либо начиная имя переменной с подчёркивания, либо изменяя область видимости переменной, оборачивая её в функцию, как это рекомендуется в сообщении об ошибке на рисунке П2.29. Основным назначением реактивности, кроме автоматизации вычислений, является упрощение построения пользовательских интерфейсов приложений, избавляя во многих случаях от необходимости писать функции обратного вызова для обработки реакций пользователя. В marimo компоненты пользовательского интерфейса могут быть реактивными переменными. Покажем это на примере, несколько забежав вперёд и определив в ячейке 4 функцию для рисования и реактивную переменную, которой присваивается виджет слайдер: # ячейка 4 import numpy as np import matplotlib.pyplot as plt def sinx_x(b, n=300, ab=(0, 3), figsize=(3,3)): x = np.linspace(ab[0], ab[1], n) y = np.sinc(b*x) fig = plt.figure(figsize=figsize) plt.plot(x, y, lw=3) plt.xlabel('x') plt.ylabel('y') plt.grid() return fig b_slider bounce=False) = mo.ui.slider(start=1., stop=5., step=0.1, de- Функция sinx_x рисует с помощью matplotlib график функции sin(𝑎𝑥) /𝑎𝑥. Слайдером можно задавать значения от 1 (start) до 5 (stop) c шагом 0,1 (step). По умолчанию слайдер возвращает задаваемое пользователем значение при любых перемещениях ползунка. Указание debounce=True приводит к тому, что значение возвращается только тогда, когда пользователь отпустит кнопку мыши и виджет потеряет фокус. Это позволяет не перерисовывать график при перемещении ползунка слайдера, но здесь мы зададим debounce=False. Следует помнить, что приложения marimo выполняются на сервере, взаимодействие осуществляется через сеть, это может привести к задержкам при обработке взаимодействий между пользователем и приложением, особенно когда сервер marimo расположен не на локальном компьютере. Кроме того, вычисления, запускаемые пользователем, также требуют ресурсов сервера. Большинство виджетов marimo имеют атрибут value, который можно передать функции sinx_x, и сделаем это, погрузив пользовательский интерфейс в Markdown: # ячейка 5 mo.md(f''' ## Связываем график с реактивной переменной b 292
{b_slider } b={a_slider.value:4.1f} {mo.as_html(sinx_x(b_slider.value))} Фактически мы сконструировали интерактивное приложение, выведя виджет и вызвав функцию, передав ей значение, возвращаемое слайдером. При этом функция обратного вызова и явная обработка событий не нужны. Результат приведён на рисунке П2.30. Рис. П2.30 Простейшее интерактивное веб-приложение В качестве эксперимента задайте debounce=True и посмотрите, что получится. Всё достаточно просто, и разработчики рекомендуют действовать именно так. К сожалению, не всегда можно обойтись без функций обратного вызова. Состояние реактивной переменной можно задать и получить с помощью двух функций. Покажем это на примере синхронизации двух слайдеров. Внимание! Все действия данного примера должны быть разнесены по различным ячейкам, как показано ниже. # ячейка 6 # функции для получения и задания состояния get_state, set_state = mo.state(1.) # ячейка 7 # первый слайдер 293
s1 = mo.ui.slider(1., on_change=set_state) # ячейка 8 # второй слайдер s2 = mo.ui.slider(1., on_change=set_state) 10., 0.1, value=get_state(), 10., 0.1, value=get_state(), # ячейка 9 # пользовательский интерфейс mo.md(f''' ## Синхронизация состояния двух слайдеров s1 {s1} {s1.value:5.1f} s2 {s2} {s2.value:5.1f} ''') Результат приведён на рисунке П2.31. Рис. П2.31 Синхронизация состояния двух слайдеров При изменении состояния одного из слайдеров состояние другого синхронизуется, но нельзя сказать, что делается это мгновенно, т. к. взаимодействие осуществляется по сети через сервер marimo. В качестве упражнения добавьте в слайдеры debounce=True и посмотрите, что получится. В данном примере нам пришлось задействовать функции обратного вызова для изменения состояния слайдеров. П.2.7. Элементы пользовательского интерфейса marimo В marimo встроен джентельменский набор элементов пользовательского интерфейса (виджетов), созданный, как и в проекте Jupyter с помощью технологии anywidgets88. Набор виджетов практически совпадает с JN, JL, с тем отличием, что названия параметров виджетов несколько отличаются. Большинство виджетов находятся в пространстве имён mo.ui. В начале блокнота a02_12.py приводятся аналоги виджетов JN, JL: btn = mo.ui.button(label="Нажми меня", kind='success', tooltip="Кнопка с надписью 'Нажми меня'") chk = mo.ui.checkbox(label="Флажок", value=True) data = mo.ui.date(label="Задаем дату: ", start="2025-03-09", stop="2025-03-18", value="2025-03-14") data_range =mo.ui.date_range(label="Задаем диапазон дат: ", start="2025-03-09", stop="2025-03-18") 88 anywidget. Reusable interactive widgets made easy [Электронный ресурс]. — URL: https://anywidget.dev/ (дата обращения 18.04.2026). 294
date_time = mo.ui.datetime(label="Задание даты/времени: ") dropdown = mo.ui.dropdown(label="Выпадающее меню: ", options=("раз", "два", "три", "четыре"), value="три") multi_select = mo.ui.multiselect(label="Множественный выбор: ", options=("раз", "два", "три", "четыре"), value=["три", "раз"]) number = mo.ui.number(label="Ввод числа: ", start=1, stop=15, step=2, value=5 ) radios = mo.ui.radio(label="Набор переключателей:", options=("раз", "два", "три", "четыре"), value="три") range_slider = mo.ui.range_slider(label="Выбор диапазона: ", start=1, stop=20, step=1, value=[5, 15]) slider = mo.ui.slider(label="Слайдер: ", start=0., stop=1., step=0.05, value=0.8) switch = mo.ui.switch(label="Переключатель: ", value=True) text = mo.ui.text(label="Поле ввода: ", placeholder="Введите текст") text_area = mo.ui.text_area(label="Текствая область: ", rows=6, value="Текст в текстовой области...") (mo.md("## Аналоги элементов управления JN, JL"), mo.hstack([btn, chk, data]), mo.hstack([data_range, date_time]), mo.hstack([dropdown, multi_select, number]), mo.hstack([radios, range_slider, slider]), mo.hstack([switch, text, text_area]), ) Рис. П2.32 Элементы пользовательского интерфейса marimo По умолчанию вывод в marimo осуществляется по вертикали (в столбик), поэтому для размещения виджетов по горизонтали мы воспользовались функ295
цией mo.hstack, которой передаётся список элементов управления. На рисунке П2.32 мы вывели алфавитный список виджетов. Большинству из перечисленных выше элементов управления можно передать следующие параметры: label — надпись, value — начальное значения, placeholder — приглашение для виджетов, предназначенных для ввода текста, start — минимальное значение, stop — максимальное значение, step — шаг. Они задаются в виджетах для ввода числовых значений. Большинство виджетов имеют фиксированную ширину, но их можно растянуть на весь контейнер, задав full_width=True. Функция обратного вызова для взаимодействия с пользователями и другими виджетами придаётся в параметре on_change. Marimo ориентирован на работу с данными, что обусловило включение в состав элементов управления специальных виджетов для работы с данными, для примеров мы используем набор данных iris. from plotly.express.data import iris df = iris() Отобразить набор данных можно с помощью виджета mo.ui.dataframe(), которому в качестве параметра передаётся набор данных. Настраиваемое наглядное представление данных даёт элемент управления data_explorer (рис. П2.33) с помощью всего единственной строки кода. Рис. П2.33 Наглядное представления данных в marimo Тестовые и числовые данные можно аккуратно вывести в таблицу с возможностью постраничного разбиения (рис. П2.34). В составе виджетов входят средства для работы с файловой системой. Так, с помощью виджета filе можно загрузить файлы из локальной файловой системы на сервер marimo, просто перетащив файл на кнопку загрузки или нажав эту кнопку для выбора файла (рис. П2.35). 296
Рис. П2.34 Табличное представление данных Рис. П2.35 Элемент управления file Для просмотра папок в файловой системе локального компьютера можно использовать виджет file_browser (рис. П2.36). Рис. П2.36 Просмотр содержимого папок в файловой системе локального компьютера 297
Виджет microphonе позволяет записывать звук, а refresh производить опрос сервера, например, в ожидании завершения длительных вычислений. В пространстве имён mo.ui, кроме перечисленных виджетов, определены: chat — компонент для поддержки чатов, редактор исходных текстов code_editor, контейнеры для графиков plotly и panel89. Кроме того, имеется несколько наборов виджетов, созданных пользователями, познакомиться с ними можно, пройдясь по ссылкам электронного ресурса Удивительный marimo90. Не все виджеты находятся в пространстве mo.ui. В частности, средства для отображения изображений и видео (image, video) находятся в пространстве имён mo. Переходя к компоновке виджетов при создании пользовательских интерфейсов, можно отметить, что контейнер «гармошка» (accordion) находится в пространстве имён mo, а средства для создания вкладок (tabs) в mo.ui. Раскладки — компоновка пользовательских интерфейсов С одним из способов компоновки пользовательских интерфейсов мы уже познакомились — это Markdown. На наш взгляд, этот подход является хорошим средством для создания интерактивных электронных учебников. Сразу отметим, что точного взаимного расположение виджетов с помощью Markdown достигнуть практически невозможно, для этого есть другие средства, о которых будет сказано далее. В marimo имеется метод batch, позволяющий вставлять виджеты и элементы мультимедиа с помощью именованных параметров. Использование обоих способов представлено в примере a02_13.py на рисунке П2.37. Вёрстка пользовательского интерфейса осуществляется по вертикали с помощью mo.vstack, а по горизонтали посредством mo.hstack. Начнём с вёрстки по вертикали. Виджеты придаются в mo.vstack либо первым позиционным параметром, либо именованным параметром items (рис. П2.38). Обратите внимание на две особенности. Во-первых, ранее мы просто указывали выводимый объект в последней строке ячейки, здесь же мы постепенно добавляем выводимые объекты в область вывода с помощью mo.output.append. На заметку Можно очистить область вывода с помощью mo.output.clear либо подготовить новое содержимое области и заменить её, вызвав mo.output.replace. Во-вторых, здесь мы «растянули» виджеты на ширину контейнера, использовав именованный параметр full_width=True. При использовании mo.vstack можно дополнительно задать равную высоту выводимых объектов (heights="equal"), вертикальный зазор между объектами в rem, горизонтальное выравнивание в параметре align (допустимые значения: 'start', 'end', 'center', 'stretch'), вертикальное выравнивание в параметре justify (допустимые значения: 'start', 'center', 'end', 'space-between', 'space-around'). 89 Interactive elements [Электронный ресурс]. – URL: https://docs.marimo.io/guides/interactivity/ (дата обращения 18.04.2026). 90 A curated list of awesome things related to marimo [Электронный ресурс]. — URL: https://github.com/ marimo-team/awesome-marimo (дата обращения 18.04.2026). 298
Рис. П2.37 Два способа вставки элементов пользовательского интерфейса в Markdown Рис. П2.38 Размещаем виджеты по вертикали с помощью mo.vstack 299
Аналогично виджет mo.hstack осуществляет горизонтальное расположение объектов (рис. П2.39). Здесь align осуществляет вертикальное выравнивание (допустимые значения: 'start', 'end', 'center', 'stretch'), а justify — горизонтальное (допустимые значения: 'start', 'center', 'end', 'space-between', 'space-around'). Задание wrap=True осуществляет перенос не уместившихся объектов по вертикали. Рис. П2.39 Размещаем виджеты по горизонтали с помощью mo.hstack Собственно размещение элементов пользовательского интерфейса осуществляется совместными применением горизонтальной и вертикальной вёрстки. В качестве примера рассмотрим приложение для вращения трёхмерных кривых и поверхностей вокруг осей координат. Мы не будем останавливаться на вычислении координат кривой трилистника и её вывода с помощью matplotlib, все исходные тексты приведены в a02_13.py. Здесь мы рассмотрим только вёрстку пользовательского приложения, которую выполним в двух ячейках блокнота. В первой ячейке создаются элементы пользовательского интерфейса. # Элементы пользовательского интерфейса title = mo.md("# Вращаем трехмерную кривую вокруг осей").center() azim = mo.ui.slider(label="Ось Z:", start=-180, stop=180, debounce=True, step=5, value=-60, full_width=False) 300
elev = mo.ui.slider(label="Ось X:", start=-180, stop=180, debounce=True, step=5, value=30, full_width=False) roll = mo.ui.slider(label="Ось Y:", start=-180, stop=180, debounce=True, step=5, value=0, full_width=False) axis = mo.ui.switch(label="Оси координат", value=True) Здесь создаются заголовок приложения, три слайдера, задающие углы поворота вокруг координатных осей, и переключатель для включения/отключения отображения координатных плоскостей и оцифровки. Отметим, что для всех слайдеров задан параметр debounce=True, с тем чтобы перерисовка изображения осуществлялась только тогда, когда пользователь отпускает ползунок слайдера. Во второй ячейке осуществляется собственно вёрстка приложения. mo.vstack([ title, mo.hstack([ mo.vstack([mo.hstack([azim, mo.md(f'${azim.value}^o$')]), mo.hstack([roll, mo.md(f'${roll.value}^o$')]), mo.hstack([elev, mo.md(f'${elev.value}^o$')]), axis ]), plot(azim=azim.value, elev=elev.value, roll=roll.value, axis=axis.value, figsize=(4,3) ) ]) ]) С помощью внешнего mo.vstack по вертикали располагаются заголовок приложения (title) и mo.hstack, в котором слева по вертикали располагаются три слайдера и переключатель. В свою очередь, вместе со слайдером выводится устанавливаемое им значение в градусах. Справа располагается изображение, выведенное с помощью функции matplotlib plot, которой передаются значения углов поворота вокруг осей координат и признак необходимости отображения координатных плоскостей. Благодаря реактивности нам не нужно писать код для функционирования приложения. Перемещение движков или манипуляции с переключателем приводят к вызову функции plot и перерисовке изображения. В качестве упражнения попробуйте изменить значение параметра debounce. Будет ли на вашем компьютере отрисовка изображения следовать за перемещениями слайдера? Рассмотренный выше подход прекрасно работает, когда проводимые вычисления и визуализация не слишком ресурсоёмкие. В ряде случаев желательно сначала задать все значения параметров приложения и только потом выполнить вычисления, нажав на кнопку. Именно для этой цели в marimo имеется кнопка mo.ui.run_button. Значение атрибута value этой кнопки равно False, при нажатии кнопки оно устанавливается в True. В отличие от других фреймворков, сброс значения кнопки осуществляется вызовом функции mo.stop. 301
Рис. П2.40 Пользовательский интерфейс приложения для вращения трёхмерной кривой трилистника относительно координатных осей Давайте повторим предыдущий пример, добавив к нему кнопку запуска (блокнот a02_14.py). Как и ранее, создадим элементы пользовательского интерфейса, кроме кнопки запуска, которая обязательно должна находиться в отдельной ячейке. # Создаем элементы пользовательского интерфейса azim0, elev0, roll0 = -60, 30, 0 title = mo.md("# Вращаем трилистник по нажатию кнопки").center() azim = mo.ui.slider(label="Ось Z:", start=-180, stop=180, debounce=True, step=5, value=azim0, full_width=False) elev = mo.ui.slider(label="Ось X:", start=-180, stop=180, debounce=True, step=5, value=elev0, full_width=False) roll = mo.ui.slider(label="Ось Y:", start=-180, stop=180, debounce=True, step=5, value=roll0, full_width=False) axis = mo.ui.switch(label="Оси координат", value=True) title # вывод заголовка Сверстаем вместе элементы пользовательского интерфейса: # верстка слайдеров и переключателя sliders_switch = mo.vstack([mo.hstack([azim, mo.md(f'${azim.value}^o$')], 302
justify="start"), mo.hstack([roll, mo.md(f'${roll.value}^o$')], justify="start"), mo.hstack([elev, mo.md(f'${elev.value}^o$')], justify="start"), axis ]) sliders_switch # отображение виджетов В отличие от предыдущего приложения, нам придётся подготовить и сохранять состояние изображения между нажатиями на кнопку. # состояние для рисунка fig_get, fig_set = mo.state(plot(azim=azim0,elev=elev0, roll=roll0)) Теперь можно создать кнопку запуска. # кнопка запуска run_button = mo.ui.run_button(label="Запустить") run_button.center() Перейдём к основной части приложения, которая обеспечивает его функциональность. # реагируем на нажатие кнопки fig = mo.stop(not run_button.value, fig_get()) # кнопка не нажата # кнопка нажата, обновляем и отображаем картинку fig = plot(axis=axis.value, azim=azim.value, elev=elev.value, roll=roll.value) fig_set(fig) fig В первой строке данного фрагмента вызывается функция mo.stop. При не нажатой кнопке запуска она возвращает сохраненное состояние картинки, сбрасывает значение атрибута value кнопки и прекращает выполнение ячейки. Если кнопка нажата, то выполнение ячейки продолжается, производится перерисовка картинки в соответствии с установленными пользователем значениями слайдеров и переключателей. Мы получили работающее приложение, взаимодействие между ячейками обеспечивает реактивность. Другое дело, что работать с таким приложением не очень удобно, нужно постоянно перемещаться по блокноту вверх и вниз, чтобы задавать значения параметров, нажимать кнопку, смотреть, что получилось с рисунком. Воспользуемся возможностями marimo для наглядной вёрстки ячеек блокнота. Для этого изменим представление блокнота на табличное, нажав на кнопку для отображения основных функций блокнота, выберем в меню Present as (Представить как) и Сетка (Grid). Визуальное представление ячеек на рабочем поле можно перетаскивать мышью из правой части блокнота, перемещать по рабочему полю, менять размеры (рис. П2.41). Вернуться к вертикальной вёрстке можно, выбрав в том же меню изменения вёрстки Toggle app view (переключить представление приложения). Запустим свёрстанное таким образом приложение с помощью команды: marimo run a02_14.py 303
Результат представлен на рисунке П2.42. Рис. П2.41 Вёрстка ячеек блокнота на сетке Рис. П2.42 Результат интерактивной вёрстки, запущенный с помощью команды run marimo Контейнеры «гармошка» и вкладки Целям организации пользовательских интерфейсов служат виджеты-контейнеры (пример a02.15) «гармошка» (mo.accordion), осуществляющие вёрстку по вертикали, и вкладки (mo.ui.tabs) для горизонтальной вёрстки. Оба виджета предназначены для работы с пользовательскими интерфейсами, содержащими большое число элементов пользовательского интерфейса, которые отображается только после того, как они все будут инициализированы, 304
несмотря на то, что видимыми являются только некоторые из них. Данная проблема решается использование функции mo.lazy. С её помощью инициализация осуществляется только для тех элементов пользовательского интерфейса, которые находятся в области видимости в настоящий момент. Для примера мы имитируем телеигру Монти Холла91, когда ведущий предлагает игроку указать на одну из трёх дверей, за одной из которых расположен приз, после чего открывает дверь, за которой ничего нет, но не загаданную игроком. После этого игрок может изменить или не изменить свой выбор. Оказывается, что изменение выбора существенно повышает вероятность получения приза. from time import sleep def item(text, dt): sleep(dt) return mo.md(f"**{text}**") mo.accordion( items ={ "Дверь 1": item("Ничегошеньки!", dt=1), "Дверь 2": item("Ура! Приз!", dt=3), "Дверь 3": item("Опять ничего!", dt=1) }, lazy=True, ) Здесь функция item имитирует раздумья игрока в течение dt секунд. «Гармошке» передаётся словарь items, его ключи выводятся в качестве надписей на «гармошке», а значения отображаются, когда пользователь щелкает на соответствующей надписи. В примере с «гармошкой» мы задали значение параметра lazy для всех вкладок «гармошки», для которой по умолчанию все вкладки закрыты (рис. П2.43). Рис. П2.43 Контейнер «гармошка» Вкладки устроены аналогичным образом, но для них c помощью параметра value можно задать открытую в начальный момент вкладку. mo.ui.tabs( tabs = { "Дверь 1": mo.lazy(item("Ничегошеньки!", dt=1), 91 Парадокс Монти Холла [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Парадокс_ Монти_Холла (дата обращения 18.04.2026). 305
show_loading_indicator=True), "Дверь 2": mo.lazy(item("Ура! Приз!", dt=5), show_loading_indicator=True), "Дверь 3": mo.lazy(item("Опять ничего!", dt=1), show_loading_indicator=True), }, value="Дверь 3", ) Рис. П2.44 Контейнер вкладки В примере для вкладок (рис. П2.44) мы воспользовались функцией mo.lazy, в ней задание значение параметра show_loading_indicator=True приводит к отображению индикатора выполнения, если отображение вкладки занимает много времени. Навигация. Боковая панель До сих пор мы рассматривали одностраничные приложения marimo, но иногда бывает необходимо осуществить переход к другим веб-страницам. Сделать это достаточно просто можно с помощью виджета mo.nav_menu (блокнот a02_16.py). Меню навигации позволяет легко строить двухуровневые меню, ориентированные по умолчанию сверху вниз (рис. П2.45). menu = { f" {mo.icon('lucide:home')} **marimo**": { "/marimo/notebook": { "description": "Пользовательский интерфейс блокнота marimo", "label": f" {mo.icon('lucide:notebook-text')} Блокнот marimo", }, "/marimo/reactive": { "description": "Реактивность переменных блокнотов marimo", "label": f" {mo.icon('lucide:rocket')} Реактивность", }, "/marimo/widgets": { "description": "Элементы пользовательского интерфейса marimo", "label": f" {mo.icon('lucide:monitor')} Виджеты", }, "/marimo/deploy": { "description": "Встраивание приложений marimo в веб-приложения", "label": f" {mo.icon('lucide:goal')} Встраивание", }, 306
} } nav_menu = mo.nav_menu( menu, orientation="vertical", ) nav_menu Меню представляет собой словарь. Ключом меню первого уровня является надпись, формируемая с помощью Markdown и включающая в себя иконку с изображением дома. На втором уровне ключом является указатель ресурса, визуальное представление передаётся в виде словаря с двумя элементами: label — надпись, отображаемая в меню, description — описание элемента меню, показываемое при наведении на него курсора мыши. Можно расположить меню горизонтально, но при этом необходимо предусмотреть место в ячейке для открытия меню, в данном случае мы это сделали, добавив текстовую область (рис. П2.46): mo.vstack([ mo.nav_menu(menu, orientation="horizontal"), mo.ui.text_area(placeholder="Введите подсказку...", rows=6) ]) Рис. П2.45 Вертикальное навигационное меню Рис. П2.46 Горизонтальное навигационное меню Средства навигации удобно использовать, когда несколько блокнотов marimo встраиваются одно веб-приложение, о чём мы поговорим в конце данного приложения. 307
Достаточно часто места на экране для размещения элементов пользовательского интерфейса не хватает. В этом случае можно использовать боковую панель, которую можно открывать только тогда, когда это необходимо (рис. П2.47). mo.sidebar(item=mo.nav_menu(menu=menu, cal"), width="18rem") orientation="verti- Содержимое боковой панели задаётся передачей его именованному параметру item, при необходимости можно увеличить ширину боковой панели, как это сделано выше. Открытие/закрытие осуществляется щелчком мыши на значке в вверху бокового меню. Рис. П2.47 Боковая панель: а — закрытое состояние; б — открытое состояние. Переключение состояния боковой панели осуществляется щелчком мышью на шевроне. П.2.8. Разные разности Квазианимация К сожалению, полноценная анимация в реальном времени в marimo отсутствует. Её приходится подготавливать отдельно в фоновом режиме, а затем вставлять в блокноты. Созданию анимированных изображений gif посвящено Приложение 6. Анимация. В данном разделе мы рассмотрим реализацию периодических действий, которые назовём квазианимацией (блокнот a02_17). В качестве первого упражнения сделаем цифровые часы, обновляющие свои показания каждую секунду, для чего создадим и отобразим виджет mo.ui.refresh с периодом обновления одна секунда. Сошлёмся на него в следующей ячейке для запуска её каждую секунду. Результат приводится на рисунке П2.48. 308
Рис. П2.48 Цифровые часы, обновляющие свои показания каждую секунду На заметку Для обновления ячеек обязательно должно быть установлено автоматическое, а не «ленивое» выполнение ячеек. Для этого в правом верхнем углу блокнота необходимо нажать кнопку Settings, затем ссылку User settings внизу появившегося диалога, выбрать Runtime из меню, далее установить on cell change autorun. Альтернативой autorun является lazy. Такое обновление выглядит вполне естественно. Виджет срабатывает не чаще чем через одну десятую секунды, и его можно настраивать во время выполнения, именно поэтому качественной анимации с его помощью получить не удастся. Ниже приводится попытка квазианимации. import numpy as np import matplotlib.pyplot as plt amin, amax, astep = 0.2, 3.0, 0.2 xmin, xmax = 0., 3. n = 300 x = np.linspace(xmin, xmax, n) 309
a_get, a_set = mo.state(amin) # состояние a def plot_sinc(figsize=(3,3)): a = a_get() y = np.sinc(a*x) # рисуем fig = plt.figure(figsize=figsize) plt.plot(x, y, 'k-', lw=3) plt.grid() plt.title(f'y = sinc({a:4.1f}*x)') plt.xlabel('x') plt.ylabel('y') plt.ylim(-.3, 1.1) # рассчитываем и сохраняем новое значение a a += astep a = amin if a>amax+astep else a a_set(a) return fig refresh2 = mo.ui.refresh(default_interval=.1, options=[0.1, 0.2, 1, 2, 5],) refresh2 Нам пришлось создать состояние для значений коэффициента a. Функция plot_sinc() рассчитывает значения 𝑦 = 𝑠𝑖𝑛(𝑎𝑥)/𝑎𝑥 для последовательно изменяющихся значений a и выводит её график. Обратите внимание, что мы предотвращаем изменение масштаба в процессе квазианимации, принудительно задавая граничные точки графика по вертикальной оси. В следующей ячейке, как и ранее, ссылаемся на виджет refresh2. При запуске ячейки квазианимация начинается. Остановить её можно, выбрав off в меню виджета. Различные способы запуска на выполнение Мы продолжим рассматривать различные способы запуска приложений на выполнение при нажатии кнопки, воспользовавшись тем, что значением не нажатой кнопки является None, для чего создадим и отобразим в отдельной ячейке кнопку с надписью «Запустить» (блокнот a02_18.py). btn1 = mo.ui.run_button(label="Запустить") btn1 В следующей ячейке по нажатию кнопки генерируется случайное число. В начальный момент времени при запуске блокнота, когда кнопка не нажата, выдаётся текстовое приглашение. import random mo.stop( not btn1.value, mo.md("Нажмите кнопку **Запустить**, чтобы сгенерировать случайное число"), ) 310
mo.md(f"**{random.randint(0, 100)}**") То же самое можно сделать с помощью виджета mo.ui.form. В примере форме передаются два других виджета с помощью метода batch. form1 = mo.md( r""" Выберите параметры алгоритма: - $\varepsilon$: {epsilon} - $\delta$: {delta} """ ).batch(epsilon=mo.ui.slider(0.1, 1, step=0.1), delta=mo.ui.number(1, 10) ).form(submit_button_label="Передать") form1 Обратите внимание на то, что форма создаётся на основе Markdown, внутри неё используется LaTeX для отображения греческих символов, а сама форма создаётся методом form; виджеты, отображаемые в форме, передаются методом batch (рис. П2.49). Рис. П2.49 Отображаем форму В следующей ячейке выводится переданное содержимое формы: mo.hstack([ mo.md(r'$\varepsilon$:'), form1.value['epsilon'], mo.md(r'$\delta$:'), form1.value['delta'], ], justify="start") if form1.value else \ mo.md("Нажмите кнопку **Передать**") Динамическое создание виджетов Иногда требуется создавать виджеты пользовательского интерфейса динамически во время исполнения. Это можно сделать, организовав виджеты с мощью массивов mo.array или словарей mo.dictionary. Ниже приводится пример формирования случайного числа полей ввода (пример a02_19.py): array = mo.ui.array([mo.ui.text(placeholder="Введите данные") \ for i in range(random.randint(2, 5))]) 311
dictionary = mo.ui.dictionary({str(i): mo.ui.text(placeholder='Введите данные') \ for i in range(random.randint(1, 5))}) mo.hstack([array, dictionary], justify="start") Индексируются массивы и словари marimo точно так же, как и в Python. На рисунке П2.50а приводится представление массива и словаря виджетов, а на П2.50б их значения, которые выводятся в следующей ячейке: mo.hstack([array.value[:], dictionary.value], justify="start") Рис. П2.50 Массив и словарь виджетов: а — отображение массива и словаря; б — значения массива и словаря. На этом мы заканчиваем обсуждения построения пользовательских интерфейсов marimo, нам осталось посмотреть, как блокноты marimo встроить в традиционное веб-приложение. П.2.9. Интеграция блокнотов marimo Фреймворк marimo в основном предназначен для быстрого создания простых интерактивных блокнотов, но разработчики предусмотрели встраивание блокнотов в веб-приложения Flask и FastAPI92. Кроме того, как оказалось, можно в некоторых случаях встраивать приложения marimo в статические веб-страницы. Здесь мы рассмотрим интеграцию блокнотов в приложения FastAPI. Встраивание блокнотов marimo в приложение FastAPI В процессе встраивания мы решим следующие подзадачи: а) создание перечня блокнотов marimo в файле toc.toml93; б) загрузка этого перечня; в) регистрация блокнотов marimo; г) отображение перечня блокнота для выбора их пользователем; д) отображение блокнотов marimo в отдельных вкладках браузера; e) кроме того, мы покажем, как с помощью marimo отображать определённые типы содержимого на примере исходных текстов-примеров. Формат toml удобен для описания различных структур данных, в том числе иерархических (пример a02_20.py94). Ниже приводится фрагмент перечня примеров, рассмотренных в этой главе (содержимое файла toc_.toml): 92 Running the marimo backend programmatically [Электронный ресурс]. — URL: https://docs.marimo.io/ guides/deploying/programmatically/ (дата обращения 18.04.2026). 93 Toml: удобный язык для конфигурационных файлов [Электронный ресурс]. — URL: https://progery.ru/ toml (дата обращения 18.04.2026). 94 Запускать этот блокнот необходимо непосредственно, перейдя в папку src\a02, чтобы работали ссылки на файлы toml. 312
# Примеры marimo ["Оглавление"] ["Оглавление"."01"] "name" = "Пример 01" "title" = "Первый блокнот marimo" "url" = "a02_01.py" "app_type" = "marimo" "prm" = "" # ... ["Оглавление"."20"] "name" = "Пример 20" "title" = "Работаем с toml" "url" = "a02_20.py" "app_type" = "marimo" "prm" = "" ["Оглавление"."21"] "name" = "Пример 21" "title" = "Трехмерные кривые и поверхности в блокноте marimo" "url" = "a02_21.py" "app_type" = "marimo" "prm" = "" В отличие, например, от json, в файлах toml можно использовать комментарии, аналогичные комментариям Python. Каждый пример представлен разделом файла toml ["Оглавление"."XX"], где XX — номер примера. Пример описывается кратким названием (name), заголовком (title), отображаемым пользователю, относительным указателем ресурса (url), app_type — тип приложения, здесь мы будем рассматривать подключение только блокнотов marimo; prm позволяет передавать блокноту marimo дополнительные данные в строке запроса. Об этом будет сказано ниже. Загружается файл toml в одну строчку методом load: import toml toc_dict = toml.load("toc_.toml") toc_dict Результатом является словарь словарей, приведённый на рисунке П2.51. Естественный порядок здесь не сохраняется, однако его можно восстановить, преобразовав словарь в упорядоченный словарь OrderedDict из библиотеки collections. Преобразуем словарь словарей в список словарей, восстановив естественный порядок. Эту структуру мы будем использовать в основном скрипте FastAPI. items = [] for key, value in toc_dict["Оглавление"].items(): items.append({key: value}) items Нам предстоит преобразовать полученные данные о примерах в представление для пользователя и гиперссылки, позволяющие пользователю переходить к примерам, загружаемым в отдельные вкладки браузера. 313
Рис. П2.51 Внутренне представление перечня примеров Скрипт FastAPI (пример toc.py) приводится ниже. Он не относится к основной тематике книги, при первом чтении его можно опустить. Важным является то, что, активировав виртуальное окружение и перейдя в папку, в которой расположен файл toc.py, запустить веб-приложение примеров можно, введя в окне командной строки или терминале: python toml.py После этого в адресной строке браузера достаточно ввести: http://localhost На рисунке П2.52 изображена страница, отображаемая в браузере, с перечнем доступных блокнотов marimo. 314
Рис. П2.52 Каталог блокнотов marimo Перейти к любому примеру можно, щелкнув мышью по карточке с его описанием. Вернёмся к описанию скрипта FastAPI toc.py. 01 from typing import Callable, Coroutine 02 from fastapi import FastAPI, Request, Response, Depends, HTTPException, status 03 from fastapi.responses import RedirectResponse 04 from fastapi.templating import Jinja2Templates 05 from pydantic import BaseModel 06 import marimo, os, toml 07 08 app_dir = os.path.dirname(__file__) # каталог приложения 09 10 templates_dir = os.path.join(os.path.dirname(__file__), "templates") # шаблоны HTML 11 examples_dir = os.path.join(os.path.dirname(__file__), "") # примеры marimo 12 13 # подгружаем оглавление из файла toml 14 toc = toml.load("toc.toml") 15 16 # сервер приложений marimo 17 server = marimo.create_asgi_app() 18 19 # собираем примеры (ссылки на блокноты marimo) 315
20 items : list[str] = [] 21 for i, item in enumerate(toc["Оглавление"].items()): 22 it = item[1] # очередной элемент оглавления 23 24 name = it["name"] if "name" in it else "Без имени" 25 title = it["title"] if "title" in it else "" 26 url = it["url"] if "url" in it else "/404" 27 app_name = os.path.splitext(url)[0] 28 app_path = os.path.join(examples_dir, url) 29 # регистрируем имена приложений marimo 30 app_type = it["app_type"] if "app_type" in it else "" 31 if app_type=="marimo": 32 server = server.with_app(path=f"/{app_name}", 33 root=app_path) 34 35 prm = it["prm"] if "prm" in it else "" # передаваемые параметры 36 url = app_name + "?"+prm if prm else app_name 37 name = f"{i+1}. {name}" 38 items.append({"name":name, "title":title, "url":url}) 39 40 # Приложение FastAPI 41 app = FastAPI() # создаем приложение FastAPI 42 43 # Создаем шаблоны Jinja 2 44 templates = Jinja2Templates(directory=templates_dir) 45 46 @app.get("/") 47 async def home(request: Request): 48 return templates.TemplateResponse( 49 "toc.html", {"request": request, "items": items} 50 ) 51 52 # Делаем приложения marimo доступными из FastAPI 53 app.mount("/", server.build()) 54 55 # Вывод исходных текстов 56 source_code = "src.py" 57 app_path = os.path.join(examples_dir, source_code) 58 server = server.with_app(path=f"/src", root=app_path) 59 60 # Запускаем сервер 61 if __name__ == "__main__": 62 import uvicorn 63 uvicorn.run(app, host="localhost", port=80) В первых шести строках скрипта осуществляется импорт необходимых пакетов и модулей Python. Далее создаются переменные для путей к папкам шаблонов и примеров (10–11). Здесь примеры расположены в той же папке, что и toc.py. Подгрузка оглавления из файла toc.toml и создание сервера приложений marimo осуществляется в строках 14, 17. Сервер приложений marimo нам понадобится для связывания конечных точек FastAPI и приложений marimo. 316
В цикле (строки 24–38) осуществляется преобразование словаря словарей с перечнем приложений marimo в список словарей. Делается это для формирования пользовательского интерфейса перечня примеров и гиперссылок на них. Следует обратить внимание на связывания примеров marimo с конечными точками FastAPI вида /имя_примера в строках 31–33. Список словарей собирается из элементов в строке 38. В строке 41 создаётся app — объект приложения FastAPI, а в строке 44 объект шаблонов Jinja, что является стандартной подготовкой к запуску приложений для данного фреймворка. За «домашнюю страницу» отвечает функция home (строки 46–50), ей передаётся объект запроса, возвращает она ответ, формируемый с помощью шаблона templates/toc.html: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Примеры marimo</title> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-gray-100"> <div class="container mx-auto px-4 py-8"> <h1 class="text-3xl font-bold text-center"> Запускаем блокноты marimo из веб-приложения FastAPI </h1> <h2 class="text-2xl text-center font-semibold mb-4"> Доступные блокноты marimo: </h2> <div class="grid grid-cols-1 sm:grid-cols-2 md:gridcols-3 lg:grid-cols-4 gap-4"> {% for item in items %} <a href="/{{ item['url'] }}" class="block" target="{{ item['url'] }}" > <div class="flex flex-col bg-white min-h-48 roundedlg shadow-md hover:shadow-lg transition-shadow duration-300 p-4"> <h3 class="text-lg font-semibold text-blue-600 hover:text-blue-800">{{ item['name'] }}</h3> <h4 class="flex-grow text-md font-semibold textblack-800">{{ item['title'] }}</h4> <p class="text-gray-600 mt-2 text-sm">Щелкните мышью, чтобы открыть приложение</p> </div> </a> {% endfor %} </div> </div> </body> </html> 317
Функциональность шаблона состоит в формировании карточки и гиперссылок для каждого примера и подключения стилей tailwind95. Нам осталось только смонтировать собранные приложения marimo, что и сделано в строке 53. Запуск веб-приложения FastAPI осуществляется в строках 61–63. Теперь к блокнотам marimo можно обращаться по их именам, например, блокнот a02_21.py можно запустить не только из домашней страницы — перечня примеров, но и введя в адресной строке браузера (рис. П2.53): http://localhost/a02_21/ Рис. П2.53 Запускаем блокнот marimo непосредственно в браузере На этом можно было бы закончить, но покажем, как можно передавать данные блокнотам marimo при их запуске. Передача данных блокнотам marimo при запуске Как и раньше, сделаем это на примере (см. src.py). В нем мы показываем, как передавать данные блокнотам marimo в различных условиях. Начнём с передачи данных в запросе, для этого вернёмся к листингу toc.py. В строках 56–58 мы добавили в веб-приложение FastAPI ещё один блокнот marimo src.py. Обратиться к нему можно так: http://localhost/src/?p=путь_к_файлу В приложении допускается отображения исходных текстов только тех файлов, которые расположены в папке примеров, например, a02_21.py. Кодирование расширения обязательно, так как оно используется для подсветки синтаксиса исходного текста. Результатом будет отображения исходного текста файла, как показано на рисунке П2.54. 95 Быстро создавайте современные веб-сайты, не выходя из HTML-кода [Электронный ресурс]. — URL: https://tailwindcss.ru/ (дата обращения 18.04.2026). 318
Рис. П2.54 Отображение исходного кода файла a02_21.py Передача данных из запроса в блокнот осуществляется следующим образом (см. src.py): # извлекаем имя файла из параметров, переданных блокноту ca = mo.cli_args() # marimo run scr.py -- --p имя_файла qp = mo.query_params() В ca или qp мы получим либо словарь переданных параметров, либо None. Мы приняли соглашение, что имя файла передаётся в элементе словаря с ключом p. Если не передать блокноту параметры, то будет отображено выпадающее меню с перечнем примеров из toc.toml (рис. П2.55), из которого можно выбрать файл. 319
Рис. П2.55 Выбор имени файла для отображения исходного текста На заметку Распечатку исходных файлов можно осуществлять и запуская блокнот src.py из терминала или командной строки: marimo run src.py -- --p путь_к_файлу Встраивание блокнотов marimo в статические веб-страницы В marimo имеется способ преобразовывать приложения в статические файлы html, которые можно запускать непосредственно в браузере, что становится возможным благодаря проекту Pyodiode96, который позволяет экспортировать приложения marimo в WebAssemby (WASM). Это позволяет запускать их в непосредственно в веб-браузере без необходимости в установке Python. Кроме ядра Python, Piodiode включает в себя достаточно широкий набор библиотек Python для научно-технических расчётов, во всяком случае numpy, pandas, scipy и matplotlib в нем имеются. Преобразование приложения marimo осуществляется достаточно просто. 1) Нужно перейти в папку с приложением. В нашем случае — a02. 2) В терминале или окне командной строки нужно запустить команду преобразования. В качестве примера мы используем блокнот a02_11.py. Преобразование осуществляется средствами marimo, нужно только указать пути к исходному и преобразованному файлам: marimo export html-wasm a02_11.py -o a02_11.html 96 Pyodiode. [Электронный ресурс]. — URL: https://github.com/pyodide/pyodide?ysclid=mgmbiq6w36583112471 (дата обращения 18.04.2026). 320
Если всё в порядке (т. е. в самом блокноте нет ошибок, а все необходимые библиотеке включены в сборку Pyodiode), то в папке появится целевой html-файл и папка assets. К сожалению, запустить преобразованное приложение из файловой системы невозможно, делается это с помощью встроенного веб-сервера Python или любого другого доступного веб-сервера. В первом случае запускаем встроенный веб-сервер с помощью команды: python -m http.server Далее в адресной строке браузера вводим: http://localhost:8000/a02_11.html Через некоторое время приложение marimo стартует во вкладке браузера (рис. П2.56): Рис. П2.56 Запуск преобразованного в WASM приложения marimo в виде веб-страницы a02_11.html Следует отметить, что размер html-файла обычно невелик, в нашем случае — 25 Кб, а вот при преобразовании в папке дополнительных ресурсов assets сгенерировано более 1000 файлов общим объёмом 33 Мб. Так что запускаются приложения marimo через сеть не слишком быстро. В заключение можно отметить, что marimo представляет собой отличную платформу для быстрой разработки интерактивных расчётных веб-приложений с развитым пользовательским интерфейсом, его достаточно просто использовать в учебном процессе. 321
Приложение 3 Краткий обзор языка разметки Мarkdown На протяжении всей книги мы широко используем язык разметки Markdown. Это простой язык разметки текстов, позволяющий достаточно просто формулировать условия задач, описывать результаты вычислительных экспериментов. О вставке текстов на Markdown в блокноты marimo говорится в Приложении 2. Рассматриваемые возможности Markdown относятся как к блокнотам Jupyter, так и marimo. Особенности использования Markdown в marimo специально оговариваются. Если в ячейку Markdown скопировать произвольный текст, то он отобразится как один-единственный абзац. В исходном тексте Markdown разбиение на абзацы кодируется пропуском строки. Несколько подряд идущих пробелов в исходном тексте сокращаются до одного. Исходный текст можно структурировать заголовками. Заголовки кодируются символами # в начале строки. Текст, следующий после заголовка на отдельной строке, отображается новым абзацем. Имеется шесть уровней заголовков: # Заголовок первого уровня Текст после заголовка на отдельной строке ## Заголовок второго уровня ### Заголовок третьего уровня #### Заголовок четвертого уровня ##### Заголовок пятого уровня ###### Заголовок шестого уровня Представление заголовков в marimo показано на рисунке П3.1. Рис. П3.1 Заголовки Markdown 322
Отделять фрагменты текста друг от друга можно горизонтальными линиями, которые кодируются тремя последовательными звёздочками (рис. П3.2): ##Горизонтальные линии Фрагмент текста 1 *** Фрагмент текста 2 *** Рис. П3.2 Горизонтальные линии Структурировать текст можно также с помощью нумерованных и ненумерованных списков. Элементы ненумерованного списка кодируются дефисом в начале строки, после которого следует пробел, а далее текст элемента списка. Элементы нумерованного списка кодируются цифрой, после которой следуют точка и пробел. Списки могут быть вложенными. Ниже следует пример вложенных списков: Рис. П3.3 Списки в Markdown 323
## Списки а Markdown 1. Первый элемент нумерованного списка 1. Второй элемент нумерованного списка - Первый элемент ненумерованного вложенного списка - Второй элемент ненумерованного вложенного списка 1. Третий элемент нумерованного списка. Цифра номера не имеет значения 1. Первый элемент вложенного нумерованного списка 1. Второй элемент вложенного нумерованного списка 1. Четвёртый элемент Имеется ещё один тип списков — список задач. В нем можно помечать уже выполненные задачи. Ниже приводится код списка задач, а на рисунке П3.4 — его представление в блокноте marimo. - [x] Написать раздел по менеджеру пакетов uv - [ ] Написать раздел по языку разметки Markdown - [ ] Написать раздел по Latex Рис. П3.4 Список задач В Markdown выделение текста осуществляется его заключением с обеих сторон специальными символами. Так, выделение курсивом осуществляется одинарными звёздочками, полужирным — двойными звёздочками, а перечёркивание кодируется символами ~~ (см. рис. П3.5): - текст, выделяемый *курсивом*; - текст, выделяемый **полужирным**; - ~~перечёркнутый текст~~. Рис. П3.5 Выделение текста Выделение подчёркиванием отсутствует, но подчеркнуть текст можно с помощью языка разметки HTML (рис. П3.6): Это текст, <i><b><u><s>выделенный рассмотренными выше способами и подчеркиванием с помощью HTML</s></u></b></i> 324
Рис. П3.6 Выделение текста с помощью языка разметки HTML Фрагменты кода, отображаемые в строке, ограничиваются обратными апострофами (`), а большие фрагменты, сохраняющие исходное форматирование, тройными обратными апострофами, например: Это фрагмент `source code (исходного кода)`, вставленный в строку.. Это многострочный фрагмент, сохраняющий исходное форматирование ``` import numpy as np import matplotlib.pyplot as plt ``` Подсветка исходного кода Python: ```python import numpy as np import matplotlib.pyplot as plt ``` Пример текста, сохраняющего исходное форматирование, приведён на рисунке П3.7. Рис. П3.7 Пример текста, сохраняющего исходное форматирование; «подсветка» на Python В marimo имеется возможность «подсветки» исходного текста на языках программирования. Более подробно об этом говорится в Приложении 2. 325
Заканчивая с выделениями, отметим, что в Markdown в блокнотах marimo можно вставлять эмодзи. Делать это можно как с помощью кодов эмодзи, ограниченных слева и справа двоеточием, так и введя в редакторе двоеточие, начинать вводить код. При этом появляется выпадающий список доступных эмодзи, из которого можно сделать выбор. Ниже и на рисунке П3.9 следует пример использования эмодзи. ## Эмодзи Вставляем коды эмодзи :joy: :tent: Эмодзи marimo🏺⭐ Рис. П3.8 Вставка эмодзи Цитаты должны начинаться с новой строки и символа «больше» (>), за которым следует пробел (рис. П3.9): ## Цитата > Лучше быть богатым и здоровым, > чем бедным и больным. Рис. П3.9 Цитата Гиперссылки кодируются так: сначала в квадратных скобках задаётся надпись, отображаемая в гиперссылке, после этого в круглых скобках — унифицированный указатель ресурса, например, [НИУ МЭИ](https://www.mpei.ru) Пробелы между закрывающей квадратной скобкой и открывающей круглой скобкой не допускаются. Если необходимо, чтобы при задержке курсора мыши на гиперссылке отображалась дополнительная информация, то её нужно указать после адреса ресурса в кавычках или апострофах, например: ## Кодирование гиперссылок [Издательство Лань](https://lanbook.com/) [ЭБС Лань](https://e.lanbook.com/ 'Лань. Электронно-библиотечная система') В ячейки Мarkdown можно вставлять изображения. Вставка изображения от гиперссылки отличается только тем, что перед открывающей квадратной скобкой ставится восклицательный знак: 326
![Логотип издательства Лань](https://lanbook.com/local/templates/.default/images/logo.svg) Необходимо иметь в виду, что в marimo загрузка изображений производится только через веб-сервер. При загрузке изображений из файловой системы необходимо использовать виджет mo,ui.image. Если необходимо задать размеры изображения, то необходимо сделать это средствами HTML (рис. П3.10): <img src="https://lanbook.com/local/templates/.default/images/logo.svg" width="100"> Рис. П3.10 Вставка изображения в документ Markdown В таблицах Markdown столбцы отделяются друг от друга символом |, шапка таблицы отделяется от содержимого символом «минус», например: | № |Приложение | Выполнено | |--:|:----------|:---------:| | П1| Менеджер пакетов uv | [x] | | П2| Блокноты marimo | [ ] | | П3| Краткий обзор языка разметки Мarkdown| [x] | | П4| Математические формулы в блокнотах Jupyter и marimo | [ ] | | П5| Построение анимации с помощью библиотеки gif | [ ] | Выравнивание содержимого в столбцах тела таблицы задаётся с помощью символов «двоеточие» в строке, отделяющей шапку от тела таблицы. Так, в примере первый столбец выравнен вправо, второй влево, а третий по центру (рис. П3.11). Рис. П3.11 Таблица в Markdown Достаточно легко кодировать переходы между ячейками документа marimo. Однако без HTML здесь не обойтись. Для этого кодируем закладку в ячейке, в которую нужно перейти: <a name="имя_закладки"> 327
При кодировании гиперссылки нужно не забыть указать перед именем закладки символ #: [Переход к закладке имя_закладки ](#имя_закладки) В результате создан переход в начало документа. В блокнотах marimo достаточно просто создаются концевые ссылки. Для создания концевой ссылки достаточно указать [^#], где # — номер ссылки. Для первой ссылки указывается 1, для второй — 2 и т. д. На следующей строке кодируется содержимое концевой ссылки, в начале которой необходимо указать [^#]: Содержимое концевых ссылок отображается в самом конце документа, отделенное от тела документа горизонтальной чертой. Ниже приведён пример кодирования концевых ссылок (рис. П3.12). Рис. П3.12 Концевые ссылки ## Кодирование концевых ссылок Это первая концевая ссылка [^1]. [^1]: Это содержимое первой концевой ссылки. Это вторая концевая ссылка [^2]. [^2]: Это содержимое второй концевой ссылки. Это третья концевая ссылка [^3]. [^3]: Это содержимое третьей концевой ссылки. Для перехода к содержимому концевой ссылки достаточно щелкнуть на ссылке в надстрочном индексе, для возврата к фрагменту документа, откуда был совершен переход, перейти по ссылке на значке . Исходный текст примеров приложения — в разделе a03_01.py. 328
Приложение 4 Формулы в блокнотах Jupyter и marimo В ячейках Markdown блокнотов Jupyter и marimo, на рисунках, выполненных с помощью matplotlib, можно кодировать математические формулы. Делается это с помощью LaTeX, краткие «шпаргалки» по нему опубликованы в Интернете [71–73]. Более полное руководство по LaTeX содержится в книге [74]. К сожалению, не все рассмотренные в ней возможности применимы в блокнотах Jupyter и marimo. Формулы могут быть вставлены непосредственно в строку (поток вывода), для чего их ограничивают слева и справа символом доллара ($). Формулы, кодируемые на отдельных строках, ограничиваются слева и справа двумя символами $, такие формулы выравниваются посередине. Как и принято в формулах, текст в них отображаются курсивом. Этот код $LaTeX$ отображается непосредственно в строке (inline), а этот код будет отображён на отдельной строке: $$LaTeX$$ Формулы, отображаемые на отдельной строке, можно нумеровать с помощью конструкции `\tag{номер_формулы}`: $$ c = a + b. \tag{42} $$ Результат представлен на рисунке П4.1. Рис. П4.1 Вставка выражений LaTeX непосредственно в текст и в отдельную строку Коды специальных символов начинаются с обратной косой черты (\). В LaTeX обычные пробелы не отображаются, для явного кодирования следует использовать последовательность символов «обратная косая черта» и «пробел». Ниже приводятся коды первых четырёх строчных букв греческого алфавита: $\alpha, \beta, \gamma, \delta$ , результат представлен на рисунке П4.2. Рис. П4.2 Первые четыре символа греческого алфавита 329
Специальные символы можно использовать в блокнотах Jupyter и marimo в именах переменных. Достаточно ввести код символа и нажать клавишу Tab. Коды символов греческого алфавита приведены в таблице П4.1. Таблица П4.1 Коды символов греческого алфавита Код \alpha \delta \eta \lambda \xi \rho \upsilon \varphi \varsigma \psi \Beta \Epsilon Символ Код Символ Код 𝛂 𝛅 𝛈 𝛌 𝛏 𝛒 𝛖 𝛗 𝛓 𝛙 𝐁 𝐄 \beta \epsilon \theta \mu o \sigma \varepsilon \varpi \vartheta \omega \Gamma \Zeta 𝛃 𝛜 𝛉 𝛍 𝐨 𝛔 𝛆 𝛡 𝛝 𝛚 Г 𝐙 \gamma \zeta \kappa \nu \pi \tau \varkappa \varrho \chi \Alpha \Delta \Eta \Theta 𝚯 \Kappa 𝐊 \Lambda \Mu 𝐌 \Nu 𝐍 \Xi O 𝐎 \Pi 𝚷 \varSigma \Psi 𝚿 \Omega 𝛀 Символ 𝛄 𝛇 𝛋 𝛎 𝛑 𝛕 𝛞 𝛠 𝛘 𝐀 𝚫 𝐇 𝚵 Для того чтобы отображать символы прямым шрифтом, а не курсивом, используется код \mathrm. Действие этого кода распространяется на следующий символ, однако символы можно группировать с помощью фигурных скобок, например: $$\mathrm{LaTeX. \ Здесь \ текст \ отображается \ прямым \ шрифтом.}$$ Рис. П4.3 Отображение надписи LaTeX прямым шрифтом Подстрочные индексы отображаются с помощью символа подчёркивания _, а надстрочные индексы с помощью символа ^. Если необходимо поместить в надстрочный или подстрочный индекс несколько символов, то их нужно заключить в фигурные скобки. Ниже приводятся коды химической формулы воды и формула Эйнштейна. Химическая формула воды $\mathrm{H_{2}O}$. Формула Эйнштейна, связывающая энергию E, массу m и скорость света c: $E=mc^2$. Последняя формула отображается курсивом. В таблице П4.2 приводятся коды некоторых широко распространённых математических символов. 330
Таблица П4.2 Коды часто используемых математических символов Код Символ Код \aleph \hbar ℵ ℏ \emptyset \int \neg ¬ \oint \prod \triangle \downarrow \R  \sum \to \leftarrow \Z \Re \in \geq  △ ↓ R ∈ ≥ Символ ∅  ∮ ∑ → ← \Reals \land \leq ∧ ≤ Код Символ \exists \nabla ∃ ∇ \partial 𝜕 \sqrt \uparrow \N \Complex √ \cdot \lor \appox ⋅ ∨ ≈ ↑ Дроби отображаются с помощью кода \frac, за которым следуют в фигурных скобках числитель и знаменатель, например: Дробь одна вторая:$\frac{1}{2}$ Покажем, как кодируется запись квадратного уравнения и формула для его решения (рис. П4.4): Рис. П4.4 Кодирование квадратного уравнения и его решения на LaTeX Квадратное уравнение: $$ax^2+bx+c=0,$$ и его решение $$x_{1,2} = \frac{- b \pm \sqrt{b^2-4ac} }{2a}.$$ Вычисление предела функции (рис. П4.5): $Предел\ функции: \lim \limits_{x \to \infty} e^{-x}=0.$ Рис. П4.5 Предел функции Вычисление суммы (рис. П4.6): Вычисляем\ сумму: $y=\sum_{i=1}^n g(x_i).$ Рис. П4.6 Вычисление суммы 331
Вычисление определённого интеграла (рис. П4.7): $Определённый\ интеграл: \int\limits_0^\infty \mathrm {e}^{-x}\ \mathrm{d}x.$ Рис. П4.7 Вычисление определённого интеграла Матрица (рис. П4.8): $$\mathrm{A}=\begin{pmatrix} a_{1,1} & a_{1,2} & a_{1,3}\\ a_{2,1} & a_{2,2} & a_{2,3}\\ a_{3,1} & a_{3,2} & a_{3,3}\\ \end{pmatrix}.$$ Рис. П4.8 Представление матрицы в LaTeX Определитель (рис. П4.9): $$\mathrm{D}=\begin{vmatrix} a_{1,1} & a_{1,2} & a_{1,3}\\ a_{2,1} & a_{2,2} & a_{2,3}\\ a_{3,1} & a_{3,2} & a_{3,3}\\ \end{vmatrix}.$$ Рис. П4.9 Представление определителя в LaTeX Рис. П4.10 Представление множества в LaTeX Представление множества в LaTeX (рис. П4.10) $$\Set{ x | x\leq\frac {1} {2}}.$$ Запустить пример данного раздела можно так: marimo run a04_01.py 332
Приложение 5 NumPy: массивы и изображения Парадокс применения Python для научно-технических расчётов состоит в том, что при всем своем удобстве, лаконичности и гибкости Python является интерпретируемым языком, и те действия, которые в таких компилируемых языках, как C и C++, выполняются единожды при компиляции, в Python делаются многократно в процессе выполнения программы. При решении расчётных задач нам чаще всего приходится решать системы алгебраических уравнений, рисовать графики, без массивов тут не обойтись. В Python изначально были предусмотрены простые механизмы для вызова функций, написанных на других языках программирования, включая C и Fortran, для которых имеются большие, быстрые и сопровождаемые на протяжении десятков лет библиотеки для решения научно-технических и инженерных задач. Работа с массивами осуществляется быстро, т. к. для C и Fortran массив — это область памяти, все элементы массива — одного типа, их размер не меняется во время выполнения программы, в отличие от Python, где всё можно менять динамически. Единственное неудобство состоит в том, что при непосредственном вызове процедур и функций приходится передавать большое число аргументов… Таким образом, очень хотелось бы, чтобы с одной стороны можно было бы работать с привычными средствами Python, а с другой стороны иметь под рукой всю мощь и производительность библиотек для решения научно-технических задач, написанных на компилируемых языках. Именно для этого и был создан NymPy — Numerical Python, основу которого составляют удобные и простые средства для работы с массивами. Раньше практически всё, что было необходимо для проведения научно-технических расчётов, было сосредоточено в этой библиотеке, но в настоящее время считается, что областью ответственности NumPy является работа с массивами, а средства для решения научно-технических задач должны находиться в специализированных библиотеках, например, в SciPy — Scientific Python, skilearn — базовые методы машинного обучения, matplotlib — научная визуализация и т. д. Это оказалось настолько удобным, что практически вся экосистема Python «понимает» и умеет работать с NumPy. Мы далеки от того, чтобы сделать это приложение исчерпывающим руководством по NumPy. Для этого написана не одна книга [75–76]. Нашей задачей является дать краткое и наглядное руководство, которое сделает понятными исходные тексты на Python, приводимые в книге. Наглядность нам обеспечит тот факт, что изображения в градациях серого — это двумерные массивы, а цветные 333
изображения — трёхмерные или четырёхмерные массивы, если учитывать не только цвет, но и его прозрачность. Как только начнём рассматривать многомерные массивы, так сразу же начнём работать с изображениями. Для работы с NumPy достаточно импортировать эту библиотеку. Создать массив NumPy можно из любой последовательности Python, например, из списка. Делается это с помощью функции np.array. import numpy as np a = np.array([1, 2, 3]) l = list(a) a, type(a), l, type(l) Мы получим: (array([1, 2, 3]), numpy.ndarray, [1, 2, 3], list) Мы специально сделали этот пример, чтобы показать, что массивы NumPy — это не списки и не кортежи, а особые объекты. Теперь нам нужно показать, что будет, если попытаться преобразовать список, элементы которого имеют различные типы данных, например, логические, целые значения, числа с плавающей точкой: a = np.array([True, False, 2, 3.0]) a Мы получим массив, содержащий числа с плавающей точкой: array([1., 0., 2., 3.]) Вспоминаем, что все элементы массива NumPy должны быть одного типа. При создании массива NumPy действует следующее неявное правило преобразования: 𝑏𝑜𝑜𝑙𝑒𝑎𝑛 → 𝑖𝑛𝑡𝑒𝑔𝑒𝑟 → 𝑓𝑙𝑜𝑎𝑡 → 𝑐𝑜𝑚𝑝𝑙𝑒𝑥 → 𝑠𝑡𝑟𝑖𝑛𝑔 → 𝑜𝑏𝑗𝑒𝑐𝑡. Таким образом, если хотя бы один элемент преобразуемого в массив списка или кортежа будет строкой, то все элементы массива станут строками, для каждого символа резервируется четыре байта (ничего не поделаешь, используется Unicode), а длина строки элемента массива равна максимальной длине строки, входящей в список: a = np.array(['a', 'bc', 'def']) a, a.itemsize, a.size, a.nbytes, a.dtype В данном случае мы используем встроенные в NumPy атрибуты, позволяющие выяснить размер элемента массива (itemsize), число элементов в массиве (size) и число байтов, занимаемое массивом в оперативной памяти (nbytes): (array(['a', 'bc', 'def'], dtype='<U3'), 12, 3, 36, dtype('<U3')) Тип массива можно определить c помощью атрибута dtype. В заповедях Python (Zen of Python) [77] говорится, что «Явное лучше неявного», поэтому будем задавать тип элементов массива в явном виде, передавая функции np.array именованный параметр dtype, например, для создания комплексного массива из списка достаточно: a=np.array([1,2,3], dtype=complex) a В результате получим: array([1.+0.j, 2.+0.j, 3.+0.j]) В связи с этим сделаем краткий обзор типов, используемых в массивах NumPy. Начнём с булевых массивов. 334
a = np.array([0, 1], dtype=bool) a, a.itemsize, a.size, a.nbytes, a.dtype Элементы булевых массивов принимают значения False, True, занимают в оперативной памяти один байт: (array([False, True]), 1, 2, 2, dtype('bool')) NumPy поддерживает набор целых типов со знаком, зададим массив, содержащий однобайтные целые со знаком: a = np.array([-2, -1, 0,1,2], dtype=np.int8) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) Здесь мы показали, как можно выяснить минимальное и максимальное значения для целых типов: (array([-2, -1, 0, 1, 2], dtype=int8), 1, 5, 5, dtype('int8'), -128, 127) Двухбайтовые целые со знаком: a = np.array([-2,-1, 0, 1,2], dtype=np.int16) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) Результат выполнения: (array([-2,-1, 0, 1, 2], dtype=int16), 2, 5, 10, dtype('int16'), -32768, 32767) Массивы с четырёхбайтовыми целыми задаются с помощью типа int32: a = np.array([-2,-1, 0, 1,2], dtype=np.int32) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) В результате мы получим: array([-2, -1, 0, 1, 2]), 4, 5, 20, dtype('int32'), -2147483648, 2147483647) Массивы с восьмибайтовыми целыми задаются с помощью типов int и np.int64: a = np.array([-2,-1, 0, 1, 2], dtype=np.int64) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) Результат: (array([-2,-1, 0, 1, 2], dtype=int64), 8, 5, 40, dtype('int64'), -9223372036854775808, 9223372036854775807) При решении задач достаточно часто приходится задавать большие массивы, в этом случае желательно обращать внимание на минимальные и максимальные допустимые значения, которые должны содержать массивы. Если есть уверенность, что значения элементов массива не превышают 100, то имеет смысл создавать массивы с типом np.int8, уменьшив объём занимаемой памяти в четыре раза по сравнению c np.int32. По аналогии с целыми со знаком можно использовать целые без знака, добавив при указании типа символ u перед int. Здесь мы приведём примеры только для np.uint8 и np.uint16: a = np.array([-2,-1, 0, 1, 2], dtype=np.uint8) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) 335
В однобайтовом целом без знака хранятся числа от 0 до 255. Обратите внимание, как преобразуются отрицательные числа: (array([254, 255, 0, 1, 2], dtype=uint8), 1, 5, 5, dtype('uint8'), 0, 255) Массивы с однобайтовыми целыми без знака используются для кодирования изображений. Проделаем то же с массивом, содержащим двухбайтовые целые без знака: a = np.array([-2, -1, 0, 1, 2], dtype=np.uint16) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.iinfo(a.dtype).min, np.iinfo(a.dtype).max) Результат выполнения: (array([65534, 65535, 0, 2, 5, 10, dtype('uint16'), 0, 65535) 1, 2], dtype=uint16), Опять же, обратите внимание на преобразование отрицательных чисел! Элементы массивов с плавающей точкой могут быть или четырёхбайтными, если задать dtype=np.float32, либо восьмибайтными, если задать dtype=float или dtype=np.float64. a = np.array([-2e-10, -1, 0, 1, 2e20], dtype=np.float32) (a, a.itemsize, a.size, a.nbytes, a.dtype,np.finfo(a.dtype).min, np.finfo(a.dtype).max) Результат выполнения: (array([-2.e-10, -1.e+00, 0.e+00, dtype=float32), 4, 5, 20, dtype('float32'), -3.4028235e+38, 3.4028235e+38) 1.e+00, 2.e+20], Для восьмибайтных чисел с плавающей точкой всё то же самое, за исключением диапазона значений: (array([-2.e-10, -1.e+00, 0.e+00, 1.e+00, 2.e+20]), 8, 5, 40, dtype('float64'), -1.7976931348623157e+308, 1.7976931348623157e+308) Комплексные числа могут иметь как четырёхбайтную (dtype=np.complex64), так и восьмибайтную (dtype=complex или dtype=np.complex128) действительную и мнимую части, по умолчанию используются плавающие числа с удвоенной точностью. a = np.array([-2e-10, -1, 0, 1, 2e20], dtype=np.complex) (a, a.itemsize, a.size, a.nbytes, a.dtype) Результат выполнения ячейки: (array([-2.e-10+0.j, -1.e+00+0.j, 2.e+20+0.j]), 16, 5, 80, dtype('complex128')) 0.e+00+0.j, 1.e+00+0.j, Напоминаем, что в строковых массивах для каждого элемента используется одинаковое число четырёхбайтных символов, для символов, отсутствующих на клавиатуре, можно использовать символы Unicode, \uXXXX, где X — шестнадцатеричная цифра 0 — F, например: a = np.array(['abc', '\u277C\u277D\u277E\u277F']) a, a.dtype, a.itemsize, a.nbytes Результат выполнения: (array(['abc', '❼❽❾❿'], dtype='<U4'), dtype('<U4'), 16, 32) 336
До сих пор мы имели дело только с одномерными массивами, но никто нам не мешает создать и многомерные массивы: a = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9,10, 11, 12]], dtype= np.int32) a, a.ndim, a.shape, len(a) Здесь мы заодно показали, как определить число измерений массива (атрибут ndim), размерности массива (атрибут shape). Как и на последовательностях Python, на массивах определена функция len, возвращающая число элементов по нулевому измерению: (array([[ 1, 2, 3, 4], [ 5, 6, 7, 8], [ 9, 10, 11, 12]]), 2, (3, 4), 3) Ранее массивы создавались с помощью функции np.array, что для массивов, содержащих тысячи и миллионы элементов, невозможно. Именно поэтому в NumPy имеется большое число функций, позволяющих создавать массивы. Перечислим некоторые из них. Для создания массивов, заполненных нулями или единицами, используются функции zeros и ones, соответственно. Для одномерных массивов этим функциям передаётся число элементов, а для многомерных — кортеж с числами элементов по измерениям. В качестве примера создадим двумерный массив, заполненный числами 42: a = np.ones((2,5)) * 42 a Нулевым элементом кортежа, придаваемого функциям zeros и ones, является число строк массива, а первым — число столбцов. Результатом выполнения является: array([[42., 42., 42., 42., 42.], [42., 42., 42., 42., 42.]]) Тот же результат можно получить с помощью функции np.zeros: a = np.zeros((2,5)) + 42 Аналогом range «чистого» Python является функция arrange: a = np.arange(6) a При передаче ей единственного аргумента, например 6, она создаёт массив, содержащий последовательность целых чисел от 0 до 5: array([0, 1, 2, 3, 4, 5]) Этой функции можно передать начальное, конечное значение, шаг и тип элементов массива: a = np.arange(1, 12, 2, dtype=np.complex128) a Так же, как в range, конечный элемент не включается в массив: array([ 1.+0.j, 3.+0.j, 5.+0.j, 7.+0.j, 9.+0.j, 11.+0.j]) На наш взгляд, гораздо более удобным является использование функции linspace, которой передаются начальное, конечное значения элементов массива и число элементов массива: a = np.linspace(0, 1, 6, dtype=np.float64) 337
a В результате получим: array([0. , 0.2, 0.4, 0.6, 0.8, 1. ]) Квадратный двумерный массив с единицами на диагонали и нулевыми остальными элементами проще всего получить с помощью функции np.identity: a = np.identity(5, dtype=np.uint8) a В результате получим: array([[1, [0, [0, [0, [0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0], 0], 0], 0], 1]], dtype=uint8) Как уже говорилось выше, большинство библиотек экосистемы NumPy работают с массивами NumPy. В качестве примера отобразим график функции, 𝑦(𝑥) = 𝑠𝑖𝑛(𝑥)/𝑥, имитирующий рисование от руки (блокнот a05_01.py, рис. П5.1). Рис. П5.1 Отображаем массив NumPy на графике matplotlib import numpy as np import matplotlib.pyplot as plt x = np.linspace(-5, 5, 1000) y = np.sinc(x) fig = plt.figure(figsize=(4,3)) with plt.xkcd(): plt.plot(x, y,'k-', lw=3) plt.xlabel('x', fontsize=16) plt.ylabel(r'y', fontsize=16) fig Обратите внимание, что функции np.sinc передаётся массив значений, а возвращает она также массив. Такие функции называются универсальными, о том, как их писать, мы поговорим далее. А вот теперь дело дошло до изображений. Каждый пиксел задаётся нулем или единицей для чёрно-белого изображения (чёрному соответствует 0, а белому 1). Если мы хотим получить изображение в градациях серого, то значение 338
пиксела задаётся числом от нуля до 255 (один байт) или числом с плавающей точкой от нуля до единицы (четыре или восемь байтов). В цветном изображении каждому элементу-пикселу соответствует либо три составляющие: интенсивности красного (r), зелёного (g) и синего (b) цветов, либо четыре байта. В последнем случае к rgb-составляющим добавляется непрозрачность (a) пиксела, причем минимальному значению соответствует непрозрачный пиксел, а максимальному — полностью прозрачный пиксел. В библиотеке scipy.misc имеются два изображения, которые мы будем использовать при работе с массивами. Первое изображение — это цветной портрет енота (блокнот a05_2.py): import numpy as np import matplotlib.pyplot as plt from scipy.datasets import face f = face() fig1 = plt.figure(figsize=(4,3)) plt.imshow(f) plt.axis('off') print(f"{f.shape=}, {f.dtype=}") fig1 Вызов функции face возвращает массив цветного изображения енота, представленного на рисунке П5.2. Рис. П5.2 Цветное изображение енота Функция face возвращает трёхмерный массив, элементами массива являются однобайтовые целые без знака: f.shape=(768, 1024, 3), f.dtype=dtype('uint8') Массив имеет 768 строк и 1024 столбца, каждому пикселу соответствуют три байта красной, зелёной и синей составляющих. Вызов функции fg = face(gray=True) print(f"{fg.shape=}, {fg.dtype=}") позволяет получить двумерный массив — изображение енота в градациях серого: 339
fg.shape=(768, 1024), fg.dtype=dtype('uint8') Для корректного отображения изображения в градациях серого приходится указывать палитру, иначе по умолчанию мы получим изображение в синезелёных тонах, а заодно отключим отображение оцифровки осей: fig2 = plt.figure(figsize=(4,3)) plt.imshow(fg, cmap='gray') plt.axis('off') fig2 Второе изображение, имеющееся в scipy.datasets, исходно представлено в градациях серого и представляет собой двумерный массив (рис. П5.3). Рис. П5.3 Второе изображение, имеющееся в scipy.datasets В результате выполнения ячейки marimo: from scipy.datasets import ascent asc = ascent() fig3 = plt.figure(figsize=(4,3)) plt.imshow(asc, cmap='gray') plt.axis('off') print(f"{asc.shape=}, {asc.dtype=}") fig3 получим: asc.shape=(512, 512), asc.dtype=dtype('uint8') На массивах NumPy определены арифметические и логические операции, которые выполняются поэлементно. Создадим два массива и выполним с ними основные арифметические операции: a = np.array([10, 20, 30, 40, 50]) b = np.array([2, 3, 4, 5, 6]) a + b, a - b, a * b, a / b, a % b Результатом вычислений является: ((array([12, 23, 34, 45, 56]), array([ 8, 17, 26, 35, 44]), 340
array([ 20, 60, 120, 200, 300]), array([5., 6.66666667, 7.5, 8., 8.33333333]), array([5, 6, 7, 8, 8], dtype=int32), array([0, 2, 2, 0, 2], dtype=int32)) Всё хорошо, когда число элементов в массивах совпадает, давайте попытаемся сложить два массива с различным числом элементов: try: np.array([1,2,3]) + np.array([4, 5]) except ValueError as ve: print(ve) При выполнении фрагмента будет перехвачена исключительная ситуация: operands could not be broadcast together with shapes (3,) (2,) Укладку (broadcasting) мы обсудим ниже. Результатом операций сравнения являются булевы массивы: a = np.array([2,3,4,1]) b = np.array([1,2,3,4]) a > b, a<=b, a==b Результат выполнения операций сравнения: (array([ True, True, True, False]), array([False, False, False, True]), array([False, False, False, False])) Сравнивать можно и со скалярными значениями: a>=3 В результате получим: array([False, True, True, False]) Доступ к элементам массива осуществляется, также как и в «чистом» Python, с помощью индексации. Для массива a индексация его элементов a[0], a[1], a[-1], a[-2] даст следующий результат: (2, 3, 1, 4) Элементы массива индексируются как от его начала a[0], так c конца [-1]. Доступ к элементам массива может осуществляться не только к одному, но и к последовательности элементов, для этого массиву необходимо передать либо последовательность индексов, либо массив, содержащий целочисленные элементы: (a[[1,1, 0, 0, -1, -1]], a[np.array([1,1, 0, 0, -1, -1], dtype=np.int16)]) Результат: (array([3, 3, 2, 2, 1, 1]), array([3, 3, 2, 2, 1, 1])) Обязательно необходимо следить, чтобы индексы не выходили за допустимые пределы, иначе будет возбуждена исключительная ситуация IndexError: try: er = a[100] except IndexError as ie: print(ie) Это приведёт к распечатке: index 100 is out of bounds for axis 0 with size 4 Массив можно индексировать логической последовательностью или логическим массивом, но в этом случае размерности исходного и логического массивов должны совпадать. 341
a[[True, False, True, False]] Результатом будет массив, для которого индексы равны True: array([2, 4]) Это очень полезная возможность, позволяющая в сочетании с логическими операциями отбирать элементы массива, удовлетворяющие некоторому условию, например, a[a>2] Результатом будет: array([3, 4]) Данная возможность широко используется при работе с массивами NumPy. При работе с многомерными массивами есть одна небольшая особенность, поясним это на примере. Создадим список: a2=[[1,2,3], [4,5,6]] a2[1], a[1][1] В первом случае мы получим строку массива, а во втором элемент: ([4, 5, 6], 5) Если же вместо списков работать с массивами, то возможна индексация, принятая в других языках программирования, например, Java и C#: a2a= np.array(a2) a2a[1], a2a[1][1], a2a[1, 1] Мы получим: (array([4, 5, 6]), 5, 5) При работе с последовательностями выражение a2[1, 1] возбудит исключительную ситуацию Type Error и сообщение (индексы списка должны быть целыми числами или списками, а не кортежами). Точно так же, как и с последовательностями, мы можем работать со срезами (slices). Напомним, что срезом называется часть массива, определяемая начальным и конечным индексами и, возможно, шагом, например, a[0:2], a[:2], a[1:3], a[1:-1], a[1:], a[:] В результате мы получим ответ, нуждающийся в пояснении: (array([2, array([2, array([3, array([3, array([3, array([2, 3]), 3]), 4]), 4]), 4, 1]), 3, 4, 1])) В первом случае мы получим срез, включающий нулевой и первый элементы. В Python последний индексируемый элемент не включается в срез. Начальный нулевой индекс перед двоеточием можно опустить. Для третьего примера a[1:3] срез начинается с элемента a[1], последним включаемым элементом является a[2], а a[3] в срез не включается. В срезах можно указывать элементы с отрицательными индексами: так, в срез a[1:-1] включаются элементы, начиная с a[1] и заканчивая предпоследним элементом, последний элемент a[-1] в срез не включается! Для включения последнего элемента в срез его индекс не надо кодировать: a[1:]. Срез, совпадающий со всем массивом, кодируется так: a[:]. Срезы можно использовать как слева, так и справа от символа присваивания, например: a = np.array([1,2,3,4,5,6,7,8,9,10]) a[1:3] = a[-2:] 342
a, a[1:3], a[-2:] В результате получим: array([ 1, 9, 10, 4, 5, 6, 7, 8, 9, 10]) Продемонстрируем работу со срезами на примере двумерных и трёхмерных массивов. Верхняя правая четвёртая часть изображения енота отображается следующим образом (рис. П5.4): fc = face() h, w, c = fc.shape fig4 = plt.figure(figsize=(3,2)) plt.imshow(fc[:h//2, w//2:, :]) fig4 Рис. П5.4 Верхняя правая часть изображения енота В срезе можно задать шаг, например, каждую десятую строку и столбец массива. Для этого достаточно указать шаг после второго двоеточия в срезе (рис. П5.5). Рис. П5.5 Демонстрируем каждую десятую строку и столбец изображения енота 343
fig5 = plt.figure(figsize=(3,2)) plt.imshow(fc[::10, ::10, :]) fig5 Мы специально задали такой большой шаг, чтобы продемонстрировать пиксельную структуру изображения. Нам никто не мешает указать отрицательный шаг, что является удобным приёмом отражения изображения относительно вертикальной и/или горизонтальной осей координат (рис. П5.6): Рис. П5.6 Демонстрируем строки и столбцы изображения в обратном порядке через одну До сих пор мы требовали, чтобы структура массивов или срезов слева или справа от символа присваивания была одинаковой, но это требование можно ослабить. Достаточно, чтобы совпадали последние по порядку размерности или присваивалась скалярная величина. Это называется укладкой (broadcasting). Поясним это на примерах, рассмотрев допустимые присваивания: a1 = np.zeros((2,3)) a2 = np.zeros((2,4)) a3 = np.zeros((2,2)) a4 = np.array([1,2,3]) a5 = np.array([4,5,6,7]) a1[:,:] = a4 a2[:,:] = a5 a3[:,:] = 42 a1, a2, a3 Мы получим следующий результат: (array([[1., 2., 3.], [1., 2., 3.]]), array([[4., 5., 6., 7.], [4., 5., 6., 7.]]), array([[42., 42.], [42., 42.]])) 344
Кстати, проанализируем часто встречающуюся ошибку. После присваивания a42 = 42 значением a42 станет целое число 42. Для того чтобы присвоить 42 всем элементам массива, нужно сделать так, как это делалось ранее: a42[:,:] = 42 Давайте теперь нарисуем сетку с помощью срезов и без единого цикла (рис. П5.7): mesh = np.ones((501,601)) mesh[::20, :] = 0 # горизонтальные линии mesh[:, ::20] = 0 # вертикальные линии fig7 = plt.figure(figsize=(4,3)) plt.imshow(mesh, cmap='gray') plt.axis('off') fig7 Рис. П5.7 Рисуем сетку с помощью срезов Присваивая один массив другому, мы не создаём копию массива, новый массив ссылается на содержимое присвоенного массива, действительно: a = np.array([1,2,3,4]) b = a a[0] = 100 a, b В результате получим: (array([100, 2, 3, 4]), array([100, 2, 3, 4])) Чтобы работать с различными массивами, необходимо явно создать копию: a = np.array([1,2,3,4]) b = a.copy() 345
a[0] = 100 a, b Теперь получаем ожидаемый результат: (array([100, 2, 3, 4]), array([1, 2, 3, 4])) Рассмотрим более сложный пример, превратив изображение енота в градациях серого в чёрно-белое. Сначала определим, чему соответствует чёрный и белый цвета в изображении енота, а также укажем порог (threshold) для преобразования в чёрное и белое: def to_bw(img, threshold=127, figsize=(4,3), axis=False): BLACK, WHITE = 0, 255 img[img>threshold] = WHITE img[img<=threshold] = BLACK fig = plt.figure() if figsize is None \ else plt.figure(figsize=figsize) plt.imshow(fg, cmap="gray") if not axis: plt.axis("off") return fig to_bw(fg) Преобразование изображение енота в чёрно-белое для порога 127 представлено на рисунке П5.8. Рис. П5.8 Чёрно-белое изображение енота С эстетической стороны получилось у нас не слишком хорошо, поэтому мы вернёмся к этой задаче в конце данного приложения, используя средства экосистемы Python. Здесь для нас важно, что все преобразования выполнены средствами NumPy, а значит быстро. Несколько слов необходимо сказать о том, как хранятся массивы NumPy в оперативной памяти. Благодаря тому, что все элементы массива одинакового типа и размер их одинаков, то расположены они непосредственно друг за другом, 346
доступ к элементам массива осуществляется быстро. Но кроме того, для каждого массива в оперативной памяти хранится дополнительная информация о типе элементов, измерениях, количестве элементов. Это позволяет быстро и просто изменять размерности массивов. a = np.linspace(1, 20, 20, dtype= np.uint8) a.shape = 4, 5 a Здесь мы ничего не делали с содержимым массивов, но вот информацию о его размерности изменили, превратив одномерный массив в двумерный: array([[ 1, 2, 3, 4, 5], [ 6, 7, 8, 9, 10], [11, 12, 13, 14, 15], [16, 17, 18, 19, 20]], dtype=uint8) То же можно сделать с помощью метода reshape, но при этом создаётся новая копия исходного массива: b = a.reshape(5,4) a.shape, b.shape Мы получили: ((4, 5), (5, 4)) При изменении размерностей необходимо использовать допустимые значения, иначе возбуждается исключительная ситуация ValueError: try: c = a.reshape(20,3) except ValueError as ve: print(ve) Результатом выполнения ячейки станет сообщение: cannot reshape array of size 20 into shape (20,3) (невозможно преобразовать массива к размерности (20,3)) Превращение двумерного массива в одномерный можно осуществить несколькими способами: a.shape=20, c, d, e = b.reshape(20), b.reshape(1, 20), b.flatten() a.shape, c.shape, d.shape, e.shape Во-первых, изменить размерность можно присваивая атрибуту shape новое значение, во-вторых, используя метод reshape и создав копию массива с новой размерностью. Обращаем внимание, что размерности (1, 20) и (20,) суть разные: в первом случае у нас двумерный массив с одной строкой и двадцатью столбцами, а во втором — одномерный массив. Наконец, преобразовать многомерный массив в одномерный с созданием копии можно с помощью метода flatten. Метод ravel превращает многомерный массив в одномерный без создания его копии. В результате выполнения ячейки мы получим: ((20,), (20,), (1, 20), (20,)) Массивы NumPy можно объединять по горизонтали и по вертикали, создавая при этом новые массивы. Поясним это на примерах. a, b = np.linspace(1,4,4), np.linspace(5,8,4) c = np.hstack([a, b]) d = np.vstack([a, b, a]) c, c.shape, d, d.shape 347
Функциям hstack и vstack, объединяющим массивы по горизонтали и вертикали, передаются последовательности массивов. В результате мы получим: (array([1., 2., 3., 4., 5., 6., 7., 8.]), (8,), array([[1., 2., 3., 4.], [5., 6., 7., 8.], [1., 2., 3., 4.]]), (3, 4)) Размерности массивов по измерению, по которому выполняется объединение, должны совпадать, в противном случае возбуждается исключительная ситуация ValueError: a, b = np.array([1,2]), np.array([3,4,5]) try: c = np.vstack([a,b]) except ValueError as ve: print(ve) В результате получим сообщение: all the input array dimensions for the concatenation axis must match exactly, but along dimension 1, the array at index 0 has size 2 and the array at index 1 has size 3 (все размеры входного массива для оси объединения должны точно совпадать, но по размеру 1 массив с индексом 0 имеет размер 2, а массив с индексом 1 - размер 3) В NumPy встроены константы, а также большое число так называемых универсальных функций. Под универсальными понимаются функции, выполняющие как действия над скалярными величинами, так и поэлементные операции над массивами. Всё это делает универсальные функции простым и удобным инструментом и при решении научно-технических задач. Удобнее пользоваться ими, а не функциями модулей стандартной библиотеки math или cmath. В этой главе мы ограничимся рассмотрением подмножества встроенных универсальных функций, но начнём всё-таки с констант: np.e, np.pi, np.inf, np.nan: Здесь наряду с числом Эйлера и отношением длины окружности к её диаметру мы указали бесконечность и not a number (не число). Результатом выполнения является: (2.718281828459045, 3.141592653589793, inf, nan) Бесконечность можно использовать в арифметических операциях, например, 1/np.inf даёт ноль. Не число (np.nan) оказалось очень удобным средством для работы с пропущенными данными. Массив можно проверить на присутствие в нем бесконечности и np.nan, например, q = np.array((np.e, np.pi, np.inf,-np.inf, np.nan)) np.isinf(q), np.isnan(q) даст следующий результат: (array([False, False, True, True, False]), array([False, False, False, False, True])) Используя эти функции, можно «очистить» массив от бесконечных и нечисловых значений: q[(~np.isnan(q))&(~np.isinf(q))] 348
Операция ~ — представляет собой отрицание, & — логическое умножение (операцию AND), а | — логическое сложение (операцию OR). Перечисленные операции часто используются при работе с массивами. Круглые скобки здесь используются, чтобы не запоминать приоритеты логических операций и операций сравнения. При выполнении рассмотренной выше строки кода c массивами NumPy получим: array([2.71828183, 3.14159265]) Далее мы рассмотрим только некоторые часто используемые встроенные в NumPy универсальные функции, с полным перечнем встроенных функций можно познакомиться в документации, опубликованной в Интернете. Если известно имя функции, то информацию об её использовании можно получить двумя путями. Первый путь состоит выводе строки документации функции, например, print(np.heaviside.__doc__) Ниже приводится только небольшая часть документации по функции Хевисайда: heaviside(x1, x2, /, out=None, *, ing='same_kind', order='K', dtype=None, ture, extobj]) where=True, subok=True[, Compute the Heaviside step function. … Можно также вызвать функцию help: help(np.heaviside) Прочитав документацию, нарисуем график функции (рис. П5.9): Рис. П5.9 Функция Хевисайда xf = np.linspace(-5, 5, 10000) yf = np.heaviside(xf, 1) fig8 = plt.figure(figsize=(3,3)) plt.plot(xf, yf, 'k-', lw=3) 349 castsigna-
plt.xlabel('x', fontsize=14) plt.ylabel('y = np.heaviside(x, 1)', fontsize=14) fig8 Документацию по константам, функциям и методам NumPy можно легко найти в Интернете. Если же имя функции неизвестно, то вопрос необходимо адресовать либо поисковой системе общего назначения, например, Google, либо специализированной, например, StackOverflow. В последние годы популярность приобрели системы ИИ, например, Qwen (https://www.chat.qwen.ai/). Набор элементарных функций NumPy достаточно широк, наряду с натуральным логарифмом np.log, можно вычислить логарифм по основанию 2 (np.log2) и по основанию 10 (np.log10). np.log10(np.array([0,1, 2, 10])) выдаст результат: array([ -inf, 0. , 0.30103, 1. ]) В этот набор входят тригонометрические, обратные тригонометрические и гиперболические функции, например, выполнение ячейки np.arctan2(1,0), np.arctan2(1,1), np.arctan2(0,1) даст результат: (1.5707963267948966, 0.7853981633974483, 0.0) Для округления имеется несколько функций, работу с которыми поясним примером: d = np.array([-3.81, -2.5123456, -1.389, -0.62345, 0.3, 1.2, 2.7]) np.round(d, 0), np.floor(d), np.ceil(d), np.trunc(d) Результат выполнения ячейки приведён ниже: (array([-4., array([-4., array([-3., array([-3., -3., -3., -2., -2., -1., -2., -1., -1., -1., -1., -0., -0., 0., 0., 1., 0., 1., 1., 2., 1., 3.]), 2.]), 3.]), 2.])) Функции mod передаются два аргумента, возвращает она остаток от деления первого аргумента на второй: (np.mod(np.array([7, 12, 3, -5]),np.array([2,2,2,2])), np.mod(np.array([7, 12, 3, -5]),2), np.mod(7, np.array([2,3,4]))) Результат выполнения: (array([1, 0, 1, 1], dtype=int32), array([1, 0, 1, 1], dtype=int32), array([1, 1, 3], dtype=int32)) На рисунке П5.10 приведены графики нескольких функций, встроенных в NumPy. Здесь np.sin — синус, np.sign возвращает -1, если аргумент, переданный функции, меньше 0 и 1 при положительном аргументе: def numpy_funcs(): n = 1000 x = np.linspace(0, 3*np.pi,n) y = np.sin(x) fig = plt.figure(figsize=(6,4)) plt.plot(x, y, 'k-', label='sin(x)', lw=3) plt.plot(x, np.sign(y),'k--', label='sign(sin(x))', lw=3) plt.plot(x, np.heaviside(y,0), 'k:', 350
label='heaviside(sin(x), 0)', lw=3) plt.legend() plt.xlabel('x', fontsize=14) plt.ylabel('y(x)', fontsize=14) return fig, x, y fig9, xh, yh = numpy_funcs() fig9 Функция np.sort позволяет сортировать массивы. Например, выполним сортировку массива из предыдущего примера (сортировка осуществляется в порядке возрастания): yh_sorted = np.sort(yh) yh[0], y[-1], yh_sorted[0], yh_sorted[-1] Рис. П5.10 Графики функций np.sin, np.sign и np.heaviside Результат выполнения ячейки: ((0.0, 3.6739403974420594e-16), (-0.999988874475714, 0.999988874475714)) Функция np.unique позволяет удалить из массива повторяющиеся элементы, оставляя уникальные: s = np.array([4,5,2, 6, 5, 2, 4, 3,2,1]) s = np.sort(s) s, np.unique(s) Результат выполнения; (array([1, 2, 2, 2, 3, 4, 4, 5, 5, 6]), array([1, 2, 3, 4, 5, 6])) Теперь обратимся к функциям, принимающим массивы, а возвращающим скалярные значения. К ним относятся np.min, np.max, np.argmin, np.argmax. Если с первыми двумя функциями всё понятно, то две последние возвращают индексы 351
минимального и максимального элементов. Применим их к массиву yh из рассмотренного выше примера: np.min(yh), np.max(yh),np.argmin(yh), np.argmax(yh) Возвращает результат: (-0.999988874475714, 0.999988874475714, 499, 166) Функция np.any возвращает True, если хотя бы один элемент массива может быть преобразован в True. Напомним, что в True в Python преобразуется всё за исключением 0, None, пустой строки, пустых списка, словаря или кортежа. Если это не так, то функция np.any возвращает False. Функция np.all() возвращает True только тогда, когда все элементы массива преобразуются в True, иначе она возвращает False. np.any(yh), np.all(yh) даёт результат: (True, False) Функции np.sum и np.prod позволяют вычислить сумму и произведение элементов массива, a np.mean, np.median, np.std предназначены для определения среднего значения, медианы и среднего квадратичного отклонения для элементов массива. Чтобы проиллюстрировать работу последних трёх функций, сгенерируем массив из 100000 нормально распределённых случайных чисел: n = 100_000 r = np.random.normal(0, 1, n) np.mean(r), np.median(r), np.std(r) Мы получили следующие значения статистических характеристик: (0.0013454232036479651, 0.0028219078696115497, 0.9969168980897677) Естественно, при других запусках примера мы получим чуть-чуть отличающиеся значения, как это всегда имеет место при использовании случайных чисел. Иногда бывает полезным получить не единственную сумму или произведение элементов массива, а массивы накопленных сумм и произведений. Сделать это можно с помощью функций np.cumsum и np.cumprod: ac = np.array([1,2,3,4,5]) np.cumsum(ac), np.cumprod(ac) Мы получим два массива накопленных сумм и произведений: (array([ 1, 3, 6, 10, 15], dtype=int32), array([ 1, 2, 6, 24, 120], dtype=int32)) При работе с NumPy мы старались обойтись без применения циклов. Ниже мы покажем приём, позволяющий построить двумерные массивы всех возможных сочетаний значений двух одномерных массивов. Этот приём широко используется при визуализации функций двух переменных. Для этого имеется функция NumPy np.meshgrid. nx, ny = 5, 6 x, y = np.linspace(0, 1, nx), np.linspace(0, 1, ny) X, Y = np.meshgrid(x, y) Z = X**2 + Y**2 x, y, X, Y, Z Выполнив ячейку, получим массивы: (array([0. , 0.25, 0.5 , 0.75, 1. ]), array([0. , 0.2, 0.4, 0.6, 0.8, 1. ]), 352
array([[0. , 0.25, 0.5 , 0.75, 1. ], [0. , 0.25, 0.5 , 0.75, 1. ], [0. , 0.25, 0.5 , 0.75, 1. ], [0. , 0.25, 0.5 , 0.75, 1. ], [0. , 0.25, 0.5 , 0.75, 1. ], [0. , 0.25, 0.5 , 0.75, 1. ]]), array([[0. , 0. , 0. , 0. , 0. ], [0.2, 0.2, 0.2, 0.2, 0.2], [0.4, 0.4, 0.4, 0.4, 0.4], [0.6, 0.6, 0.6, 0.6, 0.6], [0.8, 0.8, 0.8, 0.8, 0.8], [1. , 1. , 1. , 1. , 1. ]]), array([[0. , 0.0625, 0.25 , 0.5625, 1. [0.04 , 0.1025, 0.29 , 0.6025, 1.04 [0.16 , 0.2225, 0.41 , 0.7225, 1.16 [0.36 , 0.4225, 0.61 , 0.9225, 1.36 [0.64 , 0.7025, 0.89 , 1.2025, 1.64 [1. , 1.0625, 1.25 , 1.5625, 2. ], ], ], ], ], ]])) Визуализировать функцию двух переменных, заданную на регулярной сетке, можно с помощью функции plot_surface matplotlib (рис. П5.11): Рис. П5.11 Визуализация функции двух переменных на прямоугольной сетке fig10 = plt.figure(figsize=(14,3)) ax = plt.subplot(111, projection='3d') cb = ax.plot_surface(X, Y, Z, cmap='rainbow') ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_zlabel('Z') plt.colorbar(cb) fig10 353
На основе встроенных универсальных функций NumPy можно писать свои универсальные функции, которые будут работать как со скалярными данными, так и с массивами, передаваемыми им в качестве аргументов. Так будет, пока в функции не встречаются условные выражения. Рассмотрим пример функции на «чистом» Python, возвращающей прямоугольные импульсы с единичной амплитудой и периодом T. def imp1(t, T=2.): ts = t % T amp = 1. if ts<=T/2. else 0. return amp imp1(0.), imp1(1.5), imp1(10.4) Для скалярных значений аргументов всё в порядке: (1.0, 0.0, 1.0) При передаче функции массива в качестве первого аргумента возбуждается исключительная ситуация ValueError. Сообщение об ошибке гласит: «The truth value of an array with more than one element is ambiguous. Use a.any() or a.all() (Истинностное значение массива с более чем одним элементом неоднозначно. Используйте a.any() или a.all())». Нам нужен эквивалент условного оператора «чистого» Python. Эту задачу решает функция NumPy np.where. Ей передаются три аргумента. Первый аргумент — массив, содержащий логические значения, второй аргумент — массив, элемент которого возвращается, если соответствующий элемент первого элемента равен True, третий аргумент — массив, элементы которого возвращаются, если соответствующие элементы первого массива False. В качестве второго и/или третьего аргумента можно передавать скалярные значения. Воспользуемся этим, чтобы переписать функцию imp1 для работы с массивами (рис. П5.12). Рис. П5.12 График функции imp2 354
def imp2(t, T=2.): ts = t % T return np.where(ts<=T/2, 1., 0.) tw = np.linspace(-2, 5, 1000) yw = imp2(tw) fig11 = plt.figure(figsize=(3,3)) plt.plot(tw, yw, 'k-', lw=3) plt.xlabel('t', fontsize=14) plt.ylabel('y(t)', fontsize=14) fig11 Заметим, что функции imp2 можно передавать как массивы, так и скалярные значения. Единственно, что необходимо иметь в виду, что при передаче скалярного значения функция возвращает массив с единственным элементом. В NumPy отсутствуют средства, которые позволили бы нам получить чёрнобелое изображение енота в более или менее приличном виде, как это делалось в газетах пятьдесят лет назад. Поиск в Интернете практически сразу указывает на библиотеку PIL — Python Imaging Library, основной задачей которой является обработка изображений. Мы могли бы использовать для преобразования только средства PIL, но поступим так, как это делается при решении практически всех научно-технических задач в экосистеме Python. У нас есть массив NumPy с изображением, нам его необходимо преобразовать в объект изображения PIL, осуществить преобразование цветного изображения в чёрно-белое. Такое преобразование называется дизеринг и осуществляется с помощью алгоритма Floyd-Steinberg [78]. Для этого имеются готовые инструменты, разобраться с их использованием можно в течение пяти минут: from PIL import Image # создаем изображение PIL из массива NumPy im = Image.fromarray(f) # преобразуем изображение PIL image в "газетное" fp = im.convert(mode='1',dither=Image.FLOYDSTEINBERG) # создаем массив NumPy из изображения PIL fpa = np.array(fp) # визуализируем преобразованное "газетное" изображение fig12 = plt.figure(figsize=(4, 5)) plt.imshow(fpa, cmap='gray') plt.axis('off') print(f"{fpa.shape=}, {fpa.dtype=}") fig12 Предпоследняя строка примера демонстрирует, что преобразованный массив содержит 1024 строки и 768 столбцов и содержит булевы значения True и False: fpa.shape=(768, 1024), fpa.dtype=dtype('bool') Преобразованное изображение представлено на рисунке П5.13 Сравнение рисунков П5.8 и П5.13 показывает, что не всегда простое решение даёт приемлемый результат. Чтобы показать, что рисунок П5.13 действительно состоит из чёрных и белых пикселов, а не градаций серого, визуализируем срез массива fpa с носом енота (рис. П5.13): ff = fpa[400:500, 550:650] 355
fig13 = plt.figure(figsize=(3,3)) plt.imshow(ff, cmap='gray') fig13 На данном рисунке хорошо видно, что высокое качество преобразования цветного изображения в чёрно-белое (уменьшение цветного разрешения) обеспечивается за счёт перераспределения плотности чёрных и белых пикселов, как в газете «Правда» 70 лет назад. Рис. П5.13 Преобразование цветного изображения енота в чёрно-белое с помощью алгоритма Floyd-Steinberg Задания читателям 1. Воспроизведите все выкладки приложения в Jupyter Notebook, JupyterLab или marimo. 2. Напишите преобразование изображения енота в чёрно-белое изображение на «чистом» Python и сравните время выполнения с решением, предложенным в этой главе. 3. Чем отличаются массивы NumPy от последовательностей «чистого» Python? 4. Как создать массив NumPy из списка Python? 5. Как выяснить объём оперативной памяти, занимаемой массивом NumPy? 6. Как связаны массивы NumPy с изображениями? Какие типы элементов массивов желательно использовать при работе с изображениями? 7. Что такое срезы, зачем они используются при работе с массивами NumPy? 8. Что такое укладывание? Приведите примеры. 9. Как получить негатив изображения в градациях серого, заменив чёрный цвет на белый, а белый на чёрный? 356
Приложение 6 Анимация Наше восприятие ограничено тремя измерениями, поэтому при визуализации функций от большего числа переменных приходится прибегать к разным искусственным приёмам, включая цвет, размер маркеров, построение набора рисунков, визуализирующих зависимости от меньшего числа параметров. Анимация даёт нам возможность использовать ещё одно измерение — время. Для анимации нужно создавать рисунки-кадры, которые мало отличаются друг от друга, и отображать их пользователю через равные промежутки времени, которые не должные превышать 0,1 с. В противном случае мелькание кадров будет заметно. То же самое будет, если соседние кадры в анимации сильно отличаются. Естественно, было бы желательно совместить создание анимации с её демонстрацией зрителям в реальном времени, но для этого необходимо, чтобы время формирование кадра на сервере (мы работаем с веб-приложением Jupyter или marimo), передача по сети пользователю, отображение в браузере гарантированно занимало менее одной десятой секунды. Именно поэтому мы разделим процесс на части, включающие в себя формирование анимации на сервере, передачу сформированной анимации в браузер и последующую демонстрацию анимации в веб-странице. При формировании анимации необходимо соблюдать простые правила: последовательные кадры должны незначительно отличаться друг от друга, размеры кадров и масштабы рисунков не должны меняться. Matplotlib по умолчанию автоматически масштабирует свои рисунки, так что масштабы необходимо принудительно задавать одинаковыми для каждого кадра. Ещё одной особенностью matplotlib является «накопительный» принцип отображения графиков: результаты вызовов функции plot() накладываются друг на друга, что позволяет отображать несколько графиков на одном рисунке, но может привести к нежелательным последствиям при отображении анимации. Стирать предыдущие кадры можно с помощью функции clf() — в этом случае стирается всё, что нарисовано на холсте, или cla(), что приводит к стиранию содержимого текущего рисунка. Мы будем поступать так, как делали художники-аниматоры в докомпьютерную эру, когда сначала на обычной плотной бумаге рисовался задник — неизменяемый рисунок, используемый в последовательности кадров. Каждый кадр рисовался на прозрачной плёнке, накладывался на задник, после чего фотографировался. Сама анимация — это последовательность кадров. Эту последовательность действий мы будем имитировать с помощью стирания предыдущего кадра и рисования следующего кадра. Можно с помощью средств matplotlib не рисовать каждый раз весь кадр, а перерисовывать только изменяющиеся объекты. 357
Наиболее просто анимация формируется с помощью библиотеки gif97. Для этого нужно только написать функцию, например, play, которой передаётся один параметр — номер кадра, задать число формируемых кадров nf и путь к файлу path_to_gif_anim98, в котором будет сохранена анимация. Функция должна иметь декоратор: @gif.frame Следующим шагом должно быть создание последовательности кадров анимации: frames = [play(i) for i in range(nf)] Далее необходимо сохранить сгенерированную gif-анимацию, указав задержку между кадрами в миллисекундах (в примере — 50 мс). gif.save(frames, path_to_gif_anim, duration=50) Рассмотренный подход имеет ряд ограничений: во-первых, gif достаточно старый формат, его цветовое разрешение ограничено 256 цветами, во-вторых, каждый кадр сжимается отдельно и не учитывается то, что кадры незначительно отличаются друг от друга. Кроме того, поддерживается только matplotlib, altair и plotly не поддерживаются, но для наших целей этого вполне достаточно. Создание gif-анимации включает в себя следующие шаги: Пишем функцию, которая отображает i-ый (очередной) шаг анимации. Ещё раз напоминаем, что в данном виде gif-анимация работает только с библиотекой matplotlib. Функции передаётся единственный параметр — номер кадра. Если функции необходимо передать функции дополнительные данные, то мы их задаём в той же ячейке. В Python это называется замыканием, данные будут доступны функции при формировании анимации. В качестве примера (a06_01.py) мы создадим анимацию игры «Жизнь»99. Подробно эта игра и построение анимации только средствами matplotlib рассмотрены в Главе 7 «Клеточные автоматы» книги100. Исходный текст ячейки marimo приводится ниже: import numpy as np import matplotlib.pyplot as plt from scipy.signal import convolve # свертка # определяем переменные, необходимые # для функции, реализующей шаг анимации mask = np.array( [[1, 1, 1], [1, 0, 1], [1, 1, 1]], dtype=bool) # окружение ячейки (окрестность) nx = 200 # размерность поля extended_field = np.zeros((nx + 2, nx + 2)) # расширенное поле field = extended_field[1:-1, 1:-1] # поле игры # начальная расстановка живых элементов игры field[0, :] = field[-1, :] = 1 field[:, 0] = field[:, -1] = 1 97 gif [Электронный ресурс]. — URL: https://pypi.org/project/gif/ (дата обращения 19.04.2026). Расширением этого файла должно быть .gif. 99 Во что играл британский математик Джон Конвей. — URL: https://nplus1.ru/material/2020/04/16/on-conway (дата обращения 19.04.2026). 100 Очков, В. Ф. Math CAD и Python: обучение по технологии STEM / В. Ф. Очков, А. И. Тихонов. — Санкт-Петербург : Лань, 2025. — 472 с. — ISBN 978-5-507-52311-5. 98 358
# шаг игры жизнь def step_of_game(i): # замыкание на торе extended_field[-1, :] = extended_field[1, :] extended_field[0, :] = extended_field[-2, :] extended_field[:, -1] = extended_field[:, 1] extended_field[:, 0] = extended_field[:, -2] # число соседей каждой яейки c = convolve(extended_field, mask, method="direct", mode="valid") # правила рождения и смерти элемента игры birth_confition = (field == 0) & (c == 3) dyeing_condition = (field == 1) & ((c < 2) | (c > 3)) field[birth_confition] = 1 field[dyeing_condition] = 0 fig = plt.figure(figsize=(3, 3)) plt.imshow(field, cmap="binary") plt.axis("off") return fig step_of_game(0) Здесь сначала импортируются все необходимые библиотеки, инициализируется игровое поле, все переменные, которые будет использоваться при вычислении распределения элементов игры на очередном шаге. Также задаётся начальное распределение «живых» элементов игры. В функции step_of_game сначала формируется распределение элементов на расширенном игровом поле с помощью замыкания на торе, когда последние элементы расширенного поля становятся равными первому, а нулевые — предпоследним. Это упрощает формулировку правил игры. Далее с помощью свёртки вычисляется число соседей у каждой ячейки поля и формируются условия рождения и умирания элементов игры. Поле игры на очередном шаге отображается с помощью функции matplotlib imshow. Функция возвращает объект холста (figure) matplotlib. Это необязательно для построения анимации, но полезно для отладки, в частности, на рисунке П6.1 отображено чёрным начальное распределение «живых» элементов игры. Создание и сохранение gif-анимации осуществляется с помощью функции build_gif_animation_py: from anim import build_gif_animation_py # параметры анимации nframes = 200 # число кадров анимации duration = 50 # длительность отображения кадра анимации fn = 'public/life4.gif' # относительный путь к # сохраненному файлу анимации life_animation = build_gif_animation_py(step_of_game, nframes, duration,fn) life_animation 359
Рис. П6.1 Начальное распределение «живых» элементов игры «Жизнь» Функция импортируется из модуля Python anim.py, расположенного в той же папке, что и блокнот с анимацией. Функции передаются: функция, формирующая очередной кадр анимации, в нашем случае — step_of_game; число кадров анимации, продолжительность демонстрации кадра анимации пользователю, путь к файлу, где сохраняется анимация. Мы по рекомендации разработчиков marimo сохраняем анимацию в папке public с тем, чтобы можно было отображать их в блокнотах marimo непосредственно из Markdown. Функция, кроме сохранения файла анимации, возвращает объект mo.image, который можно отобразить в ячейке блокнота (рис. П6.2). Функция устроена так, что отображает процесс построения анимации, что бывает полезно, если в анимации большое число кадров. Пример отображения состояния анимации приведено на рисунке П6.3. Рис. П6.2 Кадр анимации игры «Жизнь» 360
Рис. П6.3 Отображение процесса построения анимации Приведём исходный код импортированной функции построения и сохранения анимации (модуль anim.py): import marimo as mo import gif def build_gif_animation_py(f, nframes, duration=50, fn="public/anim.gif"): """ Построение анимации: f- функция, формирующая с помощью matplotlib кадр анимации, функции передается номер формируемого кадра. duration - время демонстрации кадра анимации в мс. nframes - число кадров в анимации. fn - путь к файлу с сохраненной анимацией. """ @gif.frame def play(i): f(i) # формирование кадра анимации frames = [] # последовательность кадров анимации # формируем последовательность кадров with mo.status.progress_bar( total=nframes, remove_on_exit=True, title="Строим последовательность кадров", completion_title="Построили", ) as bar: for i in range(nframes): frames.append(play(i)) bar.update() # сохраняем анимацию with mo.status.spinner(title="Сохраняем анимацию...") as _spinner: gif.save(frames, fn, duration=duration) md_sp = mo.md("**Сохранение gif-анимации успешно завершено**") _spinner.update("Почти сохранено...") return mo.image(fn) В ней внутренняя функция play создана для того, чтобы декорировать функцию, формирующую кадр анимации, т. к. при создании анимации достаточно часто забываешь задекорировать эту функцию. 361
Создание анимации разбивается на два этапа. На первом этапе создаётся последовательность кадров анимации с помощью функции play. Обрамление цикла построения анимации менеджером контекста позволяет отобразить состояние процесса (см. рис. П6.3). Последовательность кадров анимации frames передаётся функции gif.save. В этом случае трудно предсказать время сохранения анимации, поэтому пользователю отображается спиннер. Задания читателям 1. Проделайте построения данного раздела в блокноте marimo. 2. Подготовьте анимацию фигур Лиссажу с параметрическим описанием 𝑥(𝑡) = sin(𝑎 ∙ 𝑡) , 𝑦(𝑡) = sin(𝑏 ∙ 𝑡), 0 ≤ 𝑡 ≤ 4𝜋. Коэффициенты 1.1 ≤ 𝑎 ≤ 3.5, 1.7 ≤ 𝑏 ≤ 5.3 изменяются в процессе анимации. Задайте законы изменения коэффициентов 𝑎, 𝑏. 3. Задание повышенной сложности. Для создания анимаций, сохраняемых в формате mp4, можно использовать класс FuncAnimation101 matplotlib. Оформите функцию сохранения анимации для работы в блокнотах marimo. Для сохранения анимаций, возможно, понадобится утилита FFmpeg102. 101 Создаем анимацию графиков. Классы FuncAnimation и ArtistAnimation. [Электронный ресурс]. — URL: https://proproprogs.ru/modules/matplotlib-sozdaem-animaciyu-grafikov--klassy-funcanimation-i-artistanimation (дата обращения 19.04.2026). 102 FFmpeg Installation Guide. [Электронный ресурс]. — URL: https://github.com/oop7/ffmpeg-install-guide (дата обращения 19.04.2026). 362
ЛИТЕРАТУРА 1. Очков, В. Ф. Как решать задачи, или Триалог о королях и капусте, решении задач, stem, steam и stream в образовании / В. Ф. Очков, А. И. Тихонов // Энергия: экономика, техника, экология. — 2024. — № 3–5. — URL: http://twtshell.ru/ochkov/EEE-3-2024.pdf, http://twtshell.ru/ochkov/EEE-4-2024-2.pdf, http://twtshell.ru/ochkov/EEE-5-2024-3.pdf. 2. Информационные технологии в инженерных расчетах: SMath и Python : учеб. пособие для вузов / В. Ф. Очков, К. А. Орлов, Ю. В. Чудова [и др.]. — Санкт-Петербург : Лань, 2023. — 212 с. — URL: http://www.twtshell.ru/ochkov/ EC-SMath.pdf. 3. Очков, В. Ф. Лев Толстой и математика / В. Ф. Очков, Н. А. Очкова. — 3-е изд., испр. и доп. — Москва : МПГУ, 2023. — 208 с. — URL : http://www.twtshell.ru/ochkov/Tolstoy-Math-3.pdf. 4. Кассинийский овал [Электронный ресурс]. — URL: https:// mathcurve.com/courbes2d.gb/cassini/cassini.shtml (дата обращения 25.05.2025). 5. Bunimovich Stadium [Электронный ресурс]. — URL: https:// blogs.ams.org/visualinsight/2016/11/15/bunimovich-stadium (дата обращения 25.05.2025). 6. Среднее Колмогорова [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Среднее_Колмогорова (дата обращения 24.04.2026). 7. Ochkov, V. Approximation of Generalized Ovals and Lemniscates towards Geometric Modeling / V. Ochkov, I. Vasileva, E. Borovinskaya [et al.] // Mathematics. — 2021. — 9. — 3325. — URL: https://doi.org/10.3390/math9243325 (русский авторизированный перевод: http://www.twtshell.ru/ochkov/Ovals.pdf). 8. Беллман, Р. Квазилинеаризация и нелинейные краевые задачи / Р. Беллман, Р. Калабаю — Москва : Мир, 1968. 9. Очков, В. Ф. Это страшное слово дифуры... / В. Ф. Очков, Е. П. Богомолова // Информатика в школе. — 2015. — № 1. — С. 55–58. — URL: http://www.twtshell.ru/ochkov/ODE.pdf. 10. Информационные технологии в инженерных расчетах: SMath & Python / В. Ф. Очков, А. И. Тихонов. — Санкт-Петербург : Лань, 2023. — URL: http://www.twtshell.ru/ochkov/EC-SMath.pdf. 11. Очков, В. Ф. Гибридное решение задач на компьютере / В. Ф. Очков, А. В. Бобряков, С. Н. Хорьков // Cloud of Science. — 2017. — Т. 4, № 2. — С. 5–26. — URL: http://www.twtshell.ru/ochkov/Hybrid.pdf. 12. URL: https://community.ptc.com/t5/Mathcad/Portrait-of-roots-of-two-equations/ m-p/776602. 13. Ochkov, V. Visualization in Mathematical Packages When Teaching with Information Technologies / V. Ochkov, I. Vasileva, K. Orlov [et al.] // Mathematics. — 2022. — 10. — 3413 с. — URL: https://www.mdpi.com/2227-7390/10/19/3413. 14. Крылов, А. Н. Мои воспоминания. — Санкт-Петербург : Политехника, 2014. — 510 с. 15. Тихонов, А. И. Научно-технические расчеты на Python. — Москва : Издательство МЭИ. 2020. — 316 с. 363
16. Бахвалов, Н. С. Численные методы / Н. С. Бахвалов, Н. П. Жидков, Г. М. Кобельков. — Москва : БИНОМ. Лаборатория знаний, 2015. — 637 с. 17. Баженов, А. Н. Простое введение в интервальный анализ без теорем и доказательств. — Москва : Издательство «ИКИ», 2025. — 344 с. 18. Щарый, С. П. Конечномерный интервальный анализ. — Новосибирск : Издательство «XYZ», 2020. — 645 с. 19. Propagation of uncertainty [Электронный ресурс]. — URL: https:// en.wikipedia.org/wiki/Propagation_of_uncertainty (дата обращения 20.06.2025). 20. Нормальное распределение [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Нормальное_распределение (дата обращения 23.04.2026). 21. Хамелеон в цифровых джунглях: пишем десктопное приложение для стеганографии на Python и PyQt6 [Электронный ресурс]. — URL: https://habr.com/ru/articles/938684/ (дата обращения 23.04.2026). 22. Сингх, С. Книга шифров: тайная история шифров и их расшифровки / Маймон Сингх — Москва : АСТ: Астрель. — 2009. — 447 с. 23. Соболева, Т. А. История шифровального дела в России. — Москва : ОЛМА-ПРЕСС, 2002. — 273 c. 24. Очков, В. Ф. Mathcad и криптография // Информатика в школе. — 2013. — № 10. 25. Очков, В. Ф. На компьютер надейся, а сам не плошай! / В. Ф. Очков, В. С. Дробилко // Информатика в школе. — 2012. — № 3 (76). — С. 62–63. 26. Очков, В. Ф. Три нелинейности маятника, или Улыбка фазового портрета / В. Ф. Очков, В. Л. Чудов, К. Писасич [и др.] // Cloud of Science. — 2020. — Т. 7. — С. 6–39. 27. Очков, В. Ф. Путешествие из Петербурга в Москву, или Свет в конце туннеля / В. Ф. Очков, К. Писасич // Информатика в школе. — 2015. — № 4. — С. 58–61. — URL: http://twtshell.ru/ochkov/Tunnel-maket.pdf. 28. Соловьев, В. Фазовый портрет/ В. Соловьев, С. Дворяшнов // Фазовый портрет // Квант. — 2019. — №1. — С. 29–34 [Электронный ресурс]. — URL: https://uni-protvino.ru/images/publications/pps_mien/math_p_68.pdf (дата обращения 22.04.2026). 29. Ризниченко, Г. Ю. Лекции по математическим моделям в биологии. ЛЕКЦИЯ 4. Модели, описываемые системами двух автономных дифференциальных уравнений [Электронный ресурс]. — URL: https://library.biophys.msu.ru/ LectMB/Lect04.htm (дата обращения 22.04.2026). 30. Математические модели динамики популяций: реализация на языке Python: учеб. пособие / Ш. Х. Зарипов, Т. В. Никоненкова, Г. И. Ложкин [и др.]. — Казань : Изд-во Казанского федерального университета, 2023. — 44 с. [Электронный ресурс]. — URL: https://kpfu.ru/staff_files/F1642759463/MathModelDynamicPopulation2023.pdf (дата обращения 22.04.2026). 31. Двойной маятник [Электронный ресурс]. — URL: https://math24.ru/двойной-маятник.html (дата обращения 22.04.2026). 32. Пасхавер, Б. Pandas в действии. — Санкт-Петербург : Питер, 2023. — 512 с. 33. Янссенс, Й. Python Polars: подробное руководство / Й. Янссенс, Т. Ньюдорп ; пер. с англ. А. Ю. Гинько. — Астана : Books.kz, 2025. — 502 с. 364
34. Очков, В. Ф. Интерполяция, экстраполяция, аппроксимация, или Ложь, наглая ложь и статистика / В. Ф. Очков, Е. П. Богомолова // Cloud of Science. — 2015. — T. 2. — № 1. — С. 61–88. 35. Очков, В. Ф. Цифровой двойник воды / В. Ф. Очков, К. А. Орлов // Энергия: экономика, техника, экология. — 2021. — № 10. — С. 18–22. 36. О М. К. Эшере. Познакомьтесь с одним из самых известных художников-графиков мира [Электронный ресурс]. — URL: https://mcescher.com/ (дата обращения 20.04.2026). 37. Избранные произведения М. К. Эшера [Электронный ресурс]. — URL: https://mcescher.com/gallery/ (дата обращения 20.04.2026). 38. Мауриц Корнелис Эшер [Электронный ресурс]. — URL: https://artchive.ru/ escher (дата обращения 20.04.2026). 39. «Невозможная архитектура» Маурица Эшера [Электронный ресурс]. — URL: https://dzen.ru/a/WbwAbUjIXmStbyop (дата обращения 20.04.2026). 40. Лошер, Ж. Л. Магия М. К. Эшера / Ж. Л. Лошер, В. Ф. Вельдхуизен // Арт-Родник, Taschen. — 2007. — 189 с. 41. Хофштадтер, Д. Гёдель, Эшер, Бах. — Самара : Бахрах-М, 2001. — 327 с. 42. Кривые Безье и Пикассо [Электронный ресурс]. — URL: https://habr.com/ ru/articles/344814/ (дата обращения 20.04.2026). 43. Scalable Vector Graphics (SVG) 2 [Электронный ресурс]. — URL: https://www.w3.org/TR/SVG2/ (дата обращения 20.04.2026). 44. Math and the Art of M. C. Escher [Электронный ресурс]. — URL: https://eschermath.org/ (дата обращения 20.04.2026). 45. Henderson, P. Functional Geometry. In Proceedings of the 1982 ACM Symposium on LISP and Functional Programming. — P. 179–187 [Электронный ресурс]. — URL: https://eprints.soton.ac.uk/257577/1/funcgeo2.pdf (дата обращения 20.04.2026). 46. Programming with Escher [Электронный ресурс]. — URL: https://mapio.github.io/programming-with-escher/ (дата обращения 20.04.2026). 47. Stevens J.-L., Bednar J. Square Limit [Электронный ресурс]. — URL: https://examples.holoviz.org/gallery/square_limit/square_limit.html (дата обращения 20.04.2026). 48. Scenic Escher [Электронный ресурс]. — URL: https://github.com/Arkham/scenic_escher (дата обращения 20.04.2026). 49. Gualandi, S. Ricorsioni grafiche nel mondo di Escher [Электронный ресурс]. — URL: https://colab.research.google.com/github/stegua/escher-2k24/blob/main/ RicorsioniGrafiche.ipynb#scrollTo=a1dxpQWJSBOm (дата обращения 20.04.2026). 50. Building Escher's Square Limit in Pixels [Электронный ресурс]. — URL: http://roy.red/posts/building-eschers-square-limit/ (дата обращения 20.04.2026). 51. The Escher School of Fish Workshop [Электронный ресурс]. — URL: https://github.com/einarwh/square-limit-blogpost (дата обращения 20.04.2026). 52. M. C. Escher and Tessellations [Электронный ресурс]. — URL: https://mathandart.com/blog/escher_and_tessellations/ (дата обращения 20.04.2026). 365
53. Functional Geometry: Takin’ it to the Square Limit… [Электронный ресурс]. — URL: https://jupiterresearch.wordpress.com/2011/12/09/functional-geometry-to-the-square-limit-and-beyond/ (дата обращения 20.04.2026). 54. Fong, С. Analytical Methods for Squaring the Disc [Электронный ресурс]. — URL: https://arxiv.org/abs/1509.06344 (дата обращения 20.04.2026). 55. Пономарев, В. В. Машинная графика : учеб. пособие. — Озерск : ОТИ МИФИ, 2006. — Ред. 2. — 72 с. [Электронный ресурс]. — URL: http://revol.ponocom.ru/-d/ReVoL-KG2-2006.pdf (дата обращения 20.04.2026). 56. ESCHER’S TILINGS [Электронный ресурс]. — URL: https://cs.uwaterloo.ca/ ~csk/other/phd/kaplan_diss_escher_print.pdf (дата обращения 20.04.2026). 57. Мозаика Пенроуза [Электронный ресурс]. — URL: https:// https://ru.ruwiki.ru/wiki/Мозаика_Пенроуза?ysclid=mo77rzxxuq729279913 (дата обращения 20.04.2026). 58. Невозможная сковорода и другие победы плиток Пенроуза [Электронный ресурс]. — URL: https://habr.com/ru/articles/441448/ (дата обращения 20.04.2026). 59. Головокружительная мозаика Пенроуза [Электронный ресурс]. — URL: https://vk.com/@wowmosaic-golovokruzhitelnaya-mozaika-penrouza (дата обращения 20.04.2026). 60. Чебышёв, П. Л. О функциях, малоудаляющихся от нуля при некоторых величинах переменной // Записки Императорской академии наук. — Санкт-Петербург, 1881. — Т. 40. — № 3. — URL: https://www.livelib.ru/work/1002538396/ reviews-o-funktsiyah-malo-udalyayuschihsya-ot-nulya-pri-nekotoryh-velichinahperemennoj-pl-chebyshev. 61. Очков, В. Ф. Путешествие в мир науки и искусства на стопоходящей машине Чебышёва / В. Ф. Очков, М. Нори // Информатика в школе. — 2018. — № 8. — С. 53–61 (URL: http:// twtshell.ru/ochkov/Tcheb.pdf). 62. Очков, В. Ф. Живые кинематические схемы в Mathcad // Открытое образование. — 2013. — № 3. — С. 27–33. — URL: http:// twtshell.ru/ochkov/ Mathcad-15/kinematic.html. 63. Очков, В. Ф. История одного шедевра // Компьютерные инструменты в образовании. — 2000. — № 3 и 4. — URL: http://twtshell.ru/ochkov/Lace/ Lace.htm. 64. Очков, В. Ф. Математика во плоти: что такое кинетическое искусство и как оно работает / В. Ф. Очков, Д. Трунин (комментарий к выставке, посвященной кинетическому и оптическому искусству. — 17.03–09.05.2018, музей современного искусства «Гараж». — URL: https://nplus1.ru/material/2018/04/05/kinetic-art). 65. Очков, В. Ф. Math CAD и Python: обучение по технологии STEM : учеб. пособие для вузов / В. Ф. Очков, А. И. Тихонов. — 2-е изд., стер. — Санкт-Петербург : Лань, 2025. — 472 с. — Текст : электронный // Лань : электронно-библиотечная система. — URL: https://e.lanbook.com/book/447302 (дата обращения: 19.04.2026). 66. The future of Python notebooks [Электронный ресурс]. — URL: https://marimo.io/ (дата обращения 15.04.2024). Сайт проекта marimo. 366
67. Marimo [Электронный ресурс]. — URL: https://docs.marimo.io/ (дата обращения 15.04.2024). Документация по marimo. 68. Awesome marimo. A curated list of awesome things related to marimo. [Электронный ресурс]. — URL: https://github.com/marimo-team/awesome-marimo (дата обращения 15.04.2024). Обновляемый ресурс библиотеками, проектами, относящимися к marimo. 69. Made with marimo. [Электронный ресурс]. — URL: https://marimo.io/ gallery (дата обращения 15.04.2024). Галерея проектов, созданных с помощью marimo. 70. Marimo cheat sheet [Электронный ресурс]. — URL: hhttps:// github.com/vrtnis/marimo-cheat-sheet (дата обращения 15.04.2024). Краткая, но содержательная шпаргалка по marimo. 71. LaTeX cheatsheet [Электронный ресурс]. — URL: https://quickref.me/ latex (дата обращения 18.04.2026). 72. A quick guide to LaTeX [Электронный ресурс]. — URL: https://alozano.clas.uconn.edu/wp-content/uploads/sites/490/2020/08/latexcheatsheet.pdf (дата обращения 18.04.2026). 73. Maths Commands in LaTeX [Электронный ресурс]. — URL: https://www.geeksforgeeks.org/engineering-mathematics/maths-commands-in-latex/ (дата обращения 18.04.2026). 74. Коттвиц, Ш. LaTeX: руководство для начинающих / пер. с англ. А. В. Снастина. — Москва : ДМК Пресс, 2022. — 320 с. 75. Маккинни, У. Python и анализ данных: Первичная обработка данных с применением pandas, NumPy и Jupiter / пер. с англ. А. А. Слинкина. 3-е изд. — Алматы : Books.kz, 2023. — 536 с. 76. Тихонов, А. И. Научно-технические расчеты на Python / А. И. Тихонов — Москва : Издательство МЭИ, 2020. — 316 с. 77. PEP 20 — The Zen of Python. — URL:https://www.python.org/dev/ peps/pep-0020. 78. Kolpatzik, B. W. Optimized error diffusion for image display / B. W. Kolpatzik, C. A. Bouman // Journal of Electronic Imaging. —1992. — 1(3). —URL: https://doi.org/10.1117/12.60027. Дополнительная литература 1. Peter Schuhr Übergangsbogen für Bahnen mit hohen Fahr-geschwindigkeiten [Электронный ресурс]. — URL: https://dgk.badw.de/fileadmin/user_upload/Files/DGK/ docs/b-314.pdf (дата обращения 24.04.2026). 2. Optimum Railway Transition Curves — Method of the Assessment and Results [Электронный ресурс]. — URL: https://www.mdpi.com/1996-1073/ 14/13/3995 (дата обращения 25.05.2025). 3. Среднее гармоническое [Электронный ресурс]. — URL: https://ru.ruwiki.ru/wiki/Среднее_гармоническое (дата обращения 24.04.2026). 367
Валерий Федорович ОЧКОВ, Антон Иванович ТИХОНОВ 16 ЗАНЯТИЙ МИТ НА PYTHON НАУЧНО-ПОПУЛЯРНОЕ ИЗДАНИЕ Зав. редакцией литературы по информационным технологиям и системам связи О. Е. Гайнутдинова Ответственный редактор А. А. Шульга Подготовка макета Н. Ю. Горшкова Корректор О. А. Знобищев Выпускающий В. А. Иутин ЛР № 065466 от 21.10.97 Гигиенический сертификат 78.01.10.953.П.1028 от 14.04.2016 г., выдан ЦГСЭН в СПб Издательство «ЛАНЬ» lan@lanbook.ru; www.lanbook.com 196105, Санкт-Петербург, пр-кт Ю. Гагарина, д. 1, лит. А. Тел./факс: (812) 336-25-09, 412-92-72. Бесплатный звонок по России: 8-800-700-40-71 ГДЕ КУПИТЬ ДЛЯ ОРГАНИЗАЦИЙ: Для того, чтобы заказать необходимые Вам книги, достаточно обратиться в любую из торговых компаний Издательского Дома «ЛАНЬ»: по России и зарубежью «ЛАНЬ-ТРЕЙД». 196105, Санкт-Петербург, пр-кт Ю. Гагарина, д. 1, лит. А. тел.: (812) 412-85-78, 412-14-45, 412-85-82; тел./факс: (812) 412-54-93 e-mail: trade@lanbook.ru www.lanbook.com пункт меню «Где купить» раздел «Прайс-листы, каталоги» в Москве и в Московской области «ЛАНЬ-ПРЕСС». 115432, Москва, пр-кт Андропова, д. 10 (БЦ Технопарк Плаза), этаж 6, оф. 6.006, тел.: (499) 350-62-10, (495) 252-79-20; e-mail: lanpress@lanbook.ru в Краснодаре и в Краснодарском крае «ЛАНЬ-ЮГ». 350010, Краснодар, ул. Зиповская, д. 9, литер Д, пом. 31; тел.: (861) 274-10-35; e-mail: lankrd98@mail.ru ДЛЯ РОЗНИЧНЫХ ПОКУПАТЕЛЕЙ: интернет-магазин Издательство «Лань»: http://www.lanbook.com магазин электронных книг Global F5: http://globalf5.com/ Подписано в печать 21.04.26 Бумага офсетная. Гарнитура Школьная. Формат 70×100 1/16. Печать офсетная/цифровая. Усл. п. л. 29,90. Тираж 30 экз. Отпечатано в полном соответствии с качеством предоставленного оригинал-макета в АО «Т8 Издательские Технологии». 109316, г. Москва, Волгоградский пр-кт, д. 42, к. 5.