Как объединить совпадающие пары в связанные компоненты в Python

Реальная проблема:

У меня есть данные о директорах многих фирм, но иногда «Джон Смит, директор XYZ» и «Джон Смит, директор ABC» — это одно и то же лицо, а иногда — разные. Также «Джон Дж. Смит, директор XYZ» и «Джон Смит, директор ABC» могут быть одним и тем же человеком, а могут и не быть. Часто изучение дополнительной информации (например, сравнение биографических данных о «Джоне Смите, директоре XYZ» и «Джоне Смите, директоре ABC») позволяет решить, относятся ли два наблюдения к одному и тому же человеку или нет.

Концептуальная версия проблемы:

В этом духе я собираю данные, которые позволят идентифицировать совпадающие пары. Например, предположим, что у меня есть следующие совпадающие пары: {(a, b), (b, c), (c, d), (d, e), (f, g)}. Я хочу использовать свойство транзитивности отношения «это тот же человек, что и» для создания «связанных компонентов» {{a, b, c, d, e}, {f, g}}. То есть {a, b, c, d, e} — это один человек, а {f, g} — другой. (В более ранней версии вопроса упоминались «клики», которые, по-видимому, являются чем-то другим; это объясняет, почему find_cliques в networkx давал «неправильные» результаты (для моих целей).

Следующий код Python выполняет эту работу. Но мне интересно: есть ли лучший (менее затратный в вычислительном отношении) подход (например, использование стандартных или доступных библиотек)?

Здесь и там есть примеры, которые кажутся связанными (например, Cliques в python), но они неполные, поэтому я не уверен, на какие библиотеки они ссылаются или как настроить мои данные для их использования.

Пример кода Python 2:

def get_cliques(pairs):
    from sets import Set

    set_list = [Set(pairs[0])]

    for pair in pairs[1:]:
        matched=False
        for set in set_list:
            if pair[0] in set or pair[1] in set:
                set.update(pair)
                matched=True
                break
        if not matched:
            set_list.append(Set(pair))

    return set_list

pairs = [('a', 'b'), ('b', 'c'), ('c', 'd'), ('d', 'e'), ('f', 'g')]

print(get_cliques(pairs))

Это дает желаемый результат: [Set(['a', 'c', 'b', 'e', 'd']), Set(['g', 'f'])].

Пример кода Python 3:

Это производит [set(['a', 'c', 'b', 'e', 'd']), set(['g', 'f'])]):

def get_cliques(pairs):

    set_list = [set(pairs[0])]

    for pair in pairs[1:]:
        matched=False
        for a_set in set_list:
            if pair[0] in a_set or pair[1] in a_set:
                a_set.update(pair)
                matched=True
                break
        if not matched:
            set_list.append(set(pair))

    return set_list

pairs = [('a', 'b'), ('b', 'c'), ('c', 'd'), ('d', 'e'), ('f', 'g')]

print(get_cliques(pairs))

person Ian Gow    schedule 15.01.2015    source источник
comment
Примеры, вероятно, относятся к networkX lib: networkx.github.io/documentation/ last/reference/ Это алгоритм, требующий значительных вычислительных ресурсов (NP-завершение для нахождения наибольшей клики), но вы должны проверить сложность своего кода по сравнению с функциями NX.   -  person Gerard Rozsavolgyi    schedule 15.01.2015
comment
Спасибо. Я никоим образом не специалист по информатике, но кажется, что эта проблема, с которой я столкнулся дважды за одну неделю, хорошо известна en.wikipedia.org/wiki/Clique_problem . Я не мог заставить networkx делать то, что я хотел. Мое решение занимает около 20 минут на 78 000 пар (применяется из PostgreSQL с использованием PL/Python), поэтому подходит для текущих задач.   -  person Ian Gow    schedule 15.01.2015
comment
в качестве примечания вы можете заменить if not matched на else (else в этом случае означает, что for достигло условия выхода)   -  person njzk2    schedule 15.01.2015
comment
Если я что-то не упустил, ваша проблема не связана с проблемой клики: вместо этого она по-разному называется консолидацией множества или поиском связанных компонентов в графе (см. также Union-Find).   -  person DSM    schedule 15.01.2015
comment
Это не похоже на клику, a и c не связаны.   -  person njzk2    schedule 15.01.2015
comment
Да, вы правы DSM, это скорее проблема поиска связанных компонентов в графе, и networkx также может легко найти это, и это менее затратно, чем поиск клик.   -  person Gerard Rozsavolgyi    schedule 15.01.2015
comment
Так может быть, теперь вопрос следует переформулировать с помощью связанных компонентов и тегов консолидации вместо клик?   -  person Gerard Rozsavolgyi    schedule 15.01.2015
comment
Применить алгоритм поиска объединения en.wikipedia.org/wiki/Disjoint-set_data_structure   -  person qwr    schedule 21.08.2018


