Алгоритм группировки похожих строк для поиска дубликатов

Рейтинг: 1Ответов: 1Опубликовано: 24.05.2023

Есть функция по удалению дубликатов строк, которая работает по следующему алгоритму. Подготовка строк: очистка от спец символов, пробелов, нижний регистр, разделение на шинглы (n-граммы). Во время подготовки строки добавляются в набор Set. Если набор не разрешил добавление, найден точный дубликат (полное равенство строк abc == abc). В результате получаем набор строк, из которых удалены полные дубли. Следующий этап это поиск похожих строк с помощью Dice-коэффициента. Если коэффициент больше заданного значения (0.84), строки считаются дублями.

Проблема в количестве итераций при сравнении коэффициентов. Худший вариант это сравнение каждого с каждым. Существенный прирост появляется, если предварительно сгруппировать строки по первому шинглу. Например, среди 10 тысяч строк только 300 начинаются с "ma". Тогда они сравниваются только друг с другом. Соответственно минус такого ускорения в том, что схожие строки с разным написанием первого шингла никогда не встретятся.

Строки всегда короткие. К примеру, "исполнитель название трека". Как правило исполнитель не содержит ошибки в первом шингле, но такое возможно для менее популярных или для названий из файлов.

skillet hero
sillet hero remix
pvris monster
awolnation sail
awolantion sail cover
...

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

fun List<IExecutorItem>.deduplicate(model: DeduplicatorModel): List<IExecutorItem> {
    val shingleSize = 2
    val baskets = mutableMapOf<String, MutableSet<Ngrams>>()
    val comparableItems = linkedMapOf<IExecutorItem, MutableSet<Ngrams>>()
    val comparableNames = mutableSetOf<String>()

    this.forEach { item ->
        item.getCleanNames(model.targetEntityType, false).forEach { name ->
            if (comparableNames.add(name)) {
                val iSet = DiceCoefficient.splitToNgrams(name, shingleSize)
                comparableItems.getOrPut(item) { mutableSetOf() }.run { add(iSet) }
                baskets.getOrPut(iSet.map.keys.first()) { mutableSetOf() }.run { add(iSet) }
            }
        }
    }

    return comparableItems.entries.fold(mutableListOf()) { acc, (item, shingles) ->
        shingles.forEach { iSet ->
            baskets[iSet.map.keys.first()]?.forEach { jSet ->
                if (iSet == jSet || !DiceCoefficient.isDuplicate(iSet, jSet, model.minDice)) {
                    acc.add(item)
                    return@fold acc
                }
            }
        }
        return@fold acc
    }
}

data class Ngrams(
    val map: LinkedHashMap<String, Int>,
    val maxIntersection: Int,
)

object DiceCoefficient {

    const val DEFAULT_DICE_THRESHOLD = 0.84

    fun splitToNgrams(list: List<String>, gramSize: Int): List<Ngrams> {
        return list.map { splitToNgrams(it, gramSize) }
    }

    fun splitToNgrams(text: String, gramSize: Int): Ngrams {
        val cleanString = text.replace("""\s""".toRegex(), "")
        val items = linkedMapOf<String, Int>()
        for (i in 0 until cleanString.length - gramSize + 1) {
            val ngram = cleanString.substring(i, i + gramSize)
            items[ngram] = items[ngram]?.plus(1) ?: 1
        }
        return Ngrams(items, items.values.sum())
    }

    fun isDuplicate(x: Ngrams, y: Ngrams, threshold: Double): Boolean {
        val minIntersection = (threshold * (x.map.size + y.map.size)) / 2.0
        if (x.maxIntersection < minIntersection || y.maxIntersection < minIntersection) {
            return false
        }

        var intersection = 0
        var yLeftNrams = y.maxIntersection
        val xNgramMap = HashMap(x.map)
        for (yNgram in y.map.keys) {
            val count = xNgramMap[yNgram] ?: 0
            if (count > 0) {
                intersection += 1
                if (intersection >= minIntersection) {
                    return true
                }
                xNgramMap[yNgram] = count - 1
            }
            yLeftNrams -= 1
            if (yLeftNrams < (minIntersection - intersection)){
                return false
            }
        }
        return false
    }
}

Ответы

▲ 1

Попробуй использовать один из алгоритмов кластеризации, например k-means (он самый простой и хорош тем, что можно задать определённое число кластеров).

Алгоритм k-means работает следующим образом:

  1. Инициализировать центры кластеров случайными значениями.
  2. Найти расстояние от каждой строки до каждого центра кластера.
  3. Присвоить каждой строке тот кластер, расстояние до центра которого минимально.
  4. Пересчитать координаты центров кластеров как среднее значение всех строк, отнесенных к этому кластеру.
  5. Повторять шаги 2-4 до тех пор, пока кластеры не перестанут изменяться или будет достигнуто максимальное количество итераций.

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

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