Метка: строки

Нечеткое сравнение текстов на Python с FuzzyWuzzy

Недавно мы обсуждали расчет расстояния Левеншейна, настало время испытать его применение на практике. Библиотека FuzzyWuzzy содержит набор функций для нечеткого поиска строк, дедупликации (удаления копий), корректировки ошибок. Она позволяет стать поиску умнее, помогая преодолеть влияние человеческого фактор.

Начнем с установки:

pip install fuzzywuzzy python-Levenshtein

Модуль python-Levenshtein можно устанавливать по желанию: работать будет и без него, но с ним гораздо быстрее (в разы). Поэтому советую его установить, он мелкий, порядка 50 Кб.

Похожесть двух строк

Задача: есть две строки, требуется вычислить степень их похожести числом от 0 до 100. В FuzzyWuzzy для этого есть несколько функций, отличающихся подходом к сравнению и вниманием к деталям. Не забудем импортировать:

from fuzzywuzzy import fuzz

Функция fuzz.ratio – простое посимвольное сравнение. Рейтинг 100 только если строки полностью равны, любое различие уменьшает рейтинг, будь то знаки препинания, регистр букв, порядок слов и так далее:

>>> fuzz.ratio("я люблю спать", "я люблю спать")
100
>>> fuzz.ratio("я люблю спать", "Я люблю cпать!")
81
>>> fuzz.ratio("я люблю спать", "я люблю есть")
88

Обратите внимание, что рейтинг второго примера ниже, чем у третьего, хотя по смыслу должно быть наоборот.

Следующая функция fuzz.token_sort_ratio решает эту проблему. Теперь акцент именно на сами слова, игнорируя регистр букв, порядок слов и даже знаки препинания по краям строки.

>>> fuzz.token_sort_ratio("я люблю спать", "я люблю есть")
56
>>> fuzz.token_sort_ratio("я люблю спать", "Я люблю спать!")
100
>>> fuzz.token_sort_ratio("я люблю спать", "спать люблю я...")
100

>>> fuzz.token_sort_ratio("Мал да удал", "удал да МАЛ")
100
>>> fuzz.token_sort_ratio("Мал да удал", "Да Мал Удал")
100

Однако, смысл пословицы немного изменился, а рейтинг остался на уровне полного совпадения.

Функция fuzz.token_set_ratio пошла еще дальше: она игнорирует повторяющиеся слова, учитывает только уникальные.

>>> fuzz.token_set_ratio("я люблю спать", "люблю я спать, спать, спать...")
100
>>> fuzz.token_set_ratio("я люблю спать", "люблю я спать, спать и спать...")
100
>>> fuzz.token_set_ratio("я люблю спать", "но надо работать")
28

# повторы в token_sort_ratio роняют рейтинг! 
>>> fuzz.token_sort_ratio("я люблю спать", "люблю я спать, спать и спать.")
65

# но вот это странно:
>>> fuzz.token_set_ratio("я люблю спать", "люблю я спать, но надо работать")
100
>>> fuzz.token_set_ratio("я люблю спать", "люблю я спать, люблю я есть")
100

Последние два примера вернули 100, хотя добавлены новые слова, и это странно. Тут следует вспомнить о fuzz.partial_ratio, которая ведет себя также. А именно, проверяет вхождение одной строки в другую. Лишние слова игнорируются, главное – оценить, чтобы ядро было одно и тоже.

>>> fuzz.partial_ratio("одно я знаю точно", "одно я знаю")
100
>>> fuzz.partial_ratio("одно я знаю точно", "одно я знаю!")
92
>>> fuzz.partial_ratio("одно я знаю точно", "я знаю")
100

Еще еще более навороченный метод fuzz.WRatio, который работает ближе к человеческой логике, комбинируя несколько методов в один алгоритм в определенными весами (отсюда и название WRatio = Weighted Ratio).

>>> fuzz.WRatio("я люблю спать", "люблю Я СПАТЬ!")
95
>>> fuzz.WRatio("я люблю спать", "люблю Я СПАТЬ и есть")
86
>>> fuzz.WRatio("я люблю спать", "!!СПАТЬ ЛЮБЛЮ Я!!")
95

Нечеткий поиск

Задача: найти в списке строк одну или несколько наиболее похожих на поисковый запрос.

Импортируем подмодуль и применим process.extract или process.extractOne:

from fuzzywuzzy import process

strings = ['привет', 'здравствуйте', 'приветствую', 'хай', 'здорова', 'ку-ку']
process.extract("Прив", strings, limit=3)
# [('привет', 90), ('приветствую', 90), ('здравствуйте', 45)]

process.extractOne("Прив", strings)
# ('привет', 90)

Удаление дубликатов

Очень полезная функция для обработки данных. Представьте, что вам досталась 1С база номенклатуры запчастей, там полный бардак, и вам нужно поудалять лишние повторяющиеся позиции товара, но где-то пробелы лишние, где-то буква перепутана и тому подобное. Тут пригодится process.dedupe.

dedupe(contains_dupes, threshold=70, scorer=token_set_ratio)

Первый аргумент – исходный список, второй – порог исключения (70 по умолчанию), третий – алгоритм сравнения (token_set_ratio по умолчанию).

Пример:

arr = ['Гайка на 4', 'гайка 4 ГОСТ-1828 оцинкованная', 'Болты на 10', 'гайка 4 ГОСТ-1828 оцинкованная ...', 'БОЛТ']

print(list(process.dedupe(arr)))
# ['гайка 4 ГОСТ-1828 оцинкованная ...', 'Болты на 10', 'БОЛТ']

FuzzyWuzzy можно применять совместно с Pandas. Например так (без особых подробностей):

def get_ratio(row):
    name = row['Last/Business Name']
    return fuzz.token_sort_ratio(name, "Alaska Sea Pilot PAC Fund")

df[df.apply(get_ratio, axis=1) > 70]

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Расстояние Левенштейна на Python

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

Одинаковые строки имеют нулевое расстояние: не нужно ничего редактировать, первая и так равна второй. «Привет» и «Привт» имеют расстояние 1 (пропущена одна буква, остальное не изменилось). «Привет» и «привты» имеют расстояние 3 (одна замена «П» на «п», удаление буквы «е» и вставка «ы» на конце). Мы будем считать

Я не буду сюда копировать теорию и тем более доказательства, это вы можете изучить в Вики.

Давайте попробуем реализовать этот алгоритм в лоб по формуле:

Рекурсивная формула

Функция m – возвращает единицу, если символы не равны, иначе 0. Вот такой код получится:

def my_dist(a, b):
    def recursive(i, j):
        if i == 0 or j == 0:
            # если одна из строк пустая, то расстояние до другой строки - ее длина
            # т.е. n вставок
            return max(i, j)
        elif a[i - 1] == b[j - 1]:
            # если оба последних символов одинаковые, то съедаем их оба, не меняя расстояние
            return recursive(i - 1, j - 1)
        else:
            # иначе выбираем минимальный вариант из трех
            return 1 + min(
                recursive(i, j - 1),  # удаление
                recursive(i - 1, j),   # вставка
                recursive(i - 1, j - 1)  # замена
            )
    return recursive(len(a), len(b))

Вспомогательная функция, чтобы протестировать производительность:

from timeit import timeit

def test_lev_dist(f: callable, a, b, n=1):
    tm = timeit("f(a, b)", globals={
        'f': f, 'a': a, 'b': b
    }, number=n)
    r = f(a, b)
    print(f'a = {a!r} and b = {b!r} and {f.__name__} = {r} and time = {tm:.4f}')

test_lev_dist(my_dist, "hello world", "bye world!")
# a = 'hello world' and b = 'bye world!' and my_dist = 6 and time = 1.3829

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

