Rosalind.info — кэширование

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

Формула чисел ФибоначчиВ рамках биоинформатики числа Фибоначчи и подобные используются для описания динамики популяций (числа F(n) равны размеру популяции при условии бессмертия индивидов). Обобщения чисел Фибоначчи рассматриваются в задачах Rosalind FIB и FIBD.

Другой пример рекуррентного отношения — числа Каталана, которые можно задать рекуррентным отношением

Формула чисел КаталанаC(n) равняется количеству правильных скобочных последовательностей длины 2n. В биоинформатике подобные числам Каталана формулы возникают в задачах сворачивания молекул РНК (см. задачи Rosalind CAT, MMCH, PMCH).

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

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

Для борьбы с первой проблемой можно использовать кэширование (другое название — мемоизация) результатов вычислений. Справиться с обеими проблемами позволяет динамическое программирование. В рамках Python оба этих средства могут быть реализованы как при помощи императивной парадигмы (то есть так, как в C++ или Pascal), так и с использованием шаблонов функционального программирования.

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

def fib(n):
    if n not in fib._cache:
        fib._cache[n] = fib(n - 1) + fib(n - 2)
    return fib._cache[n]

# Функции в Python могут хранить произвольные атрибуты
fib._cache = dict()

У такого подхода есть недостатки: программа обрастает инструкциями (в том числе глобальными), в то время как их суть неизменна практически для любой кэшируемой функции. Альтернативный подход состоит в использовании декораторов Python. Декоратор — функция, которая получает на вход функцию и выдает ее после определенного преобразования. Для указания декораторов используется символ @, как в Java.

def decorator(fn):
    def transformed_fn(...):
        # преобразовать функцию fn
    return transformed_fn

@decorator
def foo(...):
    # код функции

# эквивалентный код
def foo(...):
    # код функции
foo = decorator(foo)

При написании декораторов в полной мере раскрываются возможности Python как функционального языка программирования. Сами декораторы возможны, поскольку в Python есть функции первого класса (англ. first class functions), в отличие от, например, Java. Кроме того, для преобразования функций в декораторах обычно используются вложенные декларации функций и замыкания (англ. closure). Это относится и к декоратору кэширования:

def cached(fn):
    cache = dict()
    def cached_fn(*args):
        if not args in cache:
            cache[args] = fn(*args)
        return cache[args]
    return cached_fn

Кэшированная функция cached_fn определяется заново при каждом вызове декоратора; функция fn доступна внутри определения за счет замыкания. Таким образом, декоратор можно привязать к нескольким функциям программы. С помощью «сборки» аргументов функции с помощью синтаксиса fn(*args) становится возможным кэшировать функции с произвольным числом аргументов.

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

class RecurrentSum(object):
    '''
    Общий класс для последовательностей, задающихся
    рекуррентной формулой
        F(i) = F(i - 1) + F(i - 2) + ... + F(i - k).
    '''

    def __init__(self, count):
        self.count = count

    # Декораторы работают со специальными методами
    # (в данном случае — индексированием).
    @cached
    def __getitem__(self, n):
        if n < self.count: return 1
        sum = 0
        for i in range(max(0, n - self.count), n):
            sum += self[i]
        return sum

fib = RecurrentSum(2) # числа Фибоначчи
trib = RecurrentSum(3) # "числа трибоначчи"

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

def cache(fn):
    cache = dict()
    def cached_fn(*args):
        mapped_args = mapargs(*args)
        if not mapped_args in cache:
            cache[mapped_args] = fn(*args)
        return cache[mapped_args]
    return cached_fn

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

def decorator(...):
    def decorate(fn):
        # объявление обычного декоратора на основе аргументов
    return decorate

@decorator(...)
def foo(...):
    # код функции

# эквивалентный код
def foo(...):
    # код функции
foo = decorator(...)(foo)

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

def cached(mapargs = lambda *args: args):
    def decorate(fn):
        # код простого декоратора из предыдущего листинга
    return decorate

# использование c функцией отображения по умолчанию
@cached()
def fib(n): 
    ...
# использование с пользовательской функцией mapargs
@cached(mapargs = lambda x, y, z: x + y + z)
def some_function(x, y, z):
    ...

Единственный недостаток полученного декоратора: теперь его нельзя использовать в форме простого декоратора, то есть без скобок после названия. Это поправимо, если ввести использовать дополнительный декоратор (декоратор-inception :-).

def noargs(decorator):
    ''' Декоратор, позволяющий использовать форму без аргументов
        для параметричексих декораторов. '''

    def noarg_fn(*args, **kvargs):
        if len(args) == 1 and callable(args[0]) and len(kvargs) == 0:
            # Вызван декоратор без аргументов;
            # args[0] - преобразуемая функция
            return decorator()(args[0])
        else:
            return decorator(*args, **kvargs)
    return noarg_fn

# Пример использования
@noargs
def cached(...):
    # объявление параметрического декоратора кэширования

@cached
def foo(...):
    ...
# эквивалентно
@cached()
def foo(...):
    ...

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

@noargs
def cached(mapargs = lambda *args: args):
    ...

# Работает некорректно
@cached(lambda s: frozenset(s))
def foo(s):
    ...

# OK
@cached(mapargs = lambda s: frozenset(s))
def foo(s):
    ...

Ссылки

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *