Как-то председатель нашего дачного общества обратился ко мне с просьбой создать сайт СНТ. В общем-то, задача тривиальная, но я решил добавить на сайт некое подобие адресной книги, чтобы члены СНТ могли посмотреть телефоны и адреса соседей в случае необходимости. Вроде, всё просто: база данных с полями типа full_name, phone, address и функция поиска по этой БД. Вот тут-то и встала задача: как организовать интеллектуальный поиск? В базе данных в поле full_name все имена записаны в строгом порядке «Фамилия Имя Отчество». База данных сможет найти запись в этой ячейке только в случае строгого соответствия формата запроса и формата ячейки. Представили себе соседку Клавдию Владленовну 83 лет отроду, от которой требуется в поисковой строке ввести запрос в строго определённом порядке? Вот и я не смог представить. Поисковый механизм должен уметь находить Иванова Петра Сидоровича, Петра Сидоровича Иванова, Петра Иванова и Иванова Петра. И даже просто Петра Сидоровича. Естественно, SELECT phone FROM members WHERE full_name LIKE '%Пётр Иванов%' здесь не сработает. Нужен способ, позволяющий каждому из слов поискового запроса сопоставить метку (это — имя, это — фамилия, это — отчество) и затем выстроить слова в нужном порядке. А уж тогда и скармливать базе данных синтаксически точный запрос.



Поскольку я недавно заинтересовался машинным обучением, то и решил применить технологии машинного обучения к поставленной задаче. В общем-то, это всего-лишь задача классификации — получить на вход слово и определить, к какому классу оно относится. Только вот до этого я изучал машинное обучение на Python, а сайт СНТ писал на php. Быстро погуглив, понял, что перенести модель, обученную на питоне, на php — задача не из лёгких. Проще изначально обучать модель средствами языка php. И здесь на помощь приходит проект php-ml.

В первую очередь, для обучения нужен тренировочный набор данных, или датасет. С датасетом я особо не парился — список членов СНТ у меня уже был, из него я и составил датасет. Это CSV-файл такого вида:

"Попов", "surname"
"Новикова", "surname"
"Нелли", "name"
"Михаил", "name"
"Владимировна", "patronymic"
"Сергеевич", "patronymic"


Осталось выбрать классификатор. Я начал с классификатора SVC. Вот код обучения модели:

<?php
	declare(strict_types=1);

	ini_set('memory_limit', '-1'); 
	 
	require_once __DIR__ . '/vendor/autoload.php';

	use Phpml\Dataset\CsvDataset;
	use Phpml\Dataset\ArrayDataset;
	use Phpml\FeatureExtraction\TokenCountVectorizer;
	use Phpml\Tokenization\NGramTokenizer;
	use Phpml\CrossValidation\StratifiedRandomSplit;
	use Phpml\FeatureExtraction\TfIdfTransformer;
	use Phpml\Metric\Accuracy;
	use Phpml\Classification\SVC;
	use Phpml\SupportVectorMachine\Kernel;
	use Phpml\ModelManager;
	use Phpml\Pipeline;
	
	$dataset = new CsvDataset('parts_of_name.csv', 1);

	$samples = [];
	foreach ($dataset->getSamples() as $sample) {
		$samples[] = $sample[0];
	}

	$dataset = new ArrayDataset($samples, $dataset->getTargets());
	$randomSplit = new StratifiedRandomSplit($dataset, 0.1);
	
	$pipeline = new Pipeline([
		new TokenCountVectorizer(new NGramTokenizer(1, 3)),
		new TfIdfTransformer()
	], new SVC(Kernel::RBF, 10000));
	$pipeline->train($randomSplit->getTrainSamples(), $randomSplit->getTrainLabels());
	
	$predictedLabels = $pipeline->predict($randomSplit->getTestSamples());
	echo 'Accuracy: '.Accuracy::score($randomSplit->getTestLabels(), $predictedLabels);
	
	$modelManager = new ModelManager();
	$modelManager->saveToFile($pipeline, realpath(dirname(__FILE__)) . DIRECTORY_SEPARATOR . "classifier-".Accuracy::score($randomSplit->getTestLabels(), $predictedLabels).".model");
?>

Модель получилась отличная, её точность 0.996. Давайте посмотрим на пример использования обученной модели:

<?php
	ini_set('memory_limit', '-1');
	
	require_once __DIR__ . '/vendor/autoload.php';
	
	use Phpml\ModelManager;
	
	$modelManager = new ModelManager();

	$testData = ['Смирнова', 'Николай', 'Алексеев', 'Орлова', 'Зайцев', 'Вячеславовна', 'Ольга'];

	$restoredClassifier = $modelManager->restoreFromFile(realpath(dirname(__FILE__)) . DIRECTORY_SEPARATOR . "classifier-0.99606299212598.model");
	print_r($restoredClassifier->predict($testData));
?>

Результат исполнения этого кода:

Array
(
[0] => surname
[1] => name
[2] => surname
[3] => surname
[4] => surname
[5] => patronymic
[6] => name
)


Всё здорово, кроме одного: модель очень тяжёлая, файл модели весит 60 Мб. При её использовании на рядовом shared-хостинге вы будете постоянно натыкаться на memory limit.

Перебрав несколько вариантов, я остановился на классификаторе LogisticRegression. Обучение мало чем отличается:

<?php
	declare(strict_types=1);

	ini_set('memory_limit', '-1'); 
	 
	require_once __DIR__ . '/vendor/autoload.php';

	use Phpml\Dataset\CsvDataset;
	use Phpml\Dataset\ArrayDataset;
	use Phpml\FeatureExtraction\TokenCountVectorizer;
	use Phpml\Tokenization\NGramTokenizer;
	use Phpml\CrossValidation\StratifiedRandomSplit;
	use Phpml\FeatureExtraction\TfIdfTransformer;
	use Phpml\Metric\Accuracy;
	use Phpml\Classification\Linear\LogisticRegression;
	use Phpml\SupportVectorMachine\Kernel;
	use Phpml\ModelManager;
	use Phpml\Pipeline;
	
	$dataset = new CsvDataset('parts_of_name.csv', 1);

	$samples = [];
	foreach ($dataset->getSamples() as $sample) {
		$samples[] = $sample[0];
	}

	$dataset = new ArrayDataset($samples, $dataset->getTargets());
	$randomSplit = new StratifiedRandomSplit($dataset, 0.1);
	
	$pipeline = new Pipeline([
		new TokenCountVectorizer(new NGramTokenizer(1, 3)),
		new TfIdfTransformer()
	], new LogisticRegression());
	$pipeline->train($randomSplit->getTrainSamples(), $randomSplit->getTrainLabels());
	
	$predictedLabels = $pipeline->predict($randomSplit->getTestSamples());
	echo 'Accuracy: '.Accuracy::score($randomSplit->getTestLabels(), $predictedLabels);
	
	$modelManager = new ModelManager();
	$modelManager->saveToFile($pipeline, realpath(dirname(__FILE__)) . DIRECTORY_SEPARATOR . "classifier-".Accuracy::score($randomSplit->getTestLabels(), $predictedLabels).".model");
?>

Использование — тем более:

<?php
	require_once __DIR__ . '/vendor/autoload.php';
	
	use Phpml\ModelManager;
	
	$modelManager = new ModelManager();

	$testData = ['Смирнова', 'Николай', 'Алексеев', 'Орлова', 'Зайцев', 'Вячеславовна', 'Ольга'];

	$restoredClassifier = $modelManager->restoreFromFile(realpath(dirname(__FILE__)) . DIRECTORY_SEPARATOR . "classifier-0.98031496062992.model");
	print_r($restoredClassifier->predict($testData));
?>

Зато файл модели весит всего 655 Кб. Правда, и точность поменьше: 0.980, но для моих задач хватает с лихвой.

Теперь дело за малым — расставить слова в нужном порядке. Я сделал это как-то так:

<?php
                    $words = explode(' ', $query);
                    $model = new \Phpml\ModelManager();
                    $classifier = $model->restoreFromFile(__DIR__ . '/classifier-0.98.model');
                    $build = Array();

                    foreach($words as $name_part) {
                        $order = -1;
                        $type = $classifier->predict([$name_part]);
                        switch($type[0]) {
                            case 'surname':
                                $order = 0;
                                break;
                            case 'name':
                                $order = 1;
                                break;
                            case 'patronymic':
                                $order = 2;
                                break;
                            default:
                                $order = -1;
                        }
                        $build[$order] = $name_part;
                    }

                    //Убираем повторяющиеся значения
                    $build = array_unique($build);
                    //Удаляем ненужное
                    unset($build[-1]);
                    //Сортируем массив
                    ksort($build);
                    //Склеиваем в строку
                    $full_name = implode(' ', $build);
?>

Теперь вы можете извращаться, как душе угодно, вводя в поисковую строку «Иннокентий Илларионович», «Иннокентий Забодай-Бодайло», «Забодай-Бодайло Иннокентий» и даже «Илларионович»: технологии машинного обучения классифицируют каждое слово, выстроят все слова в строгом порядке и передадут полученный результат старому доброму оператору LIKE, который уж точно что-нибудь найдёт.

Небольшое замечание по поводу обучения модели. На хостинге такими вещами лучше не заниматься — в лучшем случае, ресурсов не хватит. Я обучал модель на своём компьютере:

$ php train.php

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

Для написания статьи был использован список самых распространённых русских фамилий, доступный по этому адресу.

Кросспостинг с моего сайта.

Комментарии (0)