Pull to refresh

Автоматическое определение языка произвольного текста на РНР — библиотека PHPLangautodetect

Reading time 15 min
Views 20K
Знаете, работа с стартапе, который пытается создать что-то новое и уникальное на рынке очень захватывает. И не только открывающимися возможностями, но и часто нетривиальными задачами и вопросами, которые ставятся перед создателями и которые раньше никто не решал. Вот один из таких вопросов как раз вчера появился передо мной: дано нам произвольную строку текста, заведомо известно, что она может быть двух, а в некоторых случаях и трехъязычной, то есть там смешанный текст из нескольких языков. Необходимо прозрачно для пользователя определить язык, на котором написан текст.

На самом деле задача не такая и редкая — подобная функциональность есть и в текстовых редакторах, и в переключателе клавиатуры PuntoSwitcher, да и в системах машинного перевода такой функционал востребован, не говоря уже про системы поиска информации. Кстати, именно в контексте создания специализированного поисковика и классификатора текстов и появилась такая проблема. Необходимо было получить такую возможность в собственной программе на платформе РНР и при этом не задействовать сторонние сервисы — подобная возможность в виде веб-сервиса присутствует в Google Language API (в своём блоге я уже исследовал этот сервис), однако она выполняется удалённо и имеет некоторые существенные для нас ограничения, в частности, процедура опознания языка выполняется с существенной задержкой и асинхронна по своей природе. Кроме этого очень хотелось иметь полный контроль над процессом и иметь возможность его гибко настраивать, чего, увы, нет в сторонних сервисах. Поэтому пришлось подумать и попробовать реализовать собственными силами, результат же представляем вашему вниманию.


Сначала немного теории. Сразу следует сказать, что сам процесс автоматического определения языка неточен и принципиально является вероятностным. То есть всегда результат даётся с какой-либо вероятностью, особенно это касается языков, которые имеют очень схожий либо даже идентичный алфавит (в написании), однако различны. При этом мы ещё и зависим от длины строки исследуемого текста — чем меньше у нас материала для исследования, тем сложнее или даже невозможно является такое определение. Ведь для статистики необходимо иметь больше поле для подсчёта параметров, а в короткой строке мы не можем получить достаточно материала для идентификации, особенно при анализе языков, которые имеют в своей основе одинаковый алфавит. В таком тексте банально может просто не встречаться уникальных букв и оно будет определено как слово другого языка. Поэтому первым ограничением метода анализа используемого алфавита является длина текста — чем он больше, тем точнее анализ. Приведу пример: слово "rappel". На каком оно языке? На английском? Оно означает «спускаться на верёвке». Но такое же слово есть и в немецком языке! И там оно означает "(внезапное) помешательство, приступ бешенства".

Этот метод имеет две разновидности. Вариант использования «процента использования алфавита» использует подсчёт количества использованных уникальных символов алфавита в тексте и расчёт % от общего объёма. Второй изменяет количество символов из текста, которое совпадает с алфавитом, при этом некоторые символы могут попадать в разные алфавиты и засчитываться обоим языкам.

Второй метод основан на использовании заранее сформированных правил, которые устанавливают идентичность текста при помощи уникальных или типичных для грамматики языка последовательностей букв (например, артикли в английском, буквы «ъ» и «ё» в русском или "є" в украинском). Такие правила для n-грамм могут разрабатываться лингвистами и позволяют быстрее и точнее определить язык текста, однако также не дают гарантированного результата. Их то сначала нужно создать, а значит владеть языком на достаточном уровне, да и не так много уникальных характерных последовательностей в разных языках. Хотя, если вы заранее знаете, какие языки вам надо определять, то между ними может быть больше уникальных сочетаний, чем если использовать все языки. Если у вас только русский и английский, то таких буквосочетаний явно больше, чем в паре немецкий-английский.