Ответы (4)


С сетьюX:

import networkx as nx
G1=nx.Graph()
G1.add_edges_from([("a","b"),("b","c"),("c","d"),("d","e"),("f","g")])
sorted(nx.connected_components(G1), key = len, reverse=True)

давая:

[['a', 'd', 'e', 'b', 'c'], ['f', 'g']]

Вы должны проверить самый быстрый алгоритм сейчас ...

OP:

Это отлично работает! Теперь это есть в моей базе данных PostgreSQL. Просто организуйте пары в таблицу из двух столбцов, а затем используйте array_agg() для передачи функции PL/Python get_connected(). Спасибо.

CREATE OR REPLACE FUNCTION get_connected(
    lhs text[],
    rhs text[])
  RETURNS SETOF text[] AS
$BODY$
    pairs = zip(lhs, rhs)

    import networkx as nx
    G=nx.Graph()
    G.add_edges_from(pairs)
    return sorted(nx.connected_components(G), key = len, reverse=True)

$BODY$ LANGUAGE plpythonu;

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

person Gerard Rozsavolgyi    schedule 15.01.2015
comment
Кажется, это работает очень хорошо. Для 10 000 пар мой код занял 34 секунды, это меньше секунды. (Это несовершенный тест, так как я работаю через PostgreSQL.) - person Ian Gow; 15.01.2015
comment
Отлично ! Таким образом, networkX, кажется, делает свою работу довольно быстро с помощью простого API. Он имеет оптимизированные алгоритмы. - person Gerard Rozsavolgyi; 15.01.2015
comment
Что касается библиотек графов Python, рассмотрите также Python iGraph (igraph.org/python), который имеет очень похожий API и большое улучшение производительности по сравнению с networkX. Если вы работаете с большой сетью, это определенно стоит того. - person Kevin.; 02.08.2016
comment
Как масштабировать это до миллиардов узлов? У меня похожая проблема. - person Shirish Kumar; 26.10.2016

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

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

from collections import defaultdict

def get_cliques(pairs):
    # Build a graph using the pairs
    nodes = defaultdict(lambda: [])
    for a, b in pairs:
        if b is not None:
            nodes[a].append((b, nodes[b]))
            nodes[b].append((a, nodes[a]))
        else:
            nodes[a]  # empty list

    # Add all neighbors to the same group    
    visited = set()
    def _build_group(key, group):
        if key in visited:
            return
        visited.add(key)
        group.add(key)
        for key, _ in nodes[key]:
            _build_group(key, group)

    groups = []
    for key in nodes.keys():
        if key in visited: continue
        groups.append(set())
        _build_group(key, groups[-1])

    return groups

if __name__ == '__main__':
    pairs = [
        ('a', 'b'), ('b', 'c'), ('b', 'd'), # a "tree"
        ('f', None),                        # no relations
        ('h', 'i'), ('i', 'j'), ('j', 'h')  # circular
    ]
    print get_cliques(pairs)
    # Output: [set(['a', 'c', 'b', 'd']), set(['f']), set(['i', 'h', 'j'])]

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

person André Laszlo    schedule 15.01.2015
comment
Я думаю, что if b: лучше было бы if b is not None: -- иначе вы исключаете 0 как элемент без всякой причины, что нарушило мои тесты. :-) - person DSM; 15.01.2015
comment
Вы не тестируете все предлагаемые решения, не так ли? :D - person André Laszlo; 15.01.2015
comment
@AndréLaszlo Спасибо за ваш ответ. Во-первых, ты прав. Это не проблема клики. Я взял этот термин из случайного использования, которое я прочитал, быстро исследуя это. Я еще не пробовал ваш подход, так как способ networkX работает очень хорошо. Спасибо также за то, что направили меня к идее графовых баз данных. В своей книге Graph Databases Ян Робинсон, Джим Уэббер и Эмиль Эйфрем проводят различие между базовым хранилищем и механизмом обработки. Я предполагаю, что я использую PostgreSQL для первого, но (я думаю) с помощью networkX, какой-то графовой базы данных для последнего. - person Ian Gow; 17.01.2015
comment
@IanGow Я не читал базы данных графов, но идея разделения хранения и обработки интересна. Я предполагаю, что факты, которые вы храните, довольно плоские или хорошо вписываются в реляционный мир? В противном случае я думаю, что Neo4j, например, может иметь хранилище, которое также оптимизировано для общих операций с графами - я не эксперт. Я рад, что вы нашли решение, которое хорошо работает! - person André Laszlo; 17.01.2015

