Python, Text mining

Поиск и лемматизация ФИО без Наташ

Время прочтения: 11 мин.

Очень часто в задачах текст майнинга требуется реализовать вытаскивание имён, года рождения, паспортных данных и т.п. из объемного текста. Для получения ФИО из текста существует библиотека “Natasha”. Но, когда имена в тексте попадаются “нестандартные” и очень редкие, библиотека, к сожалению, пасует. Также сложности появляются, когда ФИО находится не в именительном падеже, и/или имя и отчество записаны инициалами, да ещё и с неправильными знаками препинания и отступами. Плюс ко всему, мало того что “Natasha” может неверно извлечь ФИО, так ещё его нужно привести к нормальной форме (именительный падеж). Для этого существуют некоторые библиотеки, но справляются они также не со всеми именами, да и если “Natasha” неверно извлекла имя, дальнейшая лемматизация ФИО не имеет значения.

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

Следует заметить, что все дальнейшие имена в примерах были случайным образом изменены в силу политики конфиденциальности, любое совпадение с реальными людьми — случайно.

Итак, имеется огромная таблица с интересующей нас колонкой, в которой данные имеют следующий вид:

Вот в таком разнородном стиле заполнено много сотен тысяч строк с уймой опечаток, ошибок, неточностей.

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

def text_is_upper(text):
    """Функция определения написан текст заглавными буквами и строчными"""
    text = re.sub('[^А-Яа-я]', '',text)    # Убираем лишние символы 
    try:
        # % загл. букв
        percent_upper = sum(map(str.isupper, text)) / len(text)
        if percent_upper > 0.8:  # Загл. букв больше- строка капсовая
            return True
        else:
            return False
    except Exception as e:
        return False


def find_name(text):
    """Функция поиска ФИО"""
    text = str(text)          # Для уверенности переводим в строку
    text = text.replace('Ё','Е')          
    text = text.replace('ё','е')          
    # Удаляем двойные пробелы:
    text = re.sub(r'\s+',' ', text)
    # Ищем паттерн Урова Елена Михайловна / ГЛУХИХ АННА АЛЕКСЕЕВНА
    # name = None   # Инициализация переменной вытянутого имени
    """ Паттерн:
     Первое слово - Загл. буква, далее не пробел, не точка, 
    не запятая, далее могут идти строчные буквы
     Второе слово - Заглавная буква + строчные 
    или одна заглавная как инициал
     После - пробел или точка для отделения инициала
     Третье слово - аналогично второму. 
    (Урова Елена Михайловна, ГЛУХИХ АННА АЛЕКСЕЕВНА, Заяц А.О) """
    
    pattern = r'((\b[A-Я][^A-Я\s\.\,][a-я]*)(\s+)([A-Я][a-я]*)'+\
    '(\.+\s*|\s+)([A-Я][a-я]*))'

    # Если строка не написана капсом:
    if not text_is_upper(text):   
        name = re.findall(pattern, text)
        # Разбиваем ФИО на три строки Ф, И, О:
        if name:
            FIO = name[0][0].replace('.',' ')
            FIO = re.sub(r'\s+',' ', FIO).split(' ')
            if len(FIO) >= 3:
                return FIO[0], FIO[1], FIO[2]
            elif len(FIO) == 2:
                return FIO[0], FIO[1], 'пусто'
            elif len(FIO) == 1:
                return FIO[0], 'пусто', 'пусто' 
        else:
            return 'пусто','пусто','пусто'
    # Если строка капсовая:
    else:        
        # Определяем по слову перед ФИО :(
        try: # Индекс, откуда начинается ФИО осужденного:
            start_number_index = re.search(
                r'(\bосужд\w{0,} )|(\bосужд\. )',
                text, flags=re.I).end()
            text_cut = text[start_number_index:]
            FIO = text_cut.replace('.',' ')
            FIO = re.sub(r'\s+',' ', FIO).split(' ')
            if len(FIO) >= 3:
                return FIO[0], FIO[1], FIO[2]
            elif len(FIO) == 2:
                return FIO[0], FIO[1], 'пусто'
            elif len(FIO) == 1:
                return FIO[0], 'пусто', 'пусто'
        except Exception:
            return 'пусто','пусто','пусто'

Ниже df[‘Purpose’] — колонка таблицы, где находится интересующий нас текст.

# Получаем общую колонку ФИО соответсвует с 3мя ответами
df['ФИО'] = df['PURPOSE'].apply(lambda text: find_name(text))
# Распределяем на три столбца
df['Ф'] = df['ФИО'].apply(lambda x: x[0])
df['И'] = df['ФИО'].apply(lambda x: x[1])
df['О'] = df['ФИО'].apply(lambda x: x[2])
df = df.drop(['ФИО'], axis=1)

