Удаление дубликатов объектов Author во множестве

41. Удаление дубликатов объектов Author во множестве

Условие зададчи:
Из нескольких источников поступают данные об одном и том же авторе научных публикаций. Данные из каждого источника преобразуются в объект класса Author, после чего все объекты добавляются во множество set для автоматического удаления дублей.
Нужно доработать класс Author так, чтобы при добавлении объектов в множество дубликаты автоматически схлопывались в один объект.
Дубликатами считаются записи, у которых совпадает хотя бы один идентификатор или одно из значений полей. Например, если у двух авторов совпадает rinc_id, такие записи должны считаться одним и тем же автором, даже если имя записано по-разному.
Ожидаемое поведение по примеру на скрине: после добавления нескольких пересекающихся записей во множестве должен остаться один объект с объединенными данными.

from dataclasses import dataclass
from typing import List, Optional


@dataclass
class Author:
    name: Optional[List[str]] = None
    rinc_id: Optional[List[str]] = None
    scopus_id: Optional[List[str]] = None


if __name__ == '__main__':
    all_author = set()
    all_author.add(Author(['Иван'], ['123321']))
    all_author.add(Author(['Иванов'], ['123321']))
    all_author.add(Author(['Иванов'], scopus_id=['123321']))
    all_author.add(Author(['Иван'], scopus_id=['123321']))
    print(all_author)  # -> {Author(name=['Иванов', 'Иван'], rinc_id=['123321'], scopus_id=['123321'])}
Спойлеры к решению
Подсказки
  • Обычный set сам по себе не умеет объединять данные объектов.
  • set.add() может только добавить объект или не добавить его, если считает дубликатом.
  • Если объект уже есть в set, новый объект просто отбрасывается, но данные старого объекта автоматически не обновляются.
  • Для корректного решения нужен отдельный контейнер, который при добавлении ищет пересечение и вручную объединяет данные.
  • Дубликатами считаем авторов, у которых пересекается хотя бы одно значение в name, rinc_id или scopus_id.
  • При нахождении дубликата нужно объединить списки без повторов.
Решение
from dataclasses import dataclass, field
from typing import Iterable


@dataclass
class Author:
    name: list[str] = field(default_factory=list)
    rinc_id: list[str] = field(default_factory=list)
    scopus_id: list[str] = field(default_factory=list)

    def has_intersection(self, other: "Author") -> bool:
        return (
            bool(set(self.name) & set(other.name))
            or bool(set(self.rinc_id) & set(other.rinc_id))
            or bool(set(self.scopus_id) & set(other.scopus_id))
        )

    def merge(self, other: "Author") -> None:
        self.name = self._merge_lists(self.name, other.name)
        self.rinc_id = self._merge_lists(self.rinc_id, other.rinc_id)
        self.scopus_id = self._merge_lists(self.scopus_id, other.scopus_id)

    @staticmethod
    def _merge_lists(first: list[str], second: list[str]) -> list[str]:
        result = []

        for value in first + second:
            if value not in result:
                result.append(value)

        return result


class AuthorSet:
    def __init__(self):
        self.authors: list[Author] = []

    def add(self, author: Author) -> None:
        for existing_author in self.authors:
            if existing_author.has_intersection(author):
                existing_author.merge(author)
                return

        self.authors.append(author)

    def __iter__(self):
        return iter(self.authors)

    def __repr__(self):
        return repr(set(map(repr, self.authors)))

Пример использования:

if __name__ == "__main__":
    all_author = AuthorSet()

    all_author.add(Author(["Иван"], ["123321"]))
    all_author.add(Author(["Иванов"], ["123321"]))
    all_author.add(Author(["Иванов"], scopus_id=["123321"]))
    all_author.add(Author(["Иван"], scopus_id=["123321"]))

    for author in all_author:
        print(author)

Результат:

Author(name=['Иван', 'Иванов'], rinc_id=['123321'], scopus_id=['123321'])

Здесь обычный set заменён на специальный контейнер AuthorSet.

Причина: обычный set не подходит для такой логики. Он использует __hash__ и __eq__, чтобы понять, есть ли объект во множестве. Но если объект уже есть, set не вызывает никакого автоматического объединения данных. Новый объект просто не добавляется.

Поэтому правильная логика такая:

1. При добавлении нового автора пройтись по уже сохранённым авторам.
2. Проверить, есть ли пересечение по name, rinc_id или scopus_id.
3. Если пересечение есть — объединить данные.
4. Если пересечения нет — добавить автора как новую запись.

Если строго пытаться сделать это через обычный set, придётся мутировать объекты внутри __eq__, но это плохая практика. Такой код будет неочевидным, нестабильным и может нарушить правила работы set.

Более аккуратный вариант с сохранением вывода как у множества:

from dataclasses import dataclass, field


@dataclass
class Author:
    name: list[str] = field(default_factory=list)
    rinc_id: list[str] = field(default_factory=list)
    scopus_id: list[str] = field(default_factory=list)

    def has_intersection(self, other: "Author") -> bool:
        return any([
            set(self.name) & set(other.name),
            set(self.rinc_id) & set(other.rinc_id),
            set(self.scopus_id) & set(other.scopus_id),
        ])

    def merge(self, other: "Author") -> None:
        self.name = list(dict.fromkeys(self.name + other.name))
        self.rinc_id = list(dict.fromkeys(self.rinc_id + other.rinc_id))
        self.scopus_id = list(dict.fromkeys(self.scopus_id + other.scopus_id))


class AuthorSet:
    def __init__(self):
        self._items: list[Author] = []

    def add(self, author: Author) -> None:
        for item in self._items:
            if item.has_intersection(author):
                item.merge(author)
                return

        self._items.append(author)

    def __repr__(self):
        return "{" + ", ".join(repr(item) for item in self._items) + "}"


all_author = AuthorSet()

all_author.add(Author(["Иван"], ["123321"]))
all_author.add(Author(["Иванов"], ["123321"]))
all_author.add(Author(["Иванов"], scopus_id=["123321"]))
all_author.add(Author(["Иван"], scopus_id=["123321"]))

print(all_author)

Результат:

{Author(name=['Иван', 'Иванов'], rinc_id=['123321'], scopus_id=['123321'])}

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