Специалист в Data Science из Amazon буквально прочитал код самых распространённых библиотек Python. В этом материале он делится секретами работы с Python, о которых узнал из этих библиотек. За подробностями приглашаем под кат к старту нашего флагманского курса по Data Science:



Вызов super() в базовых классах


Функция super() в Python позволяет наследовать базовые классы (они же суперклассы или родительские классы) без необходимости явно ссылаться на базовый класс. Обычно метод super() используется в методе __init__. Множественное наследование практически невозможно без super(), хотя оно может быть удобно при одиночном наследовании.

Одно из интересных применений super() — его вызов в классе базовом классе. Этот приём я заметил в requests.adapters, в BaseAdapter:

class BaseAdapter:
    """The Base Transport Adapter"""

    def __init__(self):
        super().__init__()

    def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):

Базовый класс ни от чего не наследуется, так зачем же вызывать в нём super()?

Немного покопавшись, я узнал вот что: в базовом классе ключевое слово super() позволяет реализовать совместное множественное наследование. Без него вызовы __init__ родительских классов — после класса без super — пропускаются. Ниже — пример с базовым классом BaseEstimator и миксином ServingMixin, где DecisionTree унаследует оба класса.

Итак, у нас BaseEstimator, который в своём __init__ не вызывает super(). Для вывода атрибутов у него есть базовый метод __repr__:

class BaseEstimator:
    def __init__(self, name, **kwargs):
        self.name = name

    def __repr__(self):
        return f', '.join(f'{k}: {v}' for k, v in vars(self).items())

Затем мы наследуем BaseEstimator через подкласс DecisionTree. Всё работает нормально — при печати экземпляра DecisionTree отображаются атрибуты BaseEstimator и DecisionTree:

class DecisionTree(BaseEstimator):
    def __init__(self, depth, **kwargs):
        super().__init__(**kwargs)
        self.depth = depth

dt = DecisionTree(name='DT', depth=1)
print(dt)
> name: DT, depth: 1

Теперь пронаследуемся от ServingMixin и создадим экземпляр DecisionTree:

class ServingMixin:
    def __init__(self, mode, **kwargs):
        super().__init__(**kwargs)
        self.mode = mode

class DecisionTree(BaseEstimator, ServingMixin):
    def __init__(self, depth, **kwargs):
        super().__init__(**kwargs)
        self.depth = depth

dt = DecisionTree(name='Request Time DT', depth=1, mode='online')
print(dt)
> name: Request Time DT, depth: 1

dt.mode
> AttributeError: 'DecisionTree' object has no attribute 'mode'

Заметно, что ServingMixin не наследуется должным образом: атрибут ServingMixin (mode) не отображается, когда выводится экземпляр дерева решений, и, если мы пытаемся получить доступ к атрибуту mode, то окажется, что он не существует.

Это связано с тем, что без super() в BaseEstimator класс DecisionTree не вызывает следующий родительский класс в порядке разрешения методов.

Исправить это можно вызовом super() в BaseEstimator, и DecisionTree заработает, как ожидалось:

class BaseEstimator:
    def __init__(self, name, **kwargs):
        self.name = name
        super().__init__(**kwargs)

    def __repr__(self):
        return f', '.join(f'{k}: {v}' for k, v in vars(self).items())

class ServingMixin:
    def __init__(self, mode, **kwargs):
        super().__init__(**kwargs)
        self.mode = mode

class DecisionTree(BaseEstimator, ServingMixin):
    def __init__(self, depth, **kwargs):
        super().__init__(**kwargs)
        self.depth = depth

dt = DecisionTree(name='Request Time DT', depth=1, mode='online')
print(dt)
> name: Request Time DT, mode: online, depth: 1

dt.mode
> 'online'

Именно поэтому мы можем захотеть вызвать super() в базовом классе.


Когда использовать миксины?


Mixin — это класс, предоставляющий реализации методов для повторного использования дочерними классами. Он представляет ограниченную форму множественного наследования и родительский класс, который просто даёт функциональные возможности подклассам, не содержит состояния и не предназначен для создания экземпляров. Scikit-learn широко использует миксины. Это ClassifierMixin, TransformerMixin, OutlierMixin и т. д.

Когда использовать миксины? Они подходят, когда хочется:

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

Ниже — пример первого случая. Начнём с создания базового объекта запроса в werkzeug:

from werkzeug import BaseRequest

class Request(BaseRequest):
    pass

Если нужно добавить поддержку заголовка accept, перепишем код вот так:

from werkzeug import BaseRequest, AcceptMixin

class Request(AcceptMixin, BaseRequest):
    pass

Нужна поддержка пользовательского агента, аутентификации и т. д.? Нет проблем, просто добавьте миксины:

from werkzeug import BaseRequest, AcceptMixin, UserAgentMixin, AuthenticationMixin

class Request(AcceptMixin, UserAgentMixin, AuthenticationMixin, BaseRequest):
    pass

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


Использование относительного импорта


Относительный импорт гарантирует, что мы ищем текущий пакет (и импортируем из него) перед поиском в остальной части PYTHONPATH. Он работает, если перед импортом поставить . — вот пример из base.py от Sklearn:

from .utils.validation import check_X_y
from .utils.validation import check_array

Что произойдёт, если base.py не использует относительный импорт? Если у нас есть пакет с именем utils в каталоге нашего скрипта, во время импорта Python будет искать наш пакет utils, а не utils от sklearn, тем самым нарушая работу sklearn. Точка в выражении импорта гарантирует, что base.py в sklearn сначала ищет utils от Sklearn.

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


Когда добавлять код в __init__.py


__init__.py помечает каталоги как каталоги пакетов Python. Обычная практика — оставлять __init__.py пустыми. Тем не менее во многих библиотеках, которые я читал, были непустые, а иногда длинные файлы __init__.py. Это заставило меня задуматься, что и почему можно добавить в __init__.py.

Во-первых, мы можем добавить в __init__.py импорт, когда хотим реорганизовать код, который вырос в несколько модулей, без критических изменений для существующих пользователей. Допустим, у нас есть один модуль (models.py), который содержит реализацию для DecisionTree и Bandit. Со временем этот единственный модуль превращается в пакет моделей с модулями для tree и bandit. Чтобы обеспечить согласованность API для существующих пользователей, в __init__.py в пакете моделей мы можем добавить следующее:

from .tree import DecisionTree, RandomForest
from .bandit import Bandit, TSBandit

Это гарантирует, что существующие пользователи смогут продолжить импорт через from models import DecisionTree, а не from models.tree import DecisionTree. Для них API не меняется, а существующий код не ломается.

Это подводит к ещё одной причине добавить код в __init__.py, а именно — предоставить упрощённый API, чтобы пользователям не приходилось вникать в детали реализации:

app
  __init__.py
  model_implementation.py
  data_implementation.py

Вместо того чтобы заставлять пользователей решать, что импортировать из model_implementation и data_implementation, мы можем всё упростить, добавив в __init__.py такой код:

from .model_implementation import SimpleModel
from .data_implementation import SimpleDataLoader

В нём говорится, что SimpleModel и SimpleDataLoader — единственные части приложения, с которыми должны работать пользователи, и это упрощает использование пакета приложения (например, from app import SimpleModel, SimpleDataLoader). И, если пользователи знают, что делают, и хотят импортировать напрямую из model_implementation, то это тоже возможно.

Так делается в Pandas, где __init__.py импортируются типы данных, считыватели (reader) и API изменения формы, а ещё так делается в Accelerate от Hugging Face.

Помимо упомянутого выше применения, мы также можем захотеть:


Ссылки

Когда использовать экземпляр, класс и статические методы


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

  • Методам экземпляра нужен экземпляр класса, и доступ к нему они могут получить через self.
  • Методам класса не нужен экземпляр. Таким образом, они не могут получить доступ к экземпляру (self), но им доступен к класс cls
  • Статические методы не имеют доступа к self или cls. Они работают как обычные функции, но относятся к пространству имён классов.

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

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

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


Скрытая функция conftest.py


Обычно conftest.py используется для предоставления фикстур для всего каталога. После определения фикстуры в conftest.py, они могут использоваться любым тестом в пакете без необходимости импортировать их. Этот файл также используется для загрузки внешних плагинов и определения хуков, как setup и teardown.

Но, просматривая sklearn, я наткнулся на пустой conftest.py с этим интересным комментарием:

# Даже если этот файл пустой, он полезен тем, что при запуске из корневой папки
# ./sklearn добавляется в sys.path с помощью pytest. Смотрите
# https://docs.pytest.org/en/latest/explanation/pythonpath.html.
# Например, это позволяет создавать расширения на месте и запускать pytest
# doc/modules/clustering.rst, а также использовать sklearn из локальной папки, а не только из site-packages.