Вот так с помощью “регулярок”, мы предварительно вытащили ФИО из текста и разделили по разным столбцам. Поехали дальше… Теперь хотелось бы определить в каком падеже находится наше имя (соответственно фамилия и отчество тоже будут в этом падеже). Поступим так: для начала с какого-нибудь сайта сделаем выгрузку мужских и женских имён. Пример полученного файла с именами:

В коде ниже готовые тестовые файлы со списком имён называются “мужские имена.txt” и “женские_имена.txt”. Сформируем из текстовых данных списки и добавим в них некоторые редкие имена, отсутствующие в исходном файле, но присутствующие в наших данных (это делается путём прогонки алгоритма и сортировкой результатов по неотработанным именам).

male_names = open('мужские имена.txt', encoding = 'utf-8').read()[1:]
female_names = open('женские имена.txt', encoding = 'utf-8').read()[1:]
list_male_names = male_names.lower().split('\n\n')
list_female_names = female_names.lower().split('\n\n')
# Добавим редкие имена, которые встречаются в нашей таблице:
list_male_names.extend(['эдуард', 'айдар', 'алик', 'рустам',
                        'ильдар', 'азамат','равиль','альбек',
                        'магомед','яков','радион','вугар','дамир',
                        'марсель','радик','ильгиз', 'аким', 'ашот',
                        'наиль', 'рафаииль','эрик', 'ильсур',
                        'роберт','ленар','кайрат','арсен',
                        'гаврил','камиль','феликс'])
list_female_names.extend(['зинаида', 'марса','анжела', 'зуфара',
                          'лиана','камила','лиа','дария','даниэла',
                          'леся','роза','юлианна','дина'])
list_male_names = list(map(lambda x: x.replace('ё','е'),
                           list_male_names)) 
list_female_names = list(map(lambda x: x.replace('ё','е'),
                             list_female_names))

Список имён получили. Далее пробуем пройтись по полученному столбцу df[‘Имя’] и преобразовать имя в именительный падеж при необходимости. Вся логика основана на окончании слова, в коде ниже есть комментарии 🙂

def percent_similarity(comparing_name, list_of_names):
    """ Функция сравнения имён """ 
    if comparing_name.lower() in list_of_names:
        # если извл.имя есть в списке имён - оно в им.п.:
        return comparing_name    
    # ПавлЕ, ПавлУ, ПавлОМ... (одно из имен не подх.под алгор.):
    if comparing_name.lower().startswith('павл'):  
        return 'павел'
    # Если имени нет в списке имен в им.падеже, ищем в др. падеже:
    for name_from_list in list_of_names:         
        # Если извлеченное имя длинее имени из списка имен:
        if len(comparing_name) > len(name_from_list):    
            # Ищем имя из списка:
            # Родительный падеж
            if comparing_name[:-1] == name_from_list: # ТимурА>Тимур 
                return name_from_list       # им. падеж возвращаем
                break                                     
            # Творительный падеж
            elif (comparing_name[:-2] == name_from_list[:-1] and \
                  comparing_name[-2:] == 'ем') or \
            (comparing_name[:-2] == name_from_list and \
             comparing_name[-2:] == 'ом'):  # ВикторОМ, РоманОМ...                 
                return name_from_list       # АлексеЕМ, ВиталиЕМ...
                break
        # Если длина имени одинаковая и отл. буква в конце слова
        elif len(comparing_name) == len(name_from_list) and \
        comparing_name[:-1] == name_from_list[:-1]: # ОльгЕ > ОльгА    
            return name_from_list                   
            break                                     

# Преобразуем мужское и женское имена в им.падеж
df['Имя_мужское_им.падеж'] = df['И'].apply(
    lambda name: percent_similarity(name.lower(), list_male_names))    
df['Имя_женское_им.падеж'] = df['И'].apply(
    lambda name: percent_similarity(name.lower(), list_female_names))  
df['Имя_мужское_им.падеж'] = df['Имя_мужское_им.падеж'].fillna('пусто')
df['Имя_женское_им.падеж'] = df['Имя_женское_им.падеж'].fillna('пусто')

Так, получили именительный падеж имён для мужских и женских имён. Но ведь у нас встречаются имена, которые применимы и к женщине, и к мужчине. Например, женское имя Александра может быть воспринято как мужское имя Александр в родительном падеже (У Александра есть яблоко).

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

