19 дек. 2008 г.

Как разбить текст на слова

"Practical Text Mining With Perl" -- заметки на полях

Как часто нужные книги попадают в руки когда они уже не так актуальны! Вот первое, что я подумал, открыв "Practical Text Mining With Perl" (Roger Bilisoly, Wiley & Sons, 2008). А не повод ли это вернуться к старым увлечениям?

Одна из первых глав посвящена проблеме разбиения текста на слова. Задачка не такая уж и простая. Разбить строку по пробелам и убрать знаки препинания несложно:
@tokens = split(/\s+/, $line);
foreach $tk (@tokens) {
if ( $x =- /(\w+)/ ) {
# $1 содержит слово
}
}

Но результат будет немного неожиданным.

Внутренняя пунктуация

От слова "инженер-программист" останется лишь "инженер". От английского "o'clock" -- "o". Издатель O'Reilly не узнает своей фамилии... Шаблон \w+ набирает буквы, цифры и нижнее подчеркивание (_). Как только в тексте встречается другой символ, дверца (круглая скобка) закрывается. Слово закончилось.

Чтобы не отсекать правые части составных слов, автор модифицирует регулярное выражение следующим образом:
$x =~ /(([a-zA-Z']+-)*[a-zA-Z']+)/
Теперь слово может состоять из нескольких сегментов, отделенных друг от друга дефисами. Каждый такой сегмент состоит из одной или более букв или апострофов. В предыдущем примере слово могло состоять только из букв, цифр и знаков подчеркивания (\w+) .

Включение апострофа в квадратные скобки -- не лучшее решение, потому что теперь словом может считаться, например: '-'-'-'-'-'-'-'-'-'-'-'-'-'-' . И разве бывают слова, где за апострофом следует дефис? Не лучше ли принять, что разделителем составных слов может быть либо дефис либо апостроф?
$x =~ /(([a-zA-Z]+[-']))*[a-zA-Z]+'?)/
Второй очевидный недостаток: диапазон a-zA-Z не оставляет никакого шанса не только цифрам, но и кириллическим символам. Одно из решений -- использовать уникодные расширения регулярных выражений:
$x =~ /(([[:alpha:]]+['-])*[[:alpha:]]+'?)/
Разумеется, придется позаботиться о том, чтобы текст был в кодировке utf-8.

***
Всегда ли имеет смысл считать составное слово чем-то отдельным? Вот ряд примеров:
  • бледно-оранжевый
  • красно-коричневый
  • давным-давно
  • когда-нибудь
  • высоко-высоко
  • северо-западный
  • мяу-мяу
  • иван-да-марья
  • человек-амфибия
  • самолет-разведчик
  • советско-американский
  • авто-мастерская
Определить, является ли составное слово отдельной смысловой единицей или нет, способен исключительно носитель языка. Иногда ответ зависит от контекста ("красно-коричневый").

Автор "Practical Text Mining With Perl" считает, что проще все же не разбивать составные слова, и я с ним согласен. Некоторое количество лишних словообразований в лексиконе не так вредно с точки зрения анализа смысла, как дробление. Что останется от понятия "mother-in-law" (свекровь) -- пишет Bilisoly -- если разбить его на "mother", "in" и "law" ("мать", "в", "закон")?

Тире

Тире -- еще один камень преткновения. Оно может быть обозначено разными символами, отделяться или не отделяться от слов пробелами. Тут частично помогает предварительная нормализация. Например, если в тексте встретились два идущих подряд дефиса, возле которых нет пробела, обеспечим пробелы с двух сторон:
s/--/ -- /g

Скрипт

Ниже код программы, адаптированный к русскому языку. В качестве аргументов задается имя исходного файла и файла с результатом (списком слов). Предполагается, что исходный файл в Windows-кодировке. При другой кодировке следует отредактировать строчку:
use open ':encoding(cp1251)';
###############################################
# split_words.pl
# Usage: perl split_words.pl FROM_FILE TO_FILE
###############################################
use strict;
use warnings;
use open ':encoding(cp1251)';

open (my $I, '<', $ARGV[0]) or die $!;
open (my $O, '>', $ARGV[1]) or die $!;
while (<$I>) {
chomp;
s/--/ -- /g;
my @words = split /\s+/;
foreach my $w (@words) {
print $O "$1\n"
if $w =~ /(([[:alpha:]]+['-])*[[:alpha:]]+'?)/ )
}
}
close $O;
close $I;

Python-версия

А вот версия для Питона:

#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# from __future__ import with_statement # Python2.5
import sys
import re

pattern = re.compile("(([\w]+[-'])*[\w']+'?)", re.U)
with open(sys.argv[1]) as f:
for line in f:
line = unicode(line, 'cp1251')
line = line.replace('--', ' -- ')
for token in line.split():
m = pattern.match(token)
if m:
print m.group()
Код выглядит полегче. Меньше системных операций. Но есть один существенный недостаток: регулярным выражениям в Питоне недостает уникодных расширений. Поэтому пришлось использовать \w. Значит, в слово попадут цифры и символ подчеркивания, с которыми придется разбираться позже. Можно, наверное, как-то иначе с этим справиться, но питоновский код не хочется усложнять.

Везде свои тонкости

Локализованная версия испытывалась на русском переводе рассказа Эдгара По "Сердце-обличитель". В книге "Text Mining With Perl" с этой же целью используется оригинал: "Tell-Tale Heart". А что если мы обрабатываем текст, по которому не прошелся корректор? Авторы блог-постов, например, часто забывают поставить пробел после точки в конце предложения или после запятой. Предположение, что слова всегда разделены пробелами, может оказаться неверным.

Вот почему на практике часто используют несколько иной подход. Исходная строка разбивается на слова не только пробелами, а всем, что не входит в слово. Простейший пример:
@tokens = split(/\W+/, $line);
Это работает, но к сожалению, все составные слова будут разбиты.

На Питоне:
splitter = re.compile(r'\W*', re.U)
words = [ s for s in splitter.split(text) if s ]

А вот более интересный пример из книги "An Introduction to Language Processing with Perl and Prolog" (Pierre M. Nugues, Springer, 2006):
$text = <>;
while ($line = <>) {
$text .= $line;
}
$text =~ tr/a-zåàâäæçéèêëîïôöoeùûüßAZÅÀÂÄÆÇÉÈÊËÎÏÔÖOEÙÛÜ’()\-,.?!:;/\n/cs;
$text =~ s/([,.?!:;()’\-])/\n$1\n/g;
$text =~ s/\n+/\n/g;
print $text;
Здесь вместо того, чтобы обрабатывать строчку за строчкой, все прочитанные из файла строки, наоборот, сливаются в один текст. Затем:
  1. Если символ не является ни буквой ни знаком пунктуации, он заменяется символом новой строки
  2. Каждый знаки пунктуации помещается на отдельную строку
  3. Повторяющиеся символы новой строки заменяются одним
Чтобы адаптировать этот код к кириллице, придется дополнить длинную цепочку не-ASCII символов, переданных оператору tr. К сожалению, этот оператор не понимает уникодных расширений. Впрочем, того же результата можно добиться более привычным оператором замены (s///).

Первую программу можно тоже научить работать с неправильными текстами, не меняя алгоритма. Для этого можно расширить оператор разбиения слов. Или предварительно обрабатывать входящие строки -- примерно так же, как мы поступали с "тире".

Ясно одно: трудно написать универсальную процедуру на все случаи жизни. Надо учитывать, с каким материалом придется иметь дело: с литературными текстами, блогами, юридическими документами... везде свои тонкости.

4 комментария:

strict-warnings комментирует...

У вас, похоже, закралась ошибка в регулярку:
(([[:alpha:]]+['-]?)*.
Ведь не обязательно первая буквенная группа должна оканчиваться апострофом или дефисом.

strict-warnings комментирует...

И даже более того, часть регулярки после первых групппирующих скобок тоже необязательна. Получится что-то типа ([[:alpha:]+]'?)?

Наувул-Наувул комментирует...

Я рассуждал так. Cлово -- это серия букв: [[:alpha:]]+

Перед ними может идти любое количество таких же серий, заканчивающихся разделителем: ([[:alpha:]]+['-])*

Наконец, слово может заканчиваться апострофом: '?

...Где тут ошибка?

strict-warnings комментирует...

Да, все правильно. Ошибся я.