Отдельно следует остановиться на случае, когда в тексте смешиваются слова разных алфавитов. Например, имена или названия компаний и товаров могут быть написаны на оригинальном языке, чаще всего английском, однако все предложение сформулировано по-русски. Здесь поможет только вариант подсчёта общего количества символов, которые принадлежат алфавиту и на основе того, чьих символов больше, принимать решения. В будущей версии библиотека сможет распознавать такие варианты и выдавать массив из языков, которые встречаются в строке, сейчас она покажет только основной язык, тот, которого в строке больше всего символов.

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

Библиотека работает с текстами в кодировке UTF-8, поэтому требует модуля mb_strings и первым делом приводит полученную строку к стандартной форме, пытаясь её перекодировать, потом удаляет лишние знаки и проверяет длину. Минимальный объём текста 50 символов, максимальный — 1680, это примерно равно одной стандартной странице формата А4.

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

Возвращает библиотека после детектирования или значение false, что означает невозможность определения или же то, что используемого языка нет в базе данных. В случае успеха мы получаем массив с двух буквенным кодом языка (для примера: «en», «ru» или «ua»), а также дополнительные сведения — полное название языка и, в качестве бонуса, ссылку на статью о языке в Wikipedia.org (конечно, на этом же языке).

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

Напоследок одно замечание о скорости. Самым быстрым вариантом будет использовать только правила, так как их всегда меньше, чем букв и внутри библиотеки мы будем использовать более короткие циклы. В частности, чем длиннее текст и чем больше языков мы определили в базе для поиска, тем быстрее будет вариант только с правилами. Поэтому, для оптимизации вам лучше всего заранее ограничить набор языков самыми вероятными и удалить те, что вам не нужны — так сократится значительное число циклов и алгоритм будет работать быстрее. Также можно убрать проверку и декодирование строки, если вы уверены, что у вас в системе на вход алгоритма будет подаваться только гарантировано уже преобразованные к кодировке UTF-8 строки.

Официальный сайт проекта: http://code.google.com/p/phplangautodetect/
Лицензия: GNU General Public License v3
Автор: Александр Лозовюк (aleks_raiden, aleks.raiden@gmail.com)
Язык/платформа: PHP 5 (требует модуль mb_strings)
В дистрибутив включён простейший скрипт для экспериментов, он-лайн последняя версия здесь.

Ниже исходный код с комментариями и замечаниями по реализации описанных выше алгоритмов.

  1. class Lang_Auto_Detect
  2. {
  3.   // основные переменные
  4.   // сисок поддерживаемых языков
  5.   public $lang = Array('en'=>array('English','http://en.wikipedia.org/wiki/English_language'),
  6.              'ru'=>array('Russian','http://ru.wikipedia.org/wiki/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9_%D1%8F%D0%B7%D1%8B%D0%BA'),
  7.              'ua'=>array('Ukraine','http://uk.wikipedia.org/wiki/%D0%A3%D0%BA%D1%80%D0%B0%D1%97%D0%BD%D1%81%D1%8C%D0%BA%D0%B0_%D0%BC%D0%BE%D0%B2%D0%B0')
  8.             );
  9.   // порог чуствительности, сколько в % должно быть символов языка, чтобы он был определен
  10.   public $detect_range = 75;
  11.   // обрабатывать ли многоязычные документы и возвращать массив используемых языков
  12.   public $detect_multi_lang = false; // пока не реализовано
  13.   // возвращать все результаты и вероятности
  14.   public $return_all_results = false; // в реальном применении лучше отключить
  15.   // использовать дополнительно систему правил и исключений
  16.   public $use_rules = false;
  17.   //применять только правила (быстрее намного, но результат менее вероятен, чем больше текста, тем достовернее)
  18.   public $use_rules_only = false;
  19.   // приоритет правил над статистикой -
  20.   public $use_rules_priory = true; // true — правила приоритетнее статистики, false — статистика перед правилами  
  21.   // искать только первое правило или максимум совпадений?
  22.   public $match_all_rules = false; // только одно иначе = все
  23.   //использовать % от алфавита или общее количество символов каждого алфавита
  24.   public $use_str_len_per_lang = true; // true — использовать общую длину текста приоритетнее, чем % от символов алфавита, false — наоборот
  25.   
  26.   // минимальная длина строки для детектирования
  27.   public $min_str_len_detect = 50;
  28.   // для обеспечения нормальной производительности задайте максимальную длину в символах для сравнения
  29.   public $max_str_len_detect = 1680; //
  30.   
  31.  
  32.   // внутреняя непеременная — таблица алфавитов используемых при определении
  33.   private $_langs = array(
  34.           'en'=>array('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'),
  35.           'ru'=>array('а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п','р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я'),
  36.           'ua'=>array('а','б','в','г','ґ','д','е','є','ж','з','и','і','ї','й','к','л','м','н','о','п','р','с','т','у','ф','х','ц','ч','ш','щ','ь','ю','я')
  37.           );
  38.   
  39.   // хранит правила
  40.   // правила это символы или строки, наличие которой (любой или всех) автоматически влечет идентификацию текста
  41.   private $_lang_rules = array(
  42.                   'en'=>array('th', 'ir'),
  43.                   'ru'=>array('ъ', 'ё' ),
  44.                   'ua'=>array('ї', 'є')
  45.                 );
  46.   
  47.   
  48.   // конструктор класса
  49.   public function __construct()
  50.   {
  51.     return true;    
  52.   }
  53.  
  54.  
  55.   // подготовка введенной строки для сраневния
  56.   private function _prepare_str($tmp_str = null)
  57.   {
  58.     if ($tmp_str == null) return false; // если ничего не передали — выйти
  59.     
  60.     $tmp_str = trim($tmp_str);
  61.     $tmp_encoding = mb_detect_encoding($tmp_str);
  62.     
  63.     if (mb_strlen($tmp_str, $tmp_encoding) > $this->max_str_len_detect)
  64.     {
  65.       //обрезать длину текста, для роизводительности
  66.       $tmp_str = mb_substr($tmp_str, 0, $this->max_str_len_detect, $tmp_encoding);
  67.     }
  68.     else
  69.       if (mb_strlen($tmp_str, $tmp_encoding) <= $this->min_str_len_detect) return false;
  70.     
  71.     // конвертируем кодировки
  72.     $tmp_str = mb_convert_encoding($tmp_str, 'UTF-8', $tmp_encoding);
  73.     
  74.     // приводим все к нижнему регистру
  75.     $tmp_str = mb_strtolower($tmp_str, 'UTF-8');
  76.     
  77.     return $tmp_str;
  78.   }
  79.   
  80.   // функция определения языка по правилам
  81.   // правила однозначно определяют язык, однако могут оибаться :)
  82.   private function _detect_from_rules($tmp_str = null)
  83.   {
  84.     if ($tmp_str == null) return false; // если ничего не передали — выйти
  85.     if (!is_array($this->_lang_rules)) return false;
  86.     
  87.     // перебор всех правил
  88.     foreach ($this->_lang_rules as $lang_code=>$lang_rules)
  89.     {
  90.       $tmp_freq = 0;
  91.       
  92.       foreach ($lang_rules as $rule)
  93.       {
  94.         $tmp_term = mb_substr_count($tmp_str, $rule);
  95.  
  96.         if ($tmp_term > 1) // то есть символ в строе 1 или более раз
  97.         {
  98.           $tmp_freq++; // увеличим счетчик символов языка, которые в этой строке есть
  99.         }
  100.         
  101.         // теперь проверим
  102.         if ($this->match_all_rules === true)
  103.         {
  104.           // нужно совпадение всех правил
  105.           if ($tmp_freq == count($lang_rules)) return $lang_code;
  106.         }
  107.         else
  108.           {
  109.             // достаточно одного
  110.             if ($tmp_freq > 0) return $lang_code;          
  111.           }
  112.       }
  113.     }
  114.   
  115.     return false;  
  116.   }
  117.  
  118.   // функция определения языка по таблице
  119.   private function _detect_from_tables($tmp_str = null)
  120.   {
  121.     if ($tmp_str == null) return false; // если ничего не передали — выйти
  122.     
  123.     //мы уже должны ранее обработать строку для сравнения    
  124.     // перебираем все языки и для каждого определим вероятность
  125.     $lang_res = array();
  126.     
  127.     foreach ($this->lang as $lang_code=>$lang_name)
  128.     {
  129.       $lang_res[$lang_code] = 0; //по умолчанию 0, то есть не этот язык
  130.       
  131.       $tmp_freq = 0; // частота символов текущего языка
  132.       $full_lang_symbols = 0; //полное количество символов этого языка
  133.       
  134.       // так как длина строки может быть произвольной, а алфавит одинаковый, то цикл по алфавитам
  135.       $cur_lang = $this->_langs[$lang_code];
  136.             
  137.       foreach ($cur_lang as $l_item)
  138.       {
  139.         // теперь посмотреть количество вхождений символа в строку
  140.         $tmp_term = mb_substr_count($tmp_str, $l_item);
  141.         
  142.         if ($tmp_term > 1) // то есть символ в строе 1 или более раз
  143.         {
  144.           $tmp_freq++; // увеличим счетчик символов языка, которые в этой строке есть
  145.           $full_lang_symbols += $tmp_term;
  146.         }
  147.       }
  148.  
  149.       if ($this->use_str_len_per_lang === true)
  150.       {
  151.         //использовать общее количество символов
  152.         $lang_res[$lang_code] = $full_lang_symbols;
  153.       }
  154.       else
  155.         // Вычислить процент от всех символов алфавита
  156.         $lang_res[$lang_code] = ceil((100 / count($cur_lang) ) * $tmp_freq);
  157.       
  158.     }
  159.     
  160.     // так, теперь посомтрим что вышло
  161.     arsort($lang_res, SORT_NUMERIC); //сортируем массив первый элемент язык с большей вероятностью
  162.     
  163.     if ($this->return_all_results == true)
  164.     {
  165.       return $lang_res; // если вернуть все результаты — возвращаем, иначе выбрать лучший
  166.     }
  167.     else
  168.       {
  169.         // если больше указанного нами порога, возвратить код языка, иначе — null (то есть, мы не можем определить код языка)
  170.         $key = key($lang_res);
  171.         
  172.         if ($lang_res[$key] >= $this->detect_range)
  173.           return $key;
  174.         else
  175.           return false;
  176.       }
  177.     
  178.   }
  179.  
  180.  
  181.   // общая функция для определения языка
  182.   public function lang_detect($tmp_str = null)
  183.   {
  184.     if ($tmp_str == null) return false; // если ничего не передали — выйти
  185.     
  186.     $tmp_str = $this->_prepare_str($tmp_str);
  187.     
  188.     if ($tmp_str === false) return false;
  189.     
  190.     // если правила применяем ДО таблицы
  191.     if ($this->use_rules_only === true)
  192.     {
  193.       $res = $this->_detect_from_rules($tmp_str);
  194.       
  195.       return array($res, $this->lang[$res]);
  196.     }
  197.     else
  198.       {
  199.         // при использовании таблиц мы не можем получить полную раскладку по результатам, потому отключаем
  200.         $this->return_all_results = false;
  201.         
  202.         $res = $this->_detect_from_tables($tmp_str);
  203.     
  204.         if ($tmp_str === false) return false;
  205.         
  206.         if ($this->use_rules === true)
  207.         {
  208.           $res_rules = $this->_detect_from_rules($tmp_str);
  209.           
  210.           // исходим из настроек приоритета правил и статистики
  211.           if ($this->use_rules_priory === true)
  212.           {
  213.             //правила имеют бОльший вес, чем статистика
  214.             return array($res_rules, $this->lang[$res_rules]);
  215.           }
  216.           else
  217.             {
  218.               return array($res, $this->lang[$res]);
  219.             }
  220.         }
  221.         else
  222.           return array($res, $this->lang[$res]);
  223.       }  
  224.   }
  225. }
* This source code was highlighted with Source Code Highlighter.

P.S. Конечно, код не претендует на совершенство, и, возможно, в сети уже есть реализации этой функциональности, которых я не нашел. Если вы знаете известные реализации — просьба сообщить мне в комментариях. Оригинал статьи размещён в моем блоге.
Tags:
Hubs:
+31
Comments 45
Comments Comments 45

Articles