Введение в анализ текстовой информации с помощью Python и методов машинного обучения

    Введение


    Сегодня я продолжу рассказ о применении методов анализа данных и машинного обучения на практических примерах. В прошлой статье мы с вами разбирались с задачей кредитного скоринга. Ниже я попытаюсь продемонстрировать решение другой задачи с того же турнира, а именно «Задачи о паспортах» (Задание №2).
    При решении будут показаны основы анализа текстовой информации, а также ее кодирование для построения модели с помощью Python и модулей для анализа данных (pandas, scikit-learn, pymorphy).

    Постановка задачи


    При работе с большим объёмом данных важно поддерживать их чистоту. А при заполнении заявки на банковский продукт необходимо указывать полные паспортные данные, в том числе и поле «кем выдан паспорт», число различных вариантов написаний одного и того же отделения потенциальными клиентами может достигать нескольких сотен. Важно понимать, не ошибся ли клиент, заполняя другие поля: «код подразделения», «серию/номер паспорта». Для этого необходимо сверять «код подразделения» и «кем выдан паспорт».
    Задача заключается в том, чтобы проставить коды подразделений для записей из тестовой выборки, основываясь на обучающей выборке.

    Предварительная обработка данных


    Загрузим данные и посмотрим, что мы имеем:

    from pandas import read_csv
    import pymorphy2
    from sklearn.feature_extraction.text import HashingVectorizer
    from sklearn.cross_validation import train_test_split
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import accuracy_score, roc_auc_score
    from sklearn.decomposition import PCA
    
    train = read_csv('https://static.tcsbank.ru/documents/olymp/passport_training_set.csv',';', index_col='id' ,encoding='cp1251')
    train.head(5)
    

    passport_div_code passport_issuer_name passport_issue_month/year
    id
    1 422008 БЕЛОВСКИМ УВД КЕМЕРОВСКОЙ ОБЛАСТИ 11M2001
    2 500112 ТП №2 В ГОР. ОРЕХОВО-ЗУЕВО ОУФМС РОССИИ ПО МО ... 03M2009
    3 642001 ВОЛЖСКИМ РОВД ГОР.САРАТОВА 04M2002
    4 162004 УВД МОСКОВСКОГО РАЙОНА Г.КАЗАНЬ 12M2002
    5 80001 ОТДЕЛОМ ОФМС РОССИИ ПО РЕСП КАЛМЫКИЯ В Г ЭЛИСТА 08M2009

    Теперь можно посмотреть как пользователи записывают поле «кем выдан паспорт» на примере какого-либо подразделения:

    example_code = train.passport_div_code[train.passport_div_code.duplicated()].values[0]
    for i in train.passport_issuer_name[train.passport_div_code == example_code].drop_duplicates():
        print i
    

    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РЕСПУБЛИКЕ КАРЕЛИЯ В МЕДВЕЖ. Р-Е
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО Р. КАРЕЛИЯ В МЕДВЕЖЬЕГОРСКОМ РАЙОНЕ
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РЕСП КАРЕЛИЯ В МЕДВЕЖЬЕГОРСКОМ Р-НЕ
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РЕСПУБЛИКЕ КАРЕЛИЯ В МЕДВЕЖЬЕГОРСКОМ РАЙОНЕ
    ОУФМС РОССИИ ПО РЕСПУБЛИКЕ КАРЕЛИЯ В МЕДВЕЖЬЕГОРСКОМ РАЙОНЕ
    УФМС РОССИИ ПО РК В МЕДВЕЖЬЕГОРСКОМ РАЙОНЕ
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РЕСПУБЛИКЕ КАРЕЛИЯ МЕДВЕЖЬЕГОРСКОМ Р-ОНЕ
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РК В МЕДВЕЖЬЕГОРСКОМ РАЙОНЕ
    ОТДЕЛЕНИЕМ УФМС РОССИИ ПО РЕСПУБЛИКЕ КОРЕЛИЯ В МЕДВЕЖИГОРСКОМ РАЙОНЕ
    УФМС РОССИИ ПО Р. КАРЕЛИЯ МЕДВЕЖЬЕГОРСКОГО Р-НА
    ОТДЕЛОМ УФМС РОССИИ ПО РЕСПУБЛИКЕ КАРЕЛИЯ В МЕДВЕЖЬЕГОРСКОМ
    УФМС РЕСПУБЛИКИ КАРЕЛИИ МЕДВЕЖЬЕГОРСКОГО Р-ОН
    МЕДВЕЖЬЕГОРСКИМ ОВД

    Как можно заметить нужно на поле действительно заполняется криво. Но для нормально кодирования мы должны привести это поле к более-менее нормальному (однозначному) виду.
    Для начала я бы предложил привести все записи к одному регистру, например, чтобы все буквы стали строчными. Это легко сделать с помощью атрибута str, столбца DataFrame'a. Этот атрибут позволяет работать со столбцом как с строкой, а также выполнять различного рода поиск и замену по регулярным выражениям:

    train.passport_issuer_name = train.passport_issuer_name.str.lower()
    train[train.passport_div_code == example_code].head(5)
    

    passport_div_code passport_issuer_name passport_issue_month/year
    id
    19 100010 отделением уфмс россии по республике карелия в... 04M2008
    22 100010 отделением уфмс россии по р. карелия в медвежь... 10M2009
    5642 100010 отделением уфмс россии по респ карелия в медве... 08M2008
    6668 100010 отделением уфмс россии по республике карелия в... 08M2011
    8732 100010 отделением уфмс россии по республике карелия в... 08M2012

    C регистром определились. Далее надо по возможности избавиться от популярных сокращений, например район, город и т.д. Сделаем это с помощью регулярных выражений. Pandas предоставляет удобное использование регулярных выражений применительно к каждому столбцу. Это выглядит так:

    train.passport_issuer_name = train.passport_issuer_name.str.replace(u'р-(а|й|о|н|е)*',u'район')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' г( |\.|(ор(\.| )))', u' город ')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' р(\.|есп )', u' республика ')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' адм([а-я]*)(\.)?', u' административный ')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' окр(\.| |уга( )?)', u' округ ')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' ао ', u' административный округ ')
    

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

    train.passport_issuer_name = train.passport_issuer_name.str.replace(u' - ?', u'-')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u'[^а-я -]','')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u'- ',' ')
    train.passport_issuer_name = train.passport_issuer_name.str.replace(u'  *',' ')
    

    На следующем шаге, надо расшифровать аббревиатуры, типа УВД, УФНС, ЦАО, ВАО и т.д., т.к. этих их в принципе не много, но на качестве дальнейшего кодирования это скажется положительно. Например если у нас будет две записи «УВД» и «управление внутренних дел», то закодированы они будут по разному, т. к. для компьютера это разные значения.
    Итак перейдем к расшифровке. И, для начала, заведем словарь сокращений, с помощью которого мы и сделаем расшифровку:

    sokr = {u'нао': u'ненецкий автономный округ',
    u'хмао': u'ханты-мансийский автономный округ',
    u'чао': u'чукотский автономный округ',
    u'янао': u'ямало-ненецкий автономный округ',
    u'вао': u'восточный административный округ',
    u'цао': u'центральный административный округ',
    u'зао': u'западный административный округ',
    u'cао': u'северный административный округ',
    u'юао': u'южный административный округ',
    u'юзао': u'юго-западный округ',
    u'ювао': u'юго-восточный округ',
    u'свао': u'северо-восточный округ',
    u'сзао': u'северо-западный округ',
    u'оуфмс': u'отдел управление федеральной миграционной службы',
    u'офмс': u'отдел федеральной миграционной службы',
    u'уфмс': u'управление федеральной миграционной службы',
    u'увд': u'управление внутренних дел',
    u'ровд': u'районный отдел внутренних дел',
    u'говд': u'городской отдел внутренних дел',
    u'рувд': u'районное управление внутренних дел',
    u'овд': u'отдел внутренних дел',
    u'оувд': u'отдел управления внутренних дел',
    u'мро': u'межрайонный отдел',
    u'пс': u'паспортный стол',
    u'тп': u'территориальный пункт'}
    


    Теперь, собственно произведем расшифровку абривеатур и отформатируем полученные записи:

    for i in sokr.iterkeys():
        train.passport_issuer_name = train.passport_issuer_name.str.replace(u'( %s )|(^%s)|(%s$)' % (i,i,i), u' %s ' % (sokr[i]))
        
    #удалим лишние пробелы в конце и начале строки
    train.passport_issuer_name = train.passport_issuer_name.str.lstrip()
    train.passport_issuer_name = train.passport_issuer_name.str.rstrip()
    

    Предварительный этап обработки поля «кем выдан паспорт» на этом закончим. И перейдем к полю, в котором находится дата выдачи.
    Как можно заметить данные в нем хранятся в виде: месяцMгод.
    Соответственно можно просто убрать букву «M» и привести поле к числовому типу. Но если хорошо подумать, то это поле можно удалить, т.к. на один месяц в году может приходиться несколько подразделений выдававших паспорт, и соответственно это может испортить нашу модель. Исходя из этого удалим его из выборки:

    train = train.drop(['passport_issue_month/year'], axis=1)
    

    Теперь мы можем перейти к анализу данных.

    Анализ данных


    Итак, данные для построения модели у нас есть, но они находятся в текстовом виде. Для построения модели хорошо бы было их закодировать в числовом виде.
    Авторы пакета scikit-learn заботливо о нас позаботились и добавили несколько способов для извлечения и кодирования текстовых данных. Из них мне больше всего нравятся два:
    1. FeatureHasher
    2. CountVectorizer
    3. HashingVectorizer

    FeatureHasher преобразовывает строку в числовой массив заданной длинной с помощью хэш-функции (32-разрядная версия Murmurhash3)
    CountVectorizer преобразовывает входной текст в матрицу, значениями которой, являются количества вхождения данного ключа(слова) в текст. В отличие от FeatureHasher имеет больше настраиваемых параметров(например можно задать токенизатор), но работает медленнее.
    Для более точного понимания работы CountVectorizer приведем простой пример. Допустим есть таблица с текстовыми значениями:
    Значение
    раз два три
    три четыре два два
    раз раз раз четыре

    Для начала CountVectorizer собирает уникальные ключи из всех записей, в нашем примере это будет:

    [раз, два, три, четыре]

    Длина списка из уникальных ключей и будет длиной нашего закодированного текста (в нашем случае это 4). А номера элементов будут соответствовать, количеству раз встречи данного ключа с данным номером в строке:

    раз два три --> [1,1,1,0]
    три четыре два два --> [0,2,1,1]

    Соответственно после кодировки, применения данного метода мы получим:
    Значение
    1,1,1,0
    0,2,1,1
    3,0,0,1

    HashingVectorizer является смесью двух выше описанных методов. В нем можно и регулировать размер закодированной строки (как в FeatureHasher) и настраивать токенизатор (как в CountVectorizer). К тому же его производительность ближе к FeatureHasher.
    Итак, вернемся к анализу. Если мы посмотрим по внимательнее на наш набор данных то можно заметить, что есть похожие строки но записанные по разному например: "… республика карелия..." и "… по республике карелия...".
    Соответственно, если мы попробуем применить один из методов кодирования сейчас мы получим очень похожие значения. Такие случаем можно минимизировать если все слова в записи мы приведем к нормальной форме.
    Для этой задачи хорошо подходит pymorphy или nltk. Я буду использовать первый, т.к. он изначально создавался для работы с русским языком. Итак, функция которая будет отвечать за нормализацию и очиску строки выглядит так:

    def f_tokenizer(s):
        morph = pymorphy2.MorphAnalyzer()
        if type(s) == unicode:
            t = s.split(' ')
        else:
            t = s
        f = []
        for j in t:
            m = morph.parse(j.replace('.',''))
            if len(m) <> 0:
                wrd = m[0]
                if wrd.tag.POS not in ('NUMR','PREP','CONJ','PRCL','INTJ'):
                    f.append(wrd.normal_form)
        return f
    

    Функция делает следующее:
    • Сначала она преобразовывает строку в список
    • Затем для всех слов производит разбор
    • Если слово является числительным, предикативном, предлогом, союзом, частицей или междометием не включаем его в конечный набор
    • Если слово не попало в предыдущий список, берем его нормальную форму и добавляем в финальный набор

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

    coder = HashingVectorizer(tokenizer=f_tokenizer, n_features=256)
    

    Как можно заметить при создании метода кроме токенизатора мы задаем еще один параметр n_features. Через данный параметр задается длина закодированной строки (в нашем случае строка кодируется при помощи 256 столбцов). Кроме того, у HashingVectorizer есть еще одно преимущество перед CountVectorizer, но сразу может выполнять нормализацию значений, что хорошо для таких алгоритмов, как SVM.
    Теперь применим наш кодировщик к обучающему набору:

    TrainNotDuble = train.drop_duplicates()
    trn = coder.fit_transform(TrainNotDuble.passport_issuer_name.tolist()).toarray()
    

    Построение модели


    Для начала нам надо задать значения для столбца, в котором будут содержаться метки классов:

    target = TrainNotDuble.passport_div_code.values
    

    Задача, которую мы решаем сегодня, принадлежит к классу задач классификации со множеством классов. Для решения данной задачи лучше всего подошел алгоритм RandomForest. Остальные алгоритмы показали очень плохие результаты (менее 50%) поэтому я решил не занимать место в статье. При желании любой интересующийся может проверить данные результаты.
    Для оценки качества классификации будем использовать количество документов по которым принято правильное решение, т. е.

    , где P — количество документов по которым классификатор принял правильное решение, а N – размер обучающей выборки.
    В пакете scikit-learn для этого есть функция: accuracy_score
    Перед началом построения собственно модели, давайте сократим размерность с помощью «метода главных компонент», т.к. 256 столбцов для обучения довольно много:

    pca = PCA(n_components = 15)
    trn = pca.fit_transform(trn)
    

    Модель будет выглядеть так:

    model = RandomForestClassifier(n_estimators = 100, criterion='entropy')
    
    TRNtrain, TRNtest, TARtrain, TARtest = train_test_split(trn, target, test_size=0.4)
    model.fit(TRNtrain, TARtrain)
    print 'accuracy_score: ', accuracy_score(TARtest, model.predict(TRNtest))
    

    accuracy_score: 0.6523456

    Заключение


    В качестве вывода нужно отметить, что полученная точность в 65% близка к угадыванию. Чтобы улучшить нужно при первичной обработке обработать грамматические ошибки и различного рода описки. Данное действие также скажется положительно и на словаре при кодировании поля, т. е. его размер уменьшиться и соответственно уменьшиться длина строки после ее кодировки.
    Кроме того этап обучения тестовой выборки опущен специально, т. к. в нем нет ничего особенного, кроме его приведения к нужному виду (это можно легко сделать взяв за основу преобразования обучающей выборки)
    В статье я попытался показать минимальный список этапов по обработке текстовой информации для подачи ее алгоритмам машинного обучения. Возможно делающим первые шаги в анализе данных данная информация будет полезной.

    UPD: Консоль IPython Notebook TKCTask2Answer.ipynb
    Метки:
    • +38
    • 25,4k
    • 8
    Поделиться публикацией
    Комментарии 8
    • +2
      Почему бы не иметь список всех органов (структур), которые выдают паспорта и сделать полне не свободным для заполнения, а выборочным из списка?
      • 0
        Вы представляете их количество?
        • 0
          Не аргумент. Разделить на регионы, на города, на районы.
          • 0
            Уменьшит скорость заполнения по-моему.
            • 0
              лучше всего — набор с подсказками а-ля suggest
        • 0
          *поле, конечно.
          • +2
            По хорошему ПО, через которое вводится данная информация, должно бы проектироваться именно с этим условием. Но задача заключалась именно в анализе выгрузки в таком виде, как описан в статье.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.