Комментарий DSM заставил меня искать алгоритмы консолидации наборов в Python. Rosetta Code имеет две версии одного и того же алгоритма. Пример использования (нерекурсивная версия):

[('a', 'b'), ('b', 'c'), ('c', 'd'), ('d', 'e'), ('f', 'g')]

# Copied from Rosetta Code
def consolidate(sets):
    setlist = [s for s in sets if s]
    for i, s1 in enumerate(setlist):
        if s1:
            for s2 in setlist[i+1:]:
                intersection = s1.intersection(s2)
                if intersection:
                    s2.update(s1)
                    s1.clear()
                    s1 = s2
    return [s for s in setlist if s]

print consolidate([set(pair) for pair in pairs])
# Output: [set(['a', 'c', 'b', 'd']), set([None, 'f']), set(['i', 'h', 'j'])]
person André Laszlo    schedule 15.01.2015
comment
Вполне вероятно, что в случае с OP это не удастся, поскольку s очень длинный, а пределы рекурсии Python очень низкие. - person DSM; 15.01.2015
comment
Правда, надо было выбрать другой :) Перешел на итеративную версию. - person André Laszlo; 15.01.2015

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

# Modified to use a dictionary
from collections import defaultdict

def get_cliques2(pairs):
  maxClique = 1
  clique = defaultdict(int)
  for (a, b) in pairs:
    currentClique = max(clique[i] for i in (a,b))
    if currentClique == 0:
      currentClique = maxClique
      maxClique += 1
    clique[a] = clique[b] = currentClique
  reversed = defaultdict(list)
  for (k, v) in clique.iteritems(): reversed[v].append(k)
  return reversed

И просто чтобы убедить себя, что он возвращает правильный результат (get_cliques1 вот ваше исходное решение Python 2):

>>> from cliques import *
>>> get_cliques1(pairs) # Original Python 2 solution
[Set(['a', 'c', 'b', 'e', 'd']), Set(['g', 'f'])]
>>> get_cliques2(pairs) # Dictionary-based alternative
[['a', 'c', 'b', 'e', 'd'], ['g', 'f']]

Информация о времени в секундах (с 10 миллионами повторений):

$ python get_times.py 
get_cliques: 75.1285209656
get_cliques2: 69.9816100597

Для полноты и справки, это полный список как cliques.py, так и сценария синхронизации get_times.py:

# cliques.py
# Python 2.7
from collections import defaultdict
from sets import Set  # I moved your import out of the function to try to get closer to apples-apples

# Original Python 2 solution
def get_cliques1(pairs):

    set_list = [Set(pairs[0])]

    for pair in pairs[1:]:
        matched=False
        for set in set_list:
            if pair[0] in set or pair[1] in set:
                set.update(pair)
                matched=True
                break
        if not matched:
            set_list.append(Set(pair))

    return set_list

# Modified to use a dictionary
def get_cliques2(pairs):
  maxClique = 1
  clique = defaultdict(int)
  for (a, b) in pairs:
    currentClique = max(clique[i] for i in (a,b))
    if currentClique == 0:
      currentClique = maxClique
      maxClique += 1
    clique[a] = clique[b] = currentClique
  reversed = defaultdict(list)
  for (k, v) in clique.iteritems(): reversed[v].append(k)
  return reversed.values()

pairs = [('a', 'b'), ('b', 'c'), ('c', 'd'), ('d', 'e'), ('f', 'g')]


# get_times.py
# Python 2.7
from timeit import timeit

REPS = 10000000

print "get_cliques: " + str(timeit(
  stmt='get_cliques1(pairs)', setup='from cliques import get_cliques1, pairs',
  number=REPS
))
print "get_cliques2: " + str(timeit(
  stmt='get_cliques2(pairs)', setup='from cliques import get_cliques2, pairs',
  number=REPS
))

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

person rchang    schedule 15.01.2015
comment
К сожалению, я не думаю, что ваш get_cliques2 разрешает все ссылки; рассмотреть get_cliques2([[0,2],[3,1],[2,1]]). - person DSM; 15.01.2015