Недавно я прошел курс «Обработка естественного языка с использованием вероятностных моделей» от deeplearning.ai на Coursera. Этот курс охватывал широкий круг тем, таких как Коррекция орфографии, Добавление тегов речи, Моделирование языка и Преобразование Word в вектор . Все темы были освещены подробно и с подробными практическими упражнениями.
Но все мы знаем, что если мы не практикуем изученные концепции, мы обязательно забудем о них в кратчайшие сроки. Итак, я подумал о том, чтобы написать этот блог об одной из очень важных метрик, которые были рассмотрены в курсе - «Изменить расстояние или расстояние Левенштейна».
В этой статье будут рассмотрены следующие темы:
- Введение в редактирование расстояния
- Реализуйте это с помощью рекурсии
- Понять динамическое программирование и его реализация
- Работайте над проблемой, используя полученные навыки
Что такое Edit Distance и как его реализовать?
Расстояние редактирования или расстояние Левенштейна (наиболее распространенное) - это метрика для вычисления сходства между парой последовательностей. Расстояние между двумя последовательностями измеряется как количество изменений (вставка, удаление или замена), необходимых для преобразования одной последовательности в другую.
В этом разделе мы научимся реализовывать расстояние редактирования.
Метрика расстояния
Расстояние Левенштейна рассчитывается по следующей формуле:
Где хвост означает остальную часть последовательности, за исключением 1-го символа, на жаргоне Python это a[1:]
.
Пояснения к условиям:
- Если b - пустая последовательность (
|b|=0
), тогда стоимость равна длине a (|a|
). - Если a - пустая последовательность (
|a|=0
), тогда стоимость равна длине b (|b|
). - Если первые символы a и b совпадают (
a[0] = b[0]
), то стоимость - это стоимость хвоста подпоследовательности (a) (a[1:]
) и хвоста (b) (b[1:]
). - Наконец, стоимость - это минимум операции вставки, удаления или замены, которые определены:
lev(tail(a), b)
указывает, что символ удален изlev(a, tail(b)
указывает, что символ вставлен вlev(tail(a), tail(b))
обозначает замену
Примечание. Здесь, в приведенной выше формуле, стоимость вставки, удаления или замены осталась прежней, то есть 1
. Но стоимость замены обычно рассматривается как 2
, что мы и будем использовать при реализации.
Изменить расстояние с помощью рекурсии
Мы можем напрямую преобразовать приведенную выше формулу в рекурсивную функцию для вычисления расстояния редактирования между двумя последовательностями, но временная сложность такого решения составляет 𝑂 (3 (𝑚 + 𝑛)).
Итак, как только мы проясним, как работает дистанционное редактирование, мы напишем для него более оптимизированное решение, используя динамическое программирование, имеющее временную сложность 𝑂 (𝑚 ∗ 𝑛).
Ниже представлена рекурсивная функция. Я также добавлю немного повествования, то есть функцию для распечатки операций (вставка, удаление или замена), которые она выполняет.
# Below are the costs of different operations. ins_cost = 1 del_cost = 1 sub_cost = 2 # Below function will take the two sequence and will return the distance between them. def edit_distance_recurse(seq1, seq2, operations=[]): """Returns the Edit Distance between the provided two sequences.""" if len(seq2) == 0: operations = operations + ([f"Delete `{seq1}` from sequence1."] if len(seq1) else []) return len(seq1), operations if len(seq1) == 0: operations = operations + ([f"Insert `{seq2}` into sequence1."] if len(seq2) else []) return len(seq2), operations if seq1[0] == seq2[0]: operations = operations + [f"Make no change for character `{seq1[0]}`."] return edit_distance_recurse(seq1[1: ], seq2[1: ], operations) # calculate cost if insertion was made ins_operations = operations + [f"Insert `{seq2[0]}` in sequence1."] insertion, ins_operations = edit_distance_recurse(seq1, seq2[1: ], ins_operations) # calculate cost if deletion was done del_operations = operations + [f"Delete `{seq1[0]}` from sequence1."] deletion, del_operations = edit_distance_recurse(seq1[1: ], seq2, del_operations) # calculate cost if substitution was done sub_operations = operations + [f"Replace `{seq1[0]}` in sequence1 with `{seq2[0]}`."] substitution, sub_operations = edit_distance_recurse(seq1[1: ], seq2[1: ], sub_operations) # calculate minimum cost min_cost = min(insertion + ins_cost, deletion + del_cost, substitution + sub_cost) if min_cost == (substitution + sub_cost): return min_cost, sub_operations elif min_cost == deletion + del_cost: return min_cost, del_operations else: return min_cost, ins_operations
Давайте проверим эту функцию на нескольких примерах
seq1 = "numpy" seq2 = "numexpr" score, operations = edit_distance_recurse(seq1, seq2) print(f"Edit Distance between `{seq1}` & `{seq2}` is: {score}") print("\nOperations performed are:\n") for operation in operations: print(operation)
Вывод:
Edit Distance between `numpy` & `numexpr` is: 4 Operations performed are: Make no change for character `n`. Make no change for character `u`. Make no change for character `m`. Insert `e` in sequence1. Insert `x` in sequence1. Make no change for character `p`. Replace `y` in sequence1 with `r`.
Причина, по которой расстояние редактирования должно быть 4
: символы n,u,m
остаются неизменными (отсюда и стоимость 0), затем вставляются e & x
, в результате чего общая стоимость на данный момент составляет 2
. Затем не было внесено никаких изменений в p
, поэтому не изменилась стоимость и, наконец, y is replaced with r
, что привело к дополнительной стоимости в 2 раза.
Следовательно, общая стоимость составляет 4
.
Изменить расстояние с помощью динамического программирования
Динамическое программирование может применяться к проблемам, имеющим перекрывающиеся подзадачи. Как и в нашем случае, где получить расстояние редактирования между numpy
и numexpr
, мы сначала вычисляем то же самое для подпоследовательностей nump
и nume
, затем для numpy
и numex
и так далее ...
Как только мы решаем конкретную подзадачу, мы сохраняем ее результат, который позже используется для решения общей проблемы.
Чтобы узнать больше о динамическом программировании, вы можете обратиться к моему короткому руководству - Введение в динамическое программирование.
Теперь давайте разберемся, как разбить проблему на подзадачи, сохранить результаты, а затем решить общую проблему.
На изображении ниже - по строкам у нас есть sequence1
, который мы хотим преобразовать в sequence2
(который находится по столбцам) с минимальной стоимостью преобразования.
Символ #
перед двумя последовательностями указывает на пустую строку или начало строки.
Теперь мы заполним эту матрицу стоимостью различных подпоследовательностей, чтобы получить общее решение. Но сначала давайте посмотрим на базовые случаи:
- Когда
sequence1
пусто, тогда стоимость полученияsequence2
- это просто стоимость добавления символов, которые присутствуют вsequence2
. Первая строка в Maxtrix указывает, чтоsequence1
пусто.
- Если обе последовательности пусты, то стоимость
0
.
- Если мы добавим символ
n
кsequence1
, мы получим стоимость1
.
- Таким же образом мы заполним нашу первую строку, где значение в каждом столбце равно
1 + previous column value
, т.е. добавляется стоимость добавления еще одного символа.
- Примечание. значение 7 в последнем столбце означает, что если
sequence1
пусто, то стоимость преобразованияsequence1
вsequence2
составляет7
. Кроме того, стоимость преобразования пустой последовательности в подпоследовательность'num'
составляет3
. - Напротив, у нас есть случай, когда
sequence2
пусто, аsequence1
нет. Затем значения в строках представляют стоимость удаления символов для получения пустой последовательности.
- Примечание. здесь стоимость
5
представляет собой общую стоимость удаления всех символовsequence1
и получения пустогоsequence2
.
Теперь матрица с заполненными базовыми значениями затрат будет иметь следующий вид:
Решение подзадач и заполнение матрицы.
- Значение в (’n’, ’n’) равно
0
, поскольку оба символа являются одинаковыми и, следовательно, конверсия не взимается.
- В приведенной ниже матрице показано, что стоимость преобразования
#n
в#nu
составляет1
, поскольку стоимость подстрок#n
&#n
составляет0
, мы добавляем только стоимость добавленияu
кsub-sequence1
.
- Как и выше, стоимость преобразования
#nu
в#n
составляет1
, поскольку стоимость подстрок#n
&#n
составляет0
, мы добавляем только стоимость удаленияu
изsub-sequence1
.
- После нескольких итераций матрица будет выглядеть, как показано ниже. Примечание. Подпоследовательности
#num
&#num
для пользователей стоят0
, поскольку они идентичны.
- До сих пор мы рассматривали только операции вставки и удаления, но теперь мы также рассмотрим пример подстановки. Чтобы найти подпоследовательности
#nump
&#nume
, мы сначала вычислим стоимость подпоследовательностей#num
&#num
(которая, как мы отметили выше, составляет0
), следовательно, общая стоимость равна 0 + 2 = 20 + 2 = 2 - стоимость заменыp
наe
.
- Полная матрица приведена ниже, а общая стоимость указана в последнем столбце последней строки -
4
.
Кроме того, отслеживая минимальную стоимость от последнего столбца последней строки до первого столбца первой строки, мы можем получить операции, которые были выполнены для достижения этой минимальной стоимости.
Приведенная ниже функция позволяет выполнять операции с минимальными затратами.
def min_cost_path(cost, operations): # operation at the last cell path = [operations[cost.shape[0]-1][cost.shape[1]-1]] # cost at the last cell min_cost = cost[cost.shape[0]-1][cost.shape[1]-1] row = cost.shape[0]-1 col = cost.shape[1]-1 while row >0 and col > 0: if cost[row-1][col-1] <= cost[row-1][col] and cost[row-1][col-1] <= cost[row][col-1]: path.append(operations[row-1][col-1]) row -= 1 col -= 1 elif cost[row-1][col] <= cost[row-1][col-1] and cost[row-1][col] <= cost[row][col-1]: path.append(operations[row-1][col]) row -= 1 else: path.append(operations[row][col-1]) col -= 1 return "".join(path[::-1][1:])
Нижеприведенные функции вычисляют расстояние редактирования с использованием динамического программирования
def edit_distance_dp(seq1, seq2): # create an empty 2D matrix to store cost cost = np.zeros((len(seq1)+1, len(seq2)+1)) # fill the first row cost[0] = [i for i in range(len(seq2)+1)] # fill the first column cost[:, 0] = [i for i in range(len(seq1)+1)] # to store the operations made operations = np.asarray([['-' for j in range(len(seq2)+1)] \ for i in range(len(seq1)+1)]) # fill the first row by insertion operations[0] = ['I' for i in range(len(seq2)+1)] # fill the first column by insertion operation (D) operations[:, 0] = ['D' for i in range(len(seq1)+1)] operations[0, 0] = '-' # now, iterate over earch row and column for row in range(1, len(seq1)+1): for col in range(1, len(seq2)+1): # if both the characters are same then the cost will be same as # the cost of the previous sub-sequence if seq1[row-1] == seq2[col-1]: cost[row][col] = cost[row-1][col-1] else: insertion_cost = cost[row][col-1] + ins_cost deletion_cost = cost[row-1][col] + del_cost substitution_cost = cost[row-1][col-1] + sub_cost # calculate the minimum cost cost[row][col] = min(insertion_cost, deletion_cost, substitution_cost) # get the operation if cost[row][col] == substitution_cost: operations[row][col] = 'S' elif cost[row][col] == ins_cost: operations[row][col] = 'I' else: operations[row][col] = 'D' return cost[len(seq1), len(seq2)], min_cost_path(cost, operations)
Выполните указанную выше функцию для образцов последовательностей.
seq1 = "numpy" seq2 = "numexpr" score, operations = edit_distance_dp("numpy", "numexpr") print(f"Edit Distance between `{seq1}` & `{seq2}` is: {score}") print("\nOperations performed are:\n") for operation in operations: if operation == '-': print('No Change.') elif operation == 'I': print('Insertion') elif operation == 'D': print('Deletion') else: print('Substitution')
Вывод:
Edit Distance between `numpy` & `numexpr` is: 4.0 Operations performed are: No Change. No Change. No Change. Insertion Deletion No Change. Substitution
Используйте "Изменить расстояние", чтобы решить проблему.
Набор данных, который мы собираемся использовать, содержит файлы, содержащие список пакетов с установленными их версиями для двух версий языка Python: 3.6 и 3.9.
Записи пакета Pandas в двух файлах:
pandas
pandas==1.2.1
В этом упражнении для каждого пакета, упомянутого в одном файле, мы найдем наиболее подходящий из второго файла. Пригодность будет зависеть от расстояния Левенштейна или метрики «Изменить расстояние».
Загрузите данные
def preprocessor(package): """ This takes a input package and applies preprocessing steps like converting to lowercase, strip the `\n` and `space` from the ends. """ return package.lower().strip()
Загрузите требования Python 3.6
# Open the file and read the list of packages with open('/kaggle/input/pip-requirement-files/Python_ver36.txt', 'r') as f: py36 = f.readlines() # clean the data py36 = list(map(preprocessor, py36)) print("Number of packages for Python 3.6 are: ", len(py36)) print(f"\nFew of the records are:\n{py36[:5]}")
Вывод:
Number of packages for Python 3.6 are: 276 Few of the records are: ['absl-py==0.11.0', 'alabaster==0.7.12', 'anaconda-client==1.7.2', 'anaconda-project==0.8.3', 'appdirs']
Загрузите требования Python 3.9
with open('/kaggle/input/pip-requirement-files/Python_ver39.txt', 'r') as f: py39 = f.readlines() # clean the data py39 = list(map(preprocessor, py39)) print("Number of packages for Python 3.9 are: ", len(py39)) print(f"\nFew of the records are:\n{py39[:5]}")
Вывод:
Number of packages for Python 3.9 are: 146 Few of the records are: ['alabaster==0.7.12', 'anyio==2.0.2', 'appdirs==1.4.4', 'argon2-cffi==20.1.0', 'astroid==2.4.2']
Получите попарное расстояние между файлами требований
Теперь, когда мы создали функцию для расчета расстояния редактирования между двумя последовательностями, мы будем использовать ее для расчета оценки между двумя пакетами из двух разных файлов требований.
Затем для каждого пакета, упомянутого в файле требований версии Python 3.6, мы найдем наиболее подходящий пакет из файла версии Python 3.9.
# to store the best matching package for py36 found in py39 p36_best_match = {} # for each package in py36 get the score for pack36 in py36: best_score = float('inf') best_package = None # match with each package in py39 for pack39 in py39: # get the edit distance between pack36 and pack39 score, _ = edit_distance_dp(pack36, pack39) # if the score is less than best score so far # store the new score and package name if score < best_score: best_score = score best_package = pack39 # append the details of best package found for pack36 p36_best_match[pack36] = (best_package, best_score) # print the results for pack36, (pack39, score) in p36_best_match.items(): print(f"Best matching package for `{pack36}` with distance of {score} is `{pack39}`")
Некоторые из выходных записей:
Best matching package for `absl-py==0.11.0` with distance of 9.0 is `py==1.10.0` Best matching package for `alabaster==0.7.12` with distance of 0.0 is `alabaster==0.7.12` Best matching package for `anaconda-client==1.7.2` with distance of 15.0 is `nbclient==0.5.1` Best matching package for `anaconda-project==0.8.3` with distance of 17.0 is `odo==0.5.0` Best matching package for `appdirs` with distance of 7.0 is `appdirs==1.4.4` Best matching package for `argh` with distance of 10.0 is `rsa==4.7`
Проверьте правильность вышеуказанного решения
Для этого мы просто обрежем часть версии имен пакетов ==x.x.x
как из py36, так и из наиболее подходящего пакета из py39, а затем проверим, совпадают они или нет.
# this function will trim the versions and return of they are same or not def is_same(pack1, pack2): return pack1.split('==')[0] == pack2.split('==')[0] print(f"Are packages `pandas` and `pandas==1.1.1` same? {is_same('pandas', 'pandas==1.1.1')}") Are packages `pandas` and `pandas==1.1.1` same? True
Получите точность
# get total number of packages in py36 total_packs_in_py36 = len(py36) # get the count of records where match was found total_matched_records = sum([is_same(pack36, pack39) for pack36, (pack39, _) in p36_best_match.items()]) # get the accuracy accuracy = total_matched_records * 1.0 / total_packs_in_py36 print(f"The total number of correct matches are: {total_matched_records} out of {total_packs_in_py36} and the accuracy is: {accuracy:.2f}")
Вывод:
The total number of correct matches are: 138 out of 276 and the accuracy is: 0.50
Давайте посмотрим на приведенный ниже пример, чтобы понять, почему у нас такая низкая точность.
Лучшим подходящим пакетом для
xlrd
с расстоянием 10,0 являетсяrsa==4.7
# find the actual corresponding record of 'xlrd' in py39 list for pack39 in py39: if pack39.startswith('xlrd'): print(pack39) break
Вывод:
В списке py39 нет соответствующей записи «xlrd», т. Е. Он никогда не устанавливался для версии Python 3.9.
Количество записей в py36 составляет 276, а в py39 - только 146, поэтому мы можем найти совпадающие имена только для 53% ( 146/276) записей списка py36.
Кроме того, использованные данные были загружены на Kaggle, и доступ к рабочему блокноту можно получить с помощью - https://www.kaggle.com/pikkupr/implement-edit-distance-from-sratch
Надеюсь, что объяснения были ясными, и вы узнали из этой записной книжки, и дайте мне знать в комментариях, если у вас возникнут какие-либо вопросы.