Оказывается, sklearn использовал менее известную функцию conftest.py: его существование в корневом пути гарантирует, что pytest распознает модули без необходимости указывать PYTHONPATH. В фоновом режиме pytest изменяет sys.path, включая все найденные в корневом пути подмодули.


Поясняющая документация библиотек


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

Принципы проектирования Scikit-learn включают:

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

В результате большинство моделей машинного обучения и преобразователей данных имеют метод fit(). Кроме того, модели машинного обучения имеют метод predict(), а преобразователи данных — метод transform(). Эта последовательность и простота способствуют лёгкости работы со sklearn. Принцип композиции также объясняет, почему sklearn построен на множественном наследовании базовых классов и миксинов.

Другой пример — fastai, где используется многоуровневый подход. Фреймворк предоставляет высокоуровневый API, готовые к использованию функции обучения моделей для различных приложений. Высокоуровневый API построен на основе иерархии низкоуровневых API, последние предоставляют компонуемые стандартные блоки. Этот многоуровневый подход позволяет через настройку API-интерфейсов среднего уровня быстро создать прототип перед его кастомизацией.

Изображение

Поделился принципами проектирования и PyTorch. Это:


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

• • •

Вот некоторые необычные способы работы с Python, о которых я узнал, читая эти билиотеки:


Я уверен, что пока смотрю поверхностно. Может быть, я что-нибудь упустил? Пожалуйста, оставьте комментарий ниже!




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


Комментарии (7)


  1. TyVik
    22.08.2022 11:14
    +1

    В pydantic мне ещё понравилось как они python код компилируют в .so в setup.py. Кто сказал, что python не компилируемый язык программирования*?


    1. stranger777
      22.08.2022 17:42

      Спасибо за комментарий :)


    1. masai
      22.08.2022 19:56
      +1

      Там компилируется код не на Python, а на Cython, который изначально был компилируемым. Строго говоря, это не совсем Python. Хотя да, в данном случае компилируется именно код на Python, так как Cython — его надмножество. Можно было бы получить бо́льшую производительность использовав возможности именно Cython, но автор всё равно решил следующую версию написать на Rust. :)

      Компиляторы именно Python (не над- или подмножеств) тоже есть. Например, Nuitka. К сожалению, особенности Python не дают возможности получить большую выгоду от компиляции в общем случае. Хотя если добавить ограничения, то может получится очень даже неплохо, как в случае с Numba.


      1. TyVik
        23.08.2022 12:32

        Да-да, именно поэтому я поставил звёздочку.


  1. burdin
    22.08.2022 12:02
    +1

    >Исправить это можно вызовом super() в BaseEstimator, и DecisionTree заработает, как ожидалось...

    Миксин можно поставить слева, и обойтись без непонятного super в родительском классе


  1. fishHook
    22.08.2022 12:57
    +4

    Ёкарный бабай! Не могу молчать

    Специалист в Data Science из Amazon буквально прочитал код самых распространённых библиотек Python. В этом материале он делится секретами работы с Python

    Что из вышеперечисленного является секретом? Это же просто цитирование учебника. Специалист хоть один учебный материал по питону читал прежде чем "читать код популярных библиотек"?

    Вот некоторые необычные способы работы с Python

    Какие к черту необычные способы? Что же тогда для автора "обычные" способы. Это умственно отсталый писал, такое чувство


  1. masai
    22.08.2022 19:46
    +1

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

    2. Обоснование относительного импорта — вообще очень странное. Используйте относительный импорт .utils так как без точки Python не найдет модуль utils. Так он и не должен. Ну хоть нет совета добавить его в sys.path. :)

    3. О том, почему не стоит использовать относительные импорта написано уже много, скажу лишь, что согласно PEP-8 и Google Python Style Guide относительный import использовать строго не рекомендуется.

    4. Очень странно, что специалист из Amazon не знал о том, как использовать __init__.py. Тут можно добавить рекомендацию явно заполнять список импортов __all__, чтоб, например, isort/zimports не удалили неиспользуемые импорты.

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

    6. Упомянутый fastai (не путать с fastapi) печально известен низким качеством кода. Я б не советовал использовать его как образец для подражания.