def count_calls(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
        wrapped.n_calls += 1
        return f(*args, **kwargs)
    wrapped.n_calls = 0
    return wrapped

def my_dist(a, b):
    @count_calls
    def recursive(i, j):
        ...  # прочий код пропущен
    r = recursive(len(a), len(b))
    print('calls =', recursive.n_calls)
    return r

my_dist("hello world", "bye world!")
# calls =  3317804

Более 3 миллионов вызовов! И большинство из них с одинаковыми параметрами. А так как они повторяются, то можно их закешировать (при помощи lru_cache). Здесь размер кэша примерно равен числу различных комбинаций входных параметров.

from functools import lru_cache

def my_dist_cached(a, b):
    @count_calls
    @lru_cache(maxsize=len(a) * len(b))
    def recursive(i, j):
        if i == 0 or j == 0:
            return max(i, j)
        elif a[i - 1] == b[j - 1]:
            return recursive(i - 1, j - 1)
        else:
            return 1 + min(
                recursive(i, j - 1), 
                recursive(i - 1, j), 
                recursive(i - 1, j - 1)
            )

    r = recursive(len(a), len(b))
    print('calls = ', recursive.n_calls)
    return r

my_dist_cached("hello world", "bye world!")
# calls = 212

Количество вызовов сократилось до 212, а скорость работы увеличилась на порядки. Выкинем count_calls и проведем замеры времени для 10000 повторных вызовов.

def my_dist_cached(a, b):
    @lru_cache(maxsize=len(a) * len(b))
    def recursive(i, j):
        if i == 0 or j == 0:
            return max(i, j)
        elif a[i - 1] == b[j - 1]:
            return recursive(i - 1, j - 1)
        else:
            return 1 + min(
                recursive(i, j - 1),
                recursive(i - 1, j),
                recursive(i - 1, j - 1)
            )
    return recursive(len(a), len(b))

test_lev_dist(my_dist_cached, "hello world", "bye world!", n=10000)
# a = 'hello world' and b = 'bye world!' and my_dist_cached = 6 and time = 0.9740

Производительность улучшилась радикально (в прошлый раз мы прогоняли один вызов функции, а теперь 10000 раз, и то выходит быстрее). Однако, пока что объем требуемой памяти растет как O(len(a) * len(b)). Иными словами, для двух мегабайтных строк потребуются гигабайты памяти. Фактически в кэше будет хранится почти все матрица редактирований, а она нам не нужна целиком. Наша цель – правый нижний элемент.

Матрица редактирований recursive(i, j)
Матрица редактирований recursive(i, j)

Для его поиска можно обойтись лишь парой рядов: текущим и предыдущим. А остальные ряды не хранить в памяти. Так мы дойдем до конца таблицы, и нижний правый угол и будет искомым значением.

Вот пример реализации:

def distance(a, b):
    n, m = len(a), len(b)
    if n > m:
        # убедимся что n <= m, чтобы использовать минимум памяти O(min(n, m))
        a, b = b, a
        n, m = m, n

    current_row = range(n + 1)  # 0 ряд - просто восходящая последовательность (одни вставки)
    for i in range(1, m + 1):
        previous_row, current_row = current_row, [i] + [0] * n
        for j in range(1, n + 1):
            add, delete, change = previous_row[j] + 1, current_row[j - 1] + 1, previous_row[j - 1]
            if a[j - 1] != b[i - 1]:
                change += 1
            current_row[j] = min(add, delete, change)

    return current_row[n]

Объяснение. Сначала, чтобы использовать еще меньше памяти, мы можем поменять местами наши строки, чтобы длина рядов была минимальна. Это существенно экономит память, если одна из строк длинная, а другая короткая.

Затем мы понимаем, что нулевой ряд или столбец матрицы – просто восходящая последовательность. Потому что, чтобы из пустой строки получить любую не пустую, нужно ровно то число вставок, сколько символов в не пустой строке. И наоборот: n удалений из строки длины n приведут неизбежно к пустой строке.

Нам достаточно пары рядов.
Тут на картинке не ряды, а столбцы, но смысла это не меняет.

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

Эта версия еще быстрее, чем кэшированная:

 test_lev_dist(distance, "hello world", "bye world!", n=10000)
# a = 'hello world' and b = 'bye world!' and distance = 6 and time = 0.7374

Сложность этого алгоритма растет как произведение длин строк: O(n*m). Это еще не самый эффективный алгоритм. Для дальнейшего ускорения нужно воспользоваться древовидной структурой данных. Также неплохо бы учесть то, что на известном словаре можно заранее вычислить расстояния между словами.

Наконец-то, когда мы разобрались с принципом работы алгоритма, вспомним, что все велосипеды уже написаны до нас, да еще и на языке Си. Воспользуемся библиотечными функциями, установив пакет:

 pip install python-Levenshtein
import Levenshtein

test_lev_dist(Levenshtein.distance, "hello world", "bye world!", n=10000)
# a = 'hello world' and b = 'bye world!' and distance = 6 and time = 0.0032

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

bin, oct, hex – системы исчисления

Вспомним основы информатики и поговорим о системах исчисления. В жизни мы привыкли к десятичной системе (base-10 или decimal), железо компьютеров оперирует в системе двоичной (base-2 или binary) – нулями и единицами. Часто приходится иметь дело с системой шестнадцатиричной (base-16 или hexadecimal), она позволяет записывать данные в 8 раз короче, чем двоичная. Реже встречается система восьмеричная (base-8 или octal). Система исчисления – это всего лишь средство представления числа, т.е. то, как мы его в строчку запишем или считаем, само число остается самим собой, независимо от системы.

Чтобы получить целое число из строки, записанной в какой-то системе исчисления, используем функцию int (второй параметр – база системы):

>>> int('42')  # по умолчанию 10 
42
>>> int('42', 10)  # тоже самое
42
>>> int('101010', 2)
42
>>> int('BEEF', 16)
48879
>>> int('7654', 8)
4012

Наоборот сделать из числа строку в какой-то системе – встроенные функции bin, oct и hex:

>>> bin(42)
'0b101010'
>>> hex(48879)
'0xbeef'
>>> oct(4012)
'0o7654'

В строке появились префиксы 0b, 0x и 0o. Как и в Си, в Python можно использовать эти префиксы для записи чисел в коде помимо обычного десятичного варианта, если это требуется:

>>> 0b11011, 0xDEAD, 0o777
(27, 57005, 511)

Однако, часто приходится иметь дело с чистыми HEX-данными без префиксов. Многие (да и я) делали вот так:

>>> hex(12345)[2:]
'3039'

Т.е. просто отрезали первые два символа. Это не очень интуитивно, да и может привести к неправильному поведению, если передать отрицательное число:

>>> hex(-12345)
'-0x3039'
>>> hex(-12345)[2:]
'x3039'

К счастью, есть встроенная функция format(value, format_spec) (не путать с str.format), которая форматирует значение, согласно спецификатору форматирования:

>>> format(3039, 'x'), format(3039, 'X'), format(3039, '#x')
('bdf', 'BDF', '0xbdf')
>>> format(120, 'b')
'1111000'
>>> format(79, 'o')
'117'
  • x — 16-ричное без префикса, маленькие букв
  • X — 16-ричное без префикса, заглавные буквы
  • #x — 16-ричное с префиксом, маленькие буквы
  • b — двоичное число без префикса и т.д.

format(value, format_spec) эквивалентная вызову type(value).__format__(value, format_spec).

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Флаги преобразования

При форматировании строк доступны 3 флага преобразования объекта в строку: !r, !s и !a.

>>> x = "дом"
>>> f'{x}'
'дом'
>>> f'{x!r}'
"'дом'"
>>> f'{x!s}'
'дом'
>>> f'{x!a}'
"'\\u0434\\u043e\\u043c'"

Для фанатов format:

>>> '{0!r}'.format(x)
"'дом'"
>>> '{!r}'.format(x)
"'дом'"

Флаг !r вызывает repr(x), а флаг !s вызывает str(x). Флаг !a вызывает ascii(repr(x)). Функция ascii превращает все символы за пределами набора ASCII (включая русские буквы в юникоде) в их коды. Если флаг не указан, то по умолчанию считается, что он !s.

Для классов __repr__ и __str__ могут иметь различное определение:

class Foo:
    def __repr__(self):
        return "репр"
    def __str__(self):
        return "строка"
x = Foo()
print(f'{x!r}')  # репр
print(f'{x!s}')  # строка

Если __str__ нет, то будет вызван __repr__.

Рекомендации: __str__ должен давать нам человеко-читаемое описание объекта, а __repr__ – уникальное представление объекта, по которому можно частично или полностью восстановить состояние этого объекта или хотя бы помочь с отладкой. __str__ – для пользователей, __repr__ — для питонистов.

📎 Отличный пример для наглядности – datetime:

>>> import datetime
>>> dt = datetime.datetime(2019, 7, 27)
>>> repr(dt)
'datetime.datetime(2019, 7, 27, 0, 0)'
>>> str(dt)
'2019-07-27 00:00:00'
>>> eval(repr(dt)) == dt
True

str от datetime просто покажет нам дату и время в удобном формате; repr от datetime вернет строку, в которой будет вызов описан конструктора конкретно этого объекта, да так, что при исполнении этой строки как кода на Python функцией eval – мы получим объект datetime для той же даты. Впрочем, никто нас не обязывает делать этот трюк для каждого объекта.

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