Оптимизация кода Python с помощью множества поисковых запросов по атрибутам и словарям

Я написал программу на Python, которая тратит много времени на поиск атрибутов объектов и значений из ключей словаря. Я хотел бы знать, могу ли я каким-либо образом оптимизировать это время поиска, возможно, с расширением C, чтобы сократить время выполнения, или мне нужно просто повторно реализовать программу на скомпилированном языке.

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

Выражение генератора в строке 202:

    neighbors_in_selected_nodes = (neighbor for neighbor in
            node_neighbors if neighbor in selected_nodes)

и выражение генератора в строке 204:

    neighbor_z_scores = (interaction_graph.node[neighbor]['weight'] for
            neighbor in neighbors_in_selected_nodes)

Исходный код этой функции контекста предоставлен ниже.

selected_nodes — это set узлов в interaction_graph, который является экземпляром NetworkX Graph. node_neighbors – это итератор из Graph.neighbors_iter(). .

Graph сам использует словари для хранения узлов и ребер. Его атрибут Graph.node представляет собой словарь, в котором хранятся узлы и их атрибуты (например, 'weight') в словарях, принадлежащих каждому узлу.

Каждый из этих поисков должен амортизироваться за постоянное время (т. е. O (1)), однако я все еще плачу большой штраф за поиск. Есть ли способ, которым я могу ускорить эти поиски (например, написав части этого как расширение C), или мне нужно переместить программу на скомпилированный язык?


Ниже приведен полный исходный код функции, предоставляющей контекст; подавляющее большинство времени выполнения тратится на эту функцию.

def calculate_node_z_prime(
        node,
        interaction_graph,
        selected_nodes
    ):
    """Calculates a z'-score for a given node.

    The z'-score is based on the z-scores (weights) of the neighbors of
    the given node, and proportional to the z-score (weight) of the
    given node. Specifically, we find the maximum z-score of all
    neighbors of the given node that are also members of the given set
    of selected nodes, multiply this z-score by the z-score of the given
    node, and return this value as the z'-score for the given node.

    If the given node has no neighbors in the interaction graph, the
    z'-score is defined as zero.

    Returns the z'-score as zero or a positive floating point value.

    :Parameters:
    - `node`: the node for which to compute the z-prime score
    - `interaction_graph`: graph containing the gene-gene or gene
      product-gene product interactions
    - `selected_nodes`: a `set` of nodes fitting some criterion of
      interest (e.g., annotated with a term of interest)

    """
    node_neighbors = interaction_graph.neighbors_iter(node)
    neighbors_in_selected_nodes = (neighbor for neighbor in
            node_neighbors if neighbor in selected_nodes)
    neighbor_z_scores = (interaction_graph.node[neighbor]['weight'] for
            neighbor in neighbors_in_selected_nodes)
    try:
        max_z_score = max(neighbor_z_scores)
    # max() throws a ValueError if its argument has no elements; in this
    # case, we need to set the max_z_score to zero
    except ValueError, e:
        # Check to make certain max() raised this error
        if 'max()' in e.args[0]:
            max_z_score = 0
        else:
            raise e

    z_prime = interaction_graph.node[node]['weight'] * max_z_score
    return z_prime

Вот пара лучших звонков по версии cProfiler, отсортированных по времени.

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
156067701  352.313    0.000  642.072    0.000 bpln_contextual.py:204(<genexpr>)
156067701  289.759    0.000  289.759    0.000 bpln_contextual.py:202(<genexpr>)
 13963893  174.047    0.000  816.119    0.000 {max}
 13963885   69.804    0.000  936.754    0.000 bpln_contextual.py:171(calculate_node_z_prime)
  7116883   61.982    0.000   61.982    0.000 {method 'update' of 'set' objects}

person gotgenes    schedule 05.04.2010    source источник
comment
Почему две петли? neighbors_in_selected_nodes и neighbor_z_scores? Почему не одна петля? Двухэтапная формулировка, кажется, не вводит ничего нового. Зачем это делать? Можете ли вы обновить вопрос, чтобы объяснить, почему используются два понимания вместо одного?   -  person S.Lott    schedule 05.04.2010
comment
Я хочу учитывать веса только для узлов, которые 1) являются соседями И 2) выбраны (присутствуют в selected_nodes). Для этого я использую два выражения генератора: neighbors_in_selected_nodes фильтры для выбранных узлов; это связано с neighbor_z_scores для получения весов. Цепочки итераторов должны делать их одним циклом, а не двумя; цикл оценивается в пределах max().   -  person gotgenes    schedule 05.04.2010
comment
Два выражения генератора действительно могут быть записаны как один цикл for; первое выражение представляет оператор if neighbor in selected_nodes:, а второе выражение представляет neighbor_z_scores.append(interaction_graph.node[neighbor]['weight']). Используя выражения генератора, я избегаю создания списка и операций добавления. От выборки соседей до оценки max(neighbor_z_scores) я полностью работаю с цепочкой итераторов.   -  person gotgenes    schedule 05.04.2010


Ответы (4)


Как насчет того, чтобы отсортировать (или частично отсортировать с помощью collections.heapq) порядок итераций для interface_graph.neighbors_iter(node)? Поскольку вы просто пытаетесь найти максимальное значение, вы можете перебирать node_neighbors в порядке убывания, первый узел, который находится в selected_node, должен быть максимальным в selected_node.

Во-вторых, как часто будет меняться selected_node? Если он меняется редко, вы можете сэкономить много итераций, имея список «interaction_graph.node[neighbor] для x в selected_node», вместо того, чтобы каждый раз перестраивать этот список.

РЕДАКТИРОВАТЬ: чтобы ответить на комментарии

sort() будет принимать O (n log n)

Не обязательно, вы слишком много смотрите в свой учебник. Несмотря на то, что написано в вашем учебнике, вы иногда можете преодолеть барьер O(n log n), используя определенную структуру ваших данных. Если вы храните свой список соседей в естественно отсортированной структуре данных в первую очередь (например, heapq, двоичное дерево), вам не нужно повторно сортировать на каждой итерации. Конечно, это компромисс между пространством и временем, поскольку вам нужно будет хранить избыточные списки соседей, и существует сложность кода, чтобы гарантировать, что список соседей обновляется при изменении соседей.

Кроме того, python list.sort(), который использует алгоритм timsort, очень быстр для почти отсортированных данные (в некоторых случаях может усреднить O (n)). Он по-прежнему не ломает O (n log n), так как было доказано, что это математически невозможно.

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

person Lie Ryan    schedule 05.04.2010
comment
Сортировка — интересная идея. sort() займет O(n log n) (где n — количество соседей); это будет дороже, чем линейный поиск. Согласно документам heapq, heapify() равно O(n), но я считаю, что каждый поп-элемент либо O(log n), либо O(n) (не уверен), что означает, что в худшем случае будет вдвое больше операций, чем в простом линейном цикле через соседей. - person gotgenes; 06.04.2010
comment
Что касается второго пункта, то, к сожалению, selected_nodes изменяется при каждом вызове этой функции, поэтому он не подходит для кэширования. Хорошая мысль, однако. - person gotgenes; 06.04.2010

Я не понимаю, почему ваш поиск по «весу» должен быть в форме ["weight"] (узлы — это словари?) вместо .weight (узлы — это объекты).

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

class Node(object):
    # ... class stuff goes here ...

    __slots__ = ('weight',) # tuple of member names.

РЕДАКТИРОВАТЬ: Итак, я просмотрел предоставленную вами ссылку NetworkX, и есть несколько вещей, которые меня беспокоят. Во-первых, прямо вверху определение «словарь» — «FIXME».

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

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

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

Вот почему, когда я читаю:

узел
Узел может быть любым хешируемым объектом Python, кроме None.

Я думаю, ладно, без проблем, но тут же следует

