python

Fastcore — недооцененная, но полезная библиотека Python

  • четверг, 22 октября 2020 г. в 00:30:33
https://habr.com/ru/company/skillfactory/blog/524334/
  • Блог компании SkillFactory
  • Open source
  • Python
  • Программирование
  • Лайфхаки для гиков



Недавно я начал оттачивать владение языком программирования Python. Я хотел изучить продвинутые паттерны, идиомы и методы программирования. Начал я с чтения книг по продвинутому Python, но информация, похоже, не откладывалась в голове без применения навыков. Хотелось иметь возможность задавать вопросы эксперту, пока учусь, а такую возможность трудно найти! Тогда ко мне и пришла идея: что, если я найду проект с открытым и достаточно продвинутым кодом и напишу документацию и тесты? Я сделал ставку, что это заставит меня изучать все очень глубоко, а поддерживающие проект люди оценит мою работу и будут готовы ответить на мои вопросы.



Предыстория


Поиском такого проекта и написанием документации с тестами к нему я занимался целый месяц и такое обучение было самым эффективным из всех, что я пробовал. Я обнаружил, что написание документации заставило меня глубоко понять не только то, что делает код, но и то, почему код работает именно так, как он работает, а также исследовать крайние случаи во время написания тестов. Самое главное, что я мог задавать вопросы, когда застрял, а люди были готовы посвятить мне дополнительное время, зная, что их разъяснения служило тому, чтобы сделать код доступнее! Оказывается, выбранная мной библиотека, fastcore — одна из самых увлекательных в Python, с которыми я работал. Ее цели и задачи действительно уникальны.

fastcore — это основа многих проектов fast.ai. Самое главное: fastcore расширяет Python, стремясь к устранению шаблонного кода и добавлению полезной функциональности для общих задач. В этом посте я выделю некоторые из моих любимых инструментов fastcore вместо того, чтобы делиться тем, что я узнал о языке. Моя задача — вызвать интерес к этой библиотеке, и, надеюсь, мотивировать вас к ознакомлению с документацией после прочтения статьи.

Чем интересна fastcore?


  1. Ознакомление с идеями из других языков прямо в Python: Я постоянно слышу, что полезно изучать другие языки, чтобы стать лучшим программистом. Мне было трудно изучать другие языки с практической точки зрения, потому что я не мог применять их на работе. Fastcore расширяет Python, чтобы включить в него паттерны из разных языков: Julia, Ruby и Haskell. Теперь, когда я понимаю эти инструменты, у меня появилась мотивация изучать другие языки.
  2. Новый набор прагматичных инструментов: fastcore включает в себя утилиты, позволяющие писать более лаконичный выразительный код и, возможно, решать новые задачи.
  3. Изучение Python: fastcore расширяет Python, в этом процессе проявляются многие продвинутые понятия. Для мотивированных людей это прекрасный способ увидеть многое о внутренней работе языка.


Пройдемся по fastcore ураганом


Вот некоторые привлекшие мое внимание вещи, которые возможно сделать с помощью fastcore.

Делаем kwargs прозрачными


Я немного поеживаюсь каждый раз, когда вижу функцию с аргументом kwargs. Это потому, что kwargs означает обфускацию API. Мне нужно прочитать исходный код, чтобы понять, какие параметры допустимы. Посмотрим на пример ниже:

def baz(a, b=2, c =3, d=4): return a + b + c

def foo(c, a, **kwargs):
    return c + baz(a, **kwargs)

inspect.signature(foo)

<Signature (c, a, **kwargs)>

Без чтения исходного кода может быть трудно узнать, что foo также принимает дополнительные параметрыb и d. Это можно исправить с помощью delegates:

def baz(a, b=2, c =3, d=4): return a + b + c

@delegates(baz) # this decorator will pass down keyword arguments from baz
def foo(c, a, **kwargs):
    return c + baz(a, **kwargs)

inspect.signature(foo)

<Signature (c, a, b=2, d=4)>

Поведение этого декоратора настраивается. Например, вы можете передать аргументы и сохранить при этом kwargs:

@delegates(baz, keep=True)
def foo(c, a, **kwargs):
    return c + baz(a, **kwargs)

inspect.signature(foo)

<Signature (c, a, b=2, d=4, **kwargs)>

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

def basefoo(a, b=2, c =3, d=4): pass

@delegates(basefoo, but= ['d']) # exclude `d`
def foo(c, a, **kwargs): pass

inspect.signature(foo)

<Signature (c, a, b=2)>

Возможно делегирование между классами:

class BaseFoo:
    def __init__(self, e, c=2): pass

@delegates()# since no argument was passsed here we delegate to the superclass
class Foo(BaseFoo):
    def __init__(self, a, b=1, **kwargs): super().__init__(**kwargs)

inspect.signature(Foo)

<Signature (a, b=1, c=2)>

Для получения дополнительной информации прочтите документацию о delegates.

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


Вы когда-нибудь задумывались, можно ли избежать шаблонного кода, связанного с установкой атрибутов в __init__?

class Test:
    def __init__(self, a, b ,c): 
        self.a, self.b, self.c = a, b, c

Ой! Это было больно. Посмотрите на все эти повторяющиеся имена переменных. Неужели действительно нужно повторять всё это при определении класса? Уже нет! Посмотрите на store_attr:

class Test:
    def __init__(self, a, b, c): 
        store_attr()

t = Test(5,4,3)
assert t.b == 4

Вы также можете исключить определенные атрибуты:

class Test:
    def __init__(self, a, b, c): 
        store_attr(but=['c'])

t = Test(5,4,3)
assert t.b == 4
assert not hasattr(t, 'c')

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

P.S. Вы можете подумать, что классы данных тоже позволяют избежать такого шаблонного кода. Хотя в некоторых случаях это верно, store_attr более гибок. 1

Например, store_attr не полагается на наследование, а это значит, что вы не застрянете, используя множественное наследование, когда применяете его со своими собственными классами. Кроме того, в отличие от классов данных, store_attr не требует python 3.7 или выше. Вы можете использовать store_attr в любой момент в жизненном цикле объекта и в любом месте вашего класса, чтобы настроить поведение в том, как и когда хранятся переменные. Подробности здесь.

Избегаем бойлерплейта подклассов


Одна вещь, которую я ненавижу в python — связанный с подклассами шаблонный код __super__ ().__init__ (). Например:

class ParentClass:
    def __init__(self): self.some_attr = 'hello'

class ChildClass(ParentClass):
    def __init__(self):
        super().__init__()

cc = ChildClass()
assert cc.some_attr == 'hello' # only accessible b/c you used super

Мы можем избежать такого кода, используя метакласс PrePostInitMeta. Как? Определив новый класс под названием NewParent — обертку вокруг ParentClass:

class NewParent(ParentClass, metaclass=PrePostInitMeta):
    def __pre_init__(self, *args, **kwargs): super().__init__()

class ChildClass(NewParent):
    def __init__(self):pass

sc = ChildClass()
assert sc.some_attr == 'hello' 

Диспетчеризация типа


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


collide_with(x::Asteroid, y::Asteroid) = ... 
# deal with asteroid hitting asteroid

collide_with(x::Asteroid, y::Spaceship) = ... 
# deal with asteroid hitting spaceship

collide_with(x::Spaceship, y::Asteroid) = ... 
# deal with spaceship hitting asteroid

collide_with(x::Spaceship, y::Spaceship) = ... 
# deal with spaceship hitting spaceship

Диспетчеризация типа может быть особенно полезна в Data Science, где возможно разрешить различные типы ввода (т.е. массивы numpy и фреймы данных Pandas) в обрабатывающей данные функции. Типовая диспетчеризация позволяет иметь общий API у выполняющих похожие задачи функций. К сожалению, Python не поддерживает такую функциональность из коробки. К счастью, у нас есть декоратор @typedispatch. Этот декоратор полагается на подсказки типа, чтобы маршрутизировать входные данные к правильной версии функции:

@typedispatch
def f(x:str, y:str): return f'{x}{y}'

@typedispatch
def f(x:np.ndarray): return x.sum()

@typedispatch
def f(x:int, y:int): return x+y

Ниже показывается диспетчеризации типа при работе для функции f:

f('Hello ', 'World!')

'Hello World!'

f(2,3)

5

f(np.array([5,5,5,5]))

20

У этой функциональности есть ограничения (также как у других способов использования этой функции) и о них вы можете прочитать здесь. В процессе изучения типовой диспетчеризации я также нашел библиотеку Python под названием multipledispatch, написанную Mathhew Rocklin — создателем Dask.

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

Лучшая версия functools.partial


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

test_input = [1,2,3,4,5,6]
def f(arr, val): 
    "Filter a list to remove any values that are less than val."
    return [x for x in arr if x >= val]

f(test_input, 3)

[3, 4, 5, 6]

Из этой функции вы можете создать новую функцию с помощью partial, которая устанавливает значение по умолчанию: 5:

filter5 = partial(f, val=5)
filter5(test_input)

[5, 6]

Одна из проблем с partial заключается в том, что она удаляет исходную строку документации и заменяет ее общей строкой документации:

filter5.__doc__

'partial(func, *args, **keywords) - new function with partial application\n    of the given arguments and keywords.\n'


fastcore.utils.partialler исправляет это и обеспечивает сохранение строки документации таким образом, чтобы новый API был прозрачным:

filter5 = partialler(f, val=5)
filter5.__doc__

'Filter a list to remove any values that are less than val.'

Композиция функций


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

def add(arr, val): return [x + val for x in arr]
def arrsum(arr): return sum(arr)

# See the previous section on partialler
add2 = partialler(add, val=2)

transform = compose(filter5, add2, arrsum)
transform([1,2,3,4,5,6])

15

Почему это полезно? Вы можете подумать, что я могу сделать то же самое вот так:

arrsum(add2(filter5([1,2,3,4,5,6])))

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

def fit(x, transforms:list):
    "fit a model after performing transformations"
    x = compose(*transforms)(x)
    y = [np.mean(x)] * len(x) # its a dumb model.  Don't judge me
    return y

# filters out elements < 5, adds 2, then predicts the mean
fit(x=[1,2,3,4,5,6], transforms=[filter5, add2])

[7.5, 7.5]

Более подробную информацию о compose читайте в документации.

__repr__, но полезнее


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

class Test:
    def __init__(self, a, b=2, c=3): store_attr() # `store_attr` was discussed previously

Test(1)

<__main__.Test at 0x7ffcd766cee0>

Мы можем использовать basic_repr, чтобы быстро получить более разумное значение по умолчанию:

class Test:
    def __init__(self, a, b=2, c=3): store_attr() 
    __repr__ = basic_repr('a,b,c')

Test(2)

Test(a=2, b=2, c=3)

Обезьяньи патчи через декоратор


Это может быть удобно для обезьяньих патчей с помощью декоратора, что особенно полезно, когда вы хотите пропатчить импортируемую внешнюю библиотеку. Мы можем использовать декоратор @patch из fastcore.foundation вместе с подсказками типа примерно так:

class MyClass(int): pass  

@patch
def func(self:MyClass, a): return self+a

mc = MyClass(3)

Теперь в MyClass есть дополнительный метод под названием func:

mc.func(10)

13

Я еще не убедил вас? Тогда покажу вам еще один пример такого патча в следующем разделе.

pathlib.Path


Увидев эти расширения в pathlib.path, вы больше никогда не будете работать с vanilla pathlib! В pathlib добавлен ряд дополнительных методов, таких как:

  • Path.readlines: то же, что with open ('somefile', 'r') as f: f.readlines ()
  • Path.read: то же, что with open ('somefile', 'r') as f: f.read ()
  • Path.save: сохраняет файл как pickle
  • Path.load: загружает файл pickle
  • Path.ls: показывает содержимое пути в виде списка.
  • и так далее.

Подробнее об этом здесь. Вот демонстрация ls:

from fastcore.utils import *
from pathlib import Path
p = Path('.')
p.ls() # you don't get this with vanilla Pathlib.Path!!

(#7) [Path('2020-09-01-fastcore.ipynb'),Path('README.md'),Path('fastcore_imgs'),Path('2020-02-20-test.ipynb'),Path('.ipynb_checkpoints'),Path('2020-02-21-introducing-fastpages.ipynb'),Path('my_icons')]

Подождите! Что здесь происходит? Мы только что импортировали pathlib.Path — почему мы получили новую функциональность? Потому, что импортировали модуль fastcore.utils, который патчит pathlib.Path с помощью упомянутого выше декоратора @patch. Чтобы довести дело до конца и показать, чем полезен @patch, я пойду дальше и прямо сейчас добавлю еще один метод в Path:

@patch
def fun(self:Path): return "This is fun!"

p.fun()

'This is fun!'

Волшебно, правда? Вот почему я пишу об этом!

Еще более лаконичный способ написать лямбду


Self с заглавной буквы S — это еще более лаконичный способ написания вызывающих методы объекта лямбд. Например, создадим лямбду для получения суммы массива Numpy:

arr=np.array([5,4,3,2,1])
f = lambda a: a.sum()
assert f(arr) == 15

Вы можете таким же образом использовать Self:

f = Self.sum()
assert f(arr) == 15

Давайте создадим лямбду, которая будет делать группировку и возвращать максимальный элемент фрейма данных Pandas:

import pandas as pd
df=pd.DataFrame({'Some Column': ['a', 'a', 'b', 'b', ], 
                 'Another Column': [5, 7, 50, 70]})

f = Self.groupby('Some Column').mean()
f(df)

Another Column
Some Column
a 6
b 60

Подробнее о Self читайте в документации.

Функции блокнота


Они просты, но удобны и позволяют узнать, выполняется ли код в блокноте Jupyter, Colab или через оболочку IPython:

from fastcore.imports import in_notebook, in_colab, in_ipython
in_notebook(), in_colab(), in_ipython()

(True, False, True)

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

Замена стандартного списка


Вы можете быть довольно счастливы со стандартным list в Python. Это одна из тех ситуаций, когда вы не знаете, что вам нужен список лучше, пока кто-то не покажет его вам. L — как раз такой список с большим количеством доработок.

Лучший способ описать L — сделать вид, что у list и numpy родился милый ребенок. Определите список (посмотрите на приятный __repr__, показывающий длину списка!)

L(1,2,3)

(#3) [1,2,3]

Перемешайте список:

p = L.range(20).shuffle()
p

(#20) [8,7,5,12,14,16,2,15,19,6...]

Индекс [прим.перев. — скорее позиция — position] в списке:

p[2,4,6]

(#3) [5,14,2]

L имеет разумные умолчания, например, вот добавление элемента в список:

1 + L(2,3,4)

(#4) [1,2,3,4]

L может гораздо больше. Читайте документацию, чтобы узнать больше.

Но подождите… Это еще не всё!


Есть еще кое-что, что я хотел бы показать вам, но это никак не вписываются в пост. Вот список некоторых любимых вещей, которые я не показывал в этом посте:

Утилиты


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

  • mk_class: быстро добавляет кучу атрибутов в класс
  • wrap_class: добавление новых методов в класс с помощью простого декоратора
  • groupby: похоже на groupby из Scala
  • merge: слияние словарей
  • fasttuple: кортеж на стероидах
  • Infinite Lists: полезно для увеличения размеров массивов и тестирования
  • chunked: упаковка и организация элементов

Многопроцессорная обработка


Раздел Многопроцессорная обработка расширяет соответствующую библиотеку Python, предлагая такие возможности:

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

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

image




Рекомендуемые статьи