diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 36 | ||||
-rw-r--r-- | composer.json | 17 | ||||
-rw-r--r-- | phpunit.xml.dist | 20 | ||||
-rw-r--r-- | src/NXP/Stemmer.php | 138 | ||||
-rw-r--r-- | tests/StemmerTest.php | 85 | ||||
-rw-r--r-- | tests/bootstrap.php | 11 |
7 files changed, 309 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7f8ac3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea/ +/vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..629dd98 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Стеммер Портера для русского языка + +## Описание + +Данный стеммер является заменой расширению stem_russian_unicode. + +## Сравнение с расширением stem_russian_unicode + +*Плюсы:* + +1. не требовать внешних расширений для PHP. Стеммер написан целиком на PHP. +2. избавиться от проблем с юникодом. stem_russian_unicode зависит от SET_LOCALE и может при неверном значении портить строки с юникодом. +3. быть легко изменяемым под конкретные требования проекта. В случае расширения, при изменении логики работы его придётся пересобирать. + +*Минусы:* + +1. в силу того, что этот стеммер написан на PHP с использованием регулярных выражений, он должен проигрывать в скорости работы скомпилированному расширению, написанному на C. +2. Требует для своей работы PHP версии >=5.4 (возможно, добавлю поддержку версии 5.3) + +## Использование + + <?php + $text = '...'; + require __DIR__ . '/vendor/autoload.php'; + $stemmer = new \NXP\Stemmer(); + $stemmed = []; + foreach (explode(' ', $text) as $word) { + $stemmed[] = $stemmer->getWordBase($word); + } + $result = implode(' ', $stemmed); + +## Отличия от классического стеммера Портера + +Единственное отличие заключается в том, что в данной реализации буква «ё» является самостоятельной гласной, а не буквой «е» + +## Лицензия GPLv3 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..13a12f0 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "nxp/russian-porter-stemmer", + "description": "Russian porter stemmer", + "minimum-stability": "stable", + "keywords": ["russian", "stemmer"], + "homepage": "http://github.com/NeonXP/RussianStemmer", + "license": "GPLv2", + "authors": [ + { + "name": "Alexander 'NeonXP' Kiryukhin", + "email": "frei@neonxp.info" + } + ], + "autoload": { + "psr-0": {"NXP": "src/"} + } +}
\ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d373eb2 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<phpunit backupGlobals="false" + backupStaticAttributes="false" + colors="true" + convertErrorsToExceptions="true" + convertNoticesToExceptions="true" + convertWarningsToExceptions="true" + processIsolation="false" + stopOnFailure="false" + syntaxCheck="false" + bootstrap="./tests/bootstrap.php" + > + + <testsuites> + <testsuite name="Russian stemmer tests"> + <directory>./tests/</directory> + </testsuite> + </testsuites> +</phpunit>
\ No newline at end of file diff --git a/src/NXP/Stemmer.php b/src/NXP/Stemmer.php new file mode 100644 index 0000000..03b159b --- /dev/null +++ b/src/NXP/Stemmer.php @@ -0,0 +1,138 @@ +<?php +/** + * This file is part of the RussianStemmer package + * + * (c) Alexander Kiryukhin + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code + */ + +namespace NXP; + +class Stemmer +{ + private $vowel = "аеёиоуыэюя"; + private $regexPerfectiveGerunds = [ + "(в|вши|вшись)$", + "(ив|ивши|ившись|ыв|ывши|ывшись)$" + ]; + private $regexAdjective = "(ее|ие|ые|ое|ими|ыми|ей|ий|ый|ой|ем|им|ым|ом|его|ого|ему|ому|их|ых|ую|юю|ая|яя|ою|ею)$"; + private $regexParticiple = [ + "(ем|нн|вш|ющ|щ)", + "(ивш|ывш|ующ)" + ]; + private $regexReflexives = "(ся|сь)$"; + private $regexVerb = [ + "(ла|на|ете|йте|ли|й|л|ем|н|ло|но|ет|ют|ны|ть|ешь|нно)$", + "(ила|ыла|ена|ейте|уйте|ите|или|ыли|ей|уй|ил|ыл|им|ым|ен|ило|ыло|ено|ят|ует|уют|ит|ыт|ены|ить|ыть|ишь|ую|ю)$" + ]; + private $regexNoun = "(а|ев|ов|ие|ье|е|иями|ями|ами|еи|ии|и|ией|ей|ой|ий|й|иям|ям|ием|ем|ам|ом|о|у|ах|иях|ях|ы|ь|ию|ью|ю|ия|ья|я)$"; + private $regexSuperlative = "(ейш|ейше)$"; + private $regexDerivational = "(ост|ость)$"; + private $regexI = "и$"; + private $regexNN = "нн$"; + private $regexSoftSign = "ь$"; + + private $word = ''; + private $RV = 0; + private $R1 = 0; + private $R2 = 0; + + public function getWordBase($word) + { + mb_internal_encoding('UTF-8'); + $this->word = $word; + $this->findRegions(); + //Шаг 1 + //Найти окончание PERFECTIVE GERUND. Если оно существует – удалить его и завершить этот шаг. + if (!$this->removeEndings($this->regexPerfectiveGerunds, $this->RV)) { + //Иначе, удаляем окончание REFLEXIVE (если оно существует). + $this->removeEndings($this->regexReflexives, $this->RV); + //Затем в следующем порядке пробуем удалить окончания: ADJECTIVAL, VERB, NOUN. Как только одно из них найдено – шаг завершается. + if (!($this->removeEndings( + [ + $this->regexParticiple[0] . $this->regexAdjective, + $this->regexParticiple[1] . $this->regexAdjective + ], + $this->RV + ) || $this->removeEndings($this->regexAdjective, $this->RV)) + ) { + if (!$this->removeEndings($this->regexVerb, $this->RV)) { + $this->removeEndings($this->regexNoun, $this->RV); + } + } + } + //Шаг 2 + //Если слово оканчивается на и – удаляем и. + $this->removeEndings($this->regexI, $this->RV); + //Шаг 3 + //Если в R2 найдется окончание DERIVATIONAL – удаляем его. + $this->removeEndings($this->regexDerivational, $this->R2); + //Шаг 4 + //Возможен один из трех вариантов: + //Если слово оканчивается на нн – удаляем последнюю букву. + if ($this->removeEndings($this->regexNN, $this->RV)) { + $this->word .= 'н'; + } + //Если слово оканчивается на SUPERLATIVE – удаляем его и снова удаляем последнюю букву, если слово оканчивается на нн. + $this->removeEndings($this->regexSuperlative, $this->RV); + //Если слово оканчивается на ь – удаляем его. + $this->removeEndings($this->regexSoftSign, $this->RV); + + return $this->word; + } + + public function removeEndings($regex, $region) + { + $prefix = mb_substr($this->word, 0, $region, 'utf8'); + $word = mb_substr($this->word, $region, null, 'utf8'); + if (is_array($regex)) { + if (preg_match('/.+[а|я]' . $regex[0] . '/u', $word)) { + $this->word = $prefix . preg_replace('/' . $regex[0] . '/u', '', $word); + return true; + } + $regex = $regex[1]; + } + if (preg_match('/.+' . $regex . '/u', $word)) { + $this->word = $prefix . preg_replace('/' . $regex . '/u', '', $word); + return true; + } + + return false; + } + + private function findRegions() + { + $state = 0; + for ($i = 1; $i < mb_strlen($this->word, 'utf8'); $i++) { + $prevChar = mb_substr($this->word, $i - 1, 1, 'utf8'); + $char = mb_substr($this->word, $i, 1, 'utf8'); + switch ($state) { + case 0: + if ($this->isVowel($char)) { + $this->RV = $i + 1; + $state = 1; + } + break; + case 1: + if ($this->isVowel($prevChar) && !$this->isVowel($char)) { + $this->R1 = $i + 1; + $state = 2; + } + break; + case 2: + if ($this->isVowel($prevChar) && !$this->isVowel($char)) { + $this->R2 = $i + 1; + return; + } + break; + } + } + } + + private function isVowel($char) + { + return (strpos($this->vowel, $char) !== false); + } +}
\ No newline at end of file diff --git a/tests/StemmerTest.php b/tests/StemmerTest.php new file mode 100644 index 0000000..2d2db93 --- /dev/null +++ b/tests/StemmerTest.php @@ -0,0 +1,85 @@ +<?php +/** + * This file is part of the RussianStemmer package + * + * (c) Alexander Kiryukhin + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code + */ + +namespace NXP; + +class StemmerTest extends \PHPUnit_Framework_TestCase +{ + public function testStemming() + { + $stemmer = new Stemmer(); + $testWords = [ + 'результаты' => 'результат', + 'в' => 'в', + 'вавиловка' => 'вавиловк', + 'вагнера' => 'вагнер', + 'вагон' => 'вагон', + 'вагона' => 'вагон', + 'вагоне' => 'вагон', + 'вагонов' => 'вагон', + 'вагоном' => 'вагон', + 'вагоны' => 'вагон', + 'важная' => 'важн', + 'важнее' => 'важн', + 'важнейшие' => 'важн', + 'важнейшими' => 'важн', + 'важничал' => 'важнича', + 'важно' => 'важн', + 'важного' => 'важн', + 'важное' => 'важн', + 'важной' => 'важн', + 'важном' => 'важн', + 'важному' => 'важн', + 'важности' => 'важност', + 'важностию' => 'важност', + 'важность' => 'важност', + 'важностью' => 'важност', + 'важную' => 'важн', + 'важны' => 'важн', + 'важные' => 'важн', + 'важный' => 'важн', + 'важным' => 'важн', + 'важных' => 'важн', + 'вазах' => 'ваз', + 'вазы' => 'ваз', + 'вакса' => 'вакс', + 'вакханка' => 'вакханк', + 'вал' => 'вал', + 'валандался' => 'валанда', + 'валентина' => 'валентин', + 'валериановых' => 'валерианов', + 'валерию' => 'валер', + 'валетами' => 'валет', + 'вали' => 'вал', + 'валил' => 'вал', + 'валился' => 'вал', + 'валится' => 'вал', + 'валов' => 'вал', + 'вальдшнепа' => 'вальдшнеп', + 'вальс' => 'вальс', + 'вальса' => 'вальс', + 'вальсе' => 'вальс', + 'вальсишку' => 'вальсишк', + 'вальтера' => 'вальтер', + 'валяется' => 'валя', + 'валялась' => 'валя', + 'валялись' => 'валя', + 'валялось' => 'валя', + 'валялся' => 'валя', + 'валять' => 'валя', + 'валяются' => 'валя', + 'вам' => 'вам', + 'вами' => 'вам', + ]; + foreach ($testWords as $word => $base) { + $this->assertEquals($base, $stemmer->getWordBase($word)); + } + } +}
\ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8eb3e7a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,11 @@ +<?php + +$vendorDir = __DIR__ . '/../../..'; + +if (file_exists($file = $vendorDir . '/autoload.php')) { + require_once $file; +} else if (file_exists($file = './vendor/autoload.php')) { + require_once $file; +} else { + throw new \RuntimeException("Not found composer autoload"); +}
\ No newline at end of file |