атрибут узла
Узлы могут иметь произвольные объекты Python, назначаемые в качестве атрибутов с использованием пар ключевое слово/значение при добавлении узла или назначении в словарь атрибутов G.node[n] для указанного узла n.

Я думаю, если узлу нужны атрибуты, зачем хранить их отдельно? Почему бы просто не поместить его в ноду? Вреден ли написание класса с contentString и weight участниками? Края кажутся еще более безумными, поскольку они должны быть кортежами, а не объектами, которые вы могли бы подклассировать.

Так что я немного запутался в дизайнерских решениях, лежащих в основе NetworkX.

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

Наконец, что, если вы объедините свои генераторы:

neighbor_z_scores = (interaction_graph.node[neighbor]['weight'] for
        neighbor in node_neighbors if neighbor in selected_nodes)
person Mike DeSimone    schedule 05.04.2010
comment
Хороший вопрос. Сами узлы представляют собой просто строки Python (идентификаторы), которые представляют биологические объекты. Атрибут веса каждого узла хранится в графе с использованием структуры атрибутов узла: networkx.lanl.gov/reference/glossary.html#term-node-attribute Поскольку поиск по атрибутам, по сути, является поиском по словарю, я не уверен, что переход на истинный атрибут даст прирост производительности. - person gotgenes; 06.04.2010
comment
Прирост производительности будет заключаться в том, чтобы избежать сравнения строк, заменив его сравнением идентификаторов... или что-то в этом роде. Я не писал язык, но я почти уверен, что foo.bar делает байт-код лучше, чем {'bar': 123}['bar'], поскольку первый случай встречается гораздо чаще. - person Mike DeSimone; 06.04.2010
comment
Майк, интуитивно я тоже тебе верю, но результаты сравнения, которые я только что получил, показали обратное. bitbucket.org/gotgenes/interesting-python-timings/src для Python 2.6.4 доступ к атрибутам класса ‹ поиск по словарю ‹ __slots__ доступ ‹ доступ к атрибутам экземпляра с точки зрения времени. Поиск в словаре примерно на 10% быстрее, чем __slots__. - person gotgenes; 08.04.2010

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

# cache this object
ignode = interaction_graph.node
neighbor_z_scores = []
for neighbor in node_neighbors:
    try:
        neighbor_z_scores.append(ignode[neighbor]['weight'])
    except KeyError:
        pass

или с getdefault и пониманием списка:

sentinel = object()
# cache this object 
ignode = interaction_graph.node

neighbor_z_scores = (ignode[neighbor]['weight'] for neighbor in node_neighbors)
# using identity testing, it's slightly faster
neighbor_z_scores = (neighbor for neighbor in neighbor_z_scores if neighbor is not sentinel)
person Lie Ryan    schedule 05.04.2010
comment
Соседей, не являющихся членами selected_nodes, будет намного больше, чем членов, поэтому я сначала фильтрую по этому критерию. Это означает меньше 'weight' поисковых запросов. Я могу гарантировать, что все 'weight' поиски будут успешными, так что нет никакой пользы от предложения try-except. ignode — очень хорошая идея, и для этого атрибута будет много поисков. - person gotgenes; 05.04.2010

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

Добавьте их на уровне модуля:

import itertools as it, operator as op
GET_WEIGHT= op.attrgetter('weight')

Изменять:

neighbors_in_selected_nodes = (neighbor for neighbor in
        node_neighbors if neighbor in selected_nodes)

в:

neighbors_in_selected_nodes = it.ifilter(selected_node.__contains__, node_neighbors)

а также:

neighbor_z_scores = (interaction_graph.node[neighbor]['weight'] for
        neighbor in neighbors_in_selected_nodes)

в:

neighbor_z_scores = (
    it.imap(
        GET_WEIGHT,
        it.imap(
            interaction_graph.node.__getitem__,
            neighbors_in_selected_nodes)
    )
)

Эти помогают?

person tzot    schedule 18.04.2010