def choose_F_M_name(M_name, F_name, Patronymic):
    """Функция определения имя относится к мужчине 
    или женщине (напр. Александра - Александр, Евгения - Евгений)"""
    # Если percent_similarity определила и женское, и мужское имя:
    if M_name != 'пусто' and F_name != 'пусто':
        # Как правило, предпоследняя буква отчества у женщин - "н". 
        # "ИгоревНе, НиколаевНа". Поэтому смотрим пред.букву
        try:
            if Patronymic[-2].lower() == 'н':
                M_name = 'пусто'  # если есть "н" - удаляем муж. имя
            else:
                F_name = 'пусто'  # если нет "н" - удаляем жен. имя
        except:
            pass
    return M_name, F_name

# Удаляем мужское/женское имя при дублировании
df['transit'] = df.apply(lambda x:choose_F_M_name(
    x['Имя_мужское_им.падеж'],
    x['Имя_женское_им.падеж'],
    x['О']), axis = 1)
df['Имя_мужское_им.падеж'] = df['transit'].apply(lambda x: x[0])
df['Имя_женское_им.падеж'] = df['transit'].apply(lambda x: x[1])
df = df.drop(['transit'], axis = 1)

Получили имена в именительном падеже. Посмотрим, что у нас имеется:

Теперь нужно что-то сделать с фамилией и отчеством. Будем проверять, если имя было преобразовано в именительный падеж (значения для каждого объекта в столбцах вытянутого имени и имени в именительном падеже разнятся), тогда пытаемся преобразовать отчество и фамилия. Опять же, всё делается из соображений по окончаниям слов в разных падежах.

def surname_patronymic_nominative(M_name,F_name,F,O,I):
    """Функция преобр. фамилии и отчества в им. падеж"""
    """Если имя уже было преобразовано в именительный"""
    F_new, O_new = F,O   # Инициализируем текущими значениями
    F, O = F.lower(), O.lower()
    # Мужчина Male:
    if M_name != I.lower() and M_name != 'пусто':
        if F[-1:] == 'а':           # Род.падеж
            F_new = F[:-1]          # ЕвстифеевА > Евстифеев
        elif F[-2:] == 'ым':        # Твор.падеж
            F_new = F[:-2]          # ЕвстифеевЫМ > Евстифеев
        elif F[-5:] == 'ского':     # Твор.падеж
            F_new = F[:-5] +'ский'  # РяховСКОГО > РяховСКИЙ
        elif F[-5:] == 'цкого':     # Твор.падеж
            F_new = F[:-5] +'цкий'  # МудиЦКОГО > МудиЦКИЙ    
        else:
            F_new = F               # Соловых > Соловых
        if O[-1:] == 'а':           # ИгоревичА > Игоревич
            O_new = O[:-1] 
    # Женщина Female:
    elif F_name != I.lower() and F_name != 'пусто':
        if F[-4:] == 'ской':        # Род.падеж
            F_new = F[:-4]+'ская'   # СлабинСКОЙ > СлабинСКАЯ
        elif F[-4:] == 'цкой':      # Род.падеж
            F_new = F[:-4]+'цкая'   # ХмельниЦКОЙ > ХмельниЦКАЯ            
        elif F[-2:] == 'ой':        # Род.падеж обычное окончание
            F_new = F[:-2]+'a'      # ХлебниковОЙ > ХлебниковА
        else:
            F_new = F               # Кличко > Кличко
        if O[-1:] == 'ы':           # МихайловнЫ > МихайловнА
            O_new = O[:-1]+'a'             
    if len(F_new) > 1 and len(O_new) > 1: # Возвращаем фам. и отч.
        return F_new[0].upper()+F_new[1:], O_new[0].upper()+O_new[1:]
    else:
        return F_new, O_new         # Возвращаем инициалы

# Формируем фамилию и отчество в именительном падеже, 
# Если имя было трансформировано в именительный:
df['Фамилия_Отчество_именительное'] = df.apply(
    lambda x: surname_patronymic_nominative(x['Имя_мужское_им.падеж'],
                                            x['Имя_женское_им.падеж'],
                                            x['Ф'],x['О'],x['И']), axis=1) 
df['Фам_им'] = df['Фамилия_Отчество_именительное'].apply(lambda x: x[0])
df['Отч_им'] = df['Фамилия_Отчество_именительное'].apply(lambda x: x[1])
df = df.drop(['Фамилия_Отчество_именительное'], axis=1)             

Посмотрим, что мы имеем теперь. ФИО есть в именительном падеже:

Далее почистим ненужные столбцы. И посмотрим, где имя и отчество записаны как инициалы.

def Finaly_name(M_name, F_name):
    """Получаем итоговое имя"""
    if M_name != 'пусто':
        return M_name[0].upper()+M_name[1:]  #  мужское имя в им.пад
    elif M_name == 'пусто' and F_name != 'пусто':
        return F_name[0].upper()+F_name[1:]  #  женское имя в им.пад

# Получаем одну колонку имени. Мужской и женский варианты del
df['Имя_им'] = df.apply(lambda x: 
                        Finaly_name(x['Имя_мужское_им.падеж'],
                                    x['Имя_женское_им.падеж']),axis=1)
df = df.drop(['Имя_мужское_им.падеж', 'Имя_женское_им.падеж'],axis=1)

cols = ['PURPOSE', 'Ф', 'И', 'О', 'Фам_им',
        'Имя_им', 'Отч_им', 'CLIENTDATE']
df = df[cols]           # Оставили нужные нам столбцы

# Заполним имя_им, где остались пропуски, значением вытянутого
df['Имя_им'] = df['Имя_им'].fillna(df['И'])  

# Посмотрим, где инициалы в ИО
df[df['И'].apply(lambda x: len(x)==1)].head()

Так как фамилии с «а» в окончании могут принадлежать мужчине в родительном падеже и женщине в именительном, нужно понять, как будем это различать. Попробуем выяснить, сколько мужчин и сколько женщин получилось в нашей выборке.

df['Male_Name'] = df['Имя_им'].apply(lambda x: x.lower() in list_male_names)
df['Female_Name'] = df['Имя_им'].apply(lambda x: x.lower() in list_female_names)
print ('Соотношение мужских имён к женским:', 
       df['Male_Name'].value_counts()[1]/df['Female_Name'].value_counts()[1])
df = df.drop(['Male_Name', 'Female_Name'], axis=1)

Мужских имён значительно больше в нашей таблице. Будем определять окончание «а» как родительный падеж мужского имени. Осталось отделить несклоняемые фамилии от склоняемых.

def surname_nominative(F):
    """Функция преобр.фамилии в им.падеж, где имя, отч.инициальные"""
    try:
        # Мужские фамилии:
        if F[-1] == 'а' and F[-2] != 'х': # ПетруХа: ха.. не склоняем
            return F[:-1]                 # Муж. фамилия в род.пад                 
        elif F[-2:] == 'ым':
            return F[:-4]                 # Муж. фамилия в твор.пад 
        elif F[-5:] == 'ского':
            return F[:-5] + 'ский'        # Муж. фамилия в твор.пад 
        elif F[-5:] == 'цкого':
            return F[:-5] + 'цкий'        # Муж. фамилия в твор.пад 
        # Женские фамилии:
        elif F[-4:] == 'цкой':
            return F[:-4] + 'цкая'        # Жен. фамилия в твор.пад          
        elif F[-4:] == 'ской':
            return F[:-4] + 'ская'        # Жен. фамилия в твор.пад        
        elif F[-2:] == 'ой':
            return F[:-2] + 'а'           # Жен. фамилия в твор.пад
        else:
            return F                      # Без изменения
    except Exception as e:
        return F

# Преобр. фамилию у инициальных ИО в им.падеж
list_index = df[df['Имя_им'].apply(
    lambda x: len(x)) == 1].index.tolist()
df['Фам_им'][list_index] = df['Фам_им'][list_index].apply(
    lambda x: surname_nominative(x))

df[df['Имя_им'].apply(lambda x: len(x)) == 1].head(5)

Готово. Теперь на некоторых более-менее ярких примерах сравним результаты отработки готовыми библиотеками и нашим алгоритмом.

Пример отработки готовыми библиотеками:

Колонки с индексом “_1” здесь несут ФИО, вытянутое “Наташей”, с индексом “_2” – финальное ФИО в именительном падеже лемматизированное какой-то библиотекой.

Нетрудно заметить, что результат очень “так-себе”.

Посмотрим, как справился с этими полями наш алгоритм:

Алгоритм отработал на данных примерах замечательно.

Так мы убедились, что под специфичные задачи иногда бывает выгодно (по соотношению трудозатраты/качество) сделать собственный алгоритм, нежели использовать какие-либо готовые решения. Конечно, текущий алгоритм не идеален, и его можно дорабатывать и дальше, но текущее качество отработки удовлетворяет требованиям, поэтому дальнейшие “допилы” нецелесообразны 😊.

Советуем почитать