Chapter 08

言語処理100本ノックのサンプル回答

ikawaha
ikawaha
2020.12.19に更新

「言語処理100本ノック 2020」の問題から、形態素解析にかかわる問題のサンプル回答を kagome で作ってみました。kagome による形態素解析の利用例としてなにか参考になればと思います。

言語処理100本ノック

第4章:形態素解析 問30〜問37 までをやっていきます。

問30. 形態素解析結果の読み込み

形態素解析結果(neko.txt.mecab)を読み込むプログラムを実装せよ.ただし,各形態素は表層形(surface),基本形(base),品詞(pos),品詞細分類1(pos1)をキーとするマッピング型に格納し,1文を形態素(マッピング型)のリストとして表現せよ.第4章の残りの問題では,ここで作ったプログラムを活用せよ.

ここで出てくる neko.txt.mecab は事前に MeCab を利用して準備するよう求められているものです。

夏目漱石の小説『吾輩は猫である』の文章(neko.txt)をMeCabを使って形態素解析し,その結果をneko.txt.mecabというファイルに保存せよ.このファイルを用いて,以下の問に対応するプログラムを実装せよ.

問題では、neko.txt.mecab は事前に準備して利用するよう指示されていますが、それだと kagome の利用方法のサンプルにならないので、毎回解析してしまうことにします。問題を以下のように書き換えます。

夏目漱石の小説『吾輩は猫である』の文章(neko.txt)を形態素解析し、1文を形態素(マッピング型)のリストとして表現せよ。第4章の残りの問題では、ここで作ったプログラムを活用せよ。

準備:以降の問題で利用できる形態素解析をおこなう Tokenizer を用意する

kagome をラップして、問題で与えられるテキストを受け取って1文語とに形態素列を返す関数を用意します。

package chapter04

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/ikawaha/kagome-dict/ipa"
	"github.com/ikawaha/kagome/v2/tokenizer"
)

// 1文毎に形態素解析した結果。型変換がめんどいので alias
type TokenizedSentence = []tokenizer.Token

type Tokenizer struct {
	*tokenizer.Tokenizer
}

func NewTokenizer() (*Tokenizer, error) {
	t, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos()) // BOS/EOS は結果に含めない
	return &Tokenizer{
		Tokenizer: t,
	}, err
}

// Reader から1行毎にテキストを読み取って、形態素解析をおこなう
func (t Tokenizer) TokenizeReader(r io.Reader) ([]TokenizedSentence, error) {
	s := bufio.NewScanner(r)
	var ret []TokenizedSentence
	for s.Scan() {
		sen := strings.TrimSpace(s.Text()) // 一行一文なので1行を取り出す
		ret = append(ret, t.TokenizeSentence(sen)) // 形態素解析した結果を詰める
	}
	return ret, s.Err()
}

// 1文の単位を形態素する
func (t Tokenizer) TokenizeSentence(s string) TokenizedSentence {
	return t.Tokenize(s)
}

// ファイルパスを与えると1行毎に形態素解析して返す
func TokenizeTextFile(path string) ([]TokenizedSentence, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("cannot open file error, %v", err)
	}
	t, err := NewTokenizer()
	if err != nil {
		return nil, err
	}
	return t.TokenizeReader(f)
}

問30 の回答サンプル

準備した関数を利用して形態素解析するだけです。

func Answer30() {
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	for i, v := range sentences {
		fmt.Println(i, v)
	}
}

結果

0 ["一" (0: 0, 1) KNOWN [102656]]
1 []
2 ["吾輩" (0: 0, 2) KNOWN [162465] "は" (6: 2, 3) KNOWN [57061] "猫" (9: 3, 4) KNOWN [286994] "で" (12: 4, 5) KNOWN [47312] "ある" (15: 5, 7) KNOWN [3325] "。" (21: 7, 8) KNOWN [98]]
3 ["名前" (0: 0, 2) KNOWN [160584] "は" (6: 2, 3) KNOWN [57061] "まだ" (9: 3, 5) KNOWN [68945] "無い" (15: 5, 7) KNOWN [281555] "。" (21: 7, 8) KNOWN [98]]
4 []
5 ["どこ" (0: 0, 2) KNOWN [50642] "で" (6: 2, 3) KNOWN [47311] "生れ" (9: 3, 5) KNOWN [289706] "た" (15: 5, 6) KNOWN [39233] "か" (18: 6, 7) KNOWN [14923] "とんと" (21: 7, 10) KNOWN [50428] "見当" (30: 10, 12) KNOWN [343718] "が" (36: 12, 13) KNOWN [19676] "つか" (39: 13, 15) KNOWN [43604] "ぬ" (45: 15, 16) KNOWN [54055] "。" (48: 16, 17) KNOWN [98]]
6 ["何" (0: 0, 1) KNOWN [128786] "でも" (3: 1, 3) KNOWN [47667] "薄暗い" (9: 3, 6) KNOWN [335120] "じめじめ" (18: 6, 10) KNOWN [34141] "し" (30: 10, 11) KNOWN [30757] "た" (33: 11, 12) KNOWN [39233] "所" (36: 12, 13) KNOWN [221941] "で" (39: 13, 14) KNOWN [47311] "ニャーニャー" (42: 14, 20) UNKNOWN [36] "泣い" (60: 20, 22) KNOWN [270415] "て" (66: 22, 23) KNOWN [46599] "いた事" (69: 23, 26) KNOWN [5519] "だけ" (78: 26, 28) KNOWN [42041] "は" (84: 28, 29) KNOWN [57061] "記憶" (87: 29, 31) KNOWN [347078] "し" (93: 31, 32) KNOWN [30757] "て" (96: 32, 33) KNOWN [46599] "いる" (99: 33, 35) KNOWN [6651] "。" (105: 35, 36) KNOWN [98]]
7 ["吾輩" (0: 0, 2) KNOWN [162465] "は" (6: 2, 3) KNOWN [57061] "ここ" (9: 3, 5) KNOWN [26290] "で" (15: 5, 6) KNOWN [47311] "始め" (18: 6, 8) KNOWN [183346] "て" (24: 8, 9) KNOWN [46599] "人間" (27: 9, 11) KNOWN [123329] "という" (33: 11, 14) KNOWN [47756] "もの" (42: 14, 16) KNOWN [74451] "を" (48: 16, 17) KNOWN [80580] "見" (51: 17, 18) KNOWN [342798] "た" (54: 18, 19) KNOWN [39233] "。" (57: 19, 20) KNOWN [98]]
8 ["しかも" (0: 0, 3) KNOWN [30979] "あと" (9: 3, 5) KNOWN [2154] "で" (15: 5, 6) KNOWN [47311] "聞く" (18: 6, 8) KNOWN [323447] "と" (24: 8, 9) KNOWN [47729] "それ" (27: 9, 11) KNOWN [38966] "は" (33: 11, 12) KNOWN [57061] "書生" (36: 12, 14) KNOWN [244897] "という" (42: 14, 17) KNOWN [47756] "人間" (51: 17, 19) KNOWN [123329] "中" (57: 19, 20) KNOWN [114799] "で" (60: 20, 21) KNOWN [47311] "一番" (63: 21, 23) KNOWN [103582] "獰悪" (69: 23, 25) KNOWN [287313] "な" (75: 25, 26) KNOWN [50942] "種族" (78: 26, 28) KNOWN [305959] "で" (84: 28, 29) KNOWN [47312] "あっ" (87: 29, 31) KNOWN [1841] "た" (93: 31, 32) KNOWN [39233] "そう" (96: 32, 34) KNOWN [37934] "だ" (102: 34, 35) KNOWN [41863] "。" (105: 35, 36) KNOWN [98]]
9 ["この" (0: 0, 2) KNOWN [27305] "書生" (6: 2, 4) KNOWN [244897] "という" (12: 4, 7) KNOWN [47756] "の" (21: 7, 8) KNOWN [55827] "は" (24: 8, 9) KNOWN [57061] "時々" (27: 9, 11) KNOWN [242664] "我々" (33: 11, 13) KNOWN [221162] "を" (39: 13, 14) KNOWN [80580] "捕え" (42: 14, 16) KNOWN [229750] "て" (48: 16, 17) KNOWN [46599] "煮" (51: 17, 18) KNOWN [283048] "て" (54: 18, 19) KNOWN [46599] "食う" (57: 19, 21) KNOWN [381427] "という" (63: 21, 24) KNOWN [47756] "話" (72: 24, 25) KNOWN [348009] "で" (75: 25, 26) KNOWN [47312] "ある" (78: 26, 28) KNOWN [3325] "。" (84: 28, 29) KNOWN [98]]
snip...

問31. 動詞

動詞の表層形をすべて抽出せよ.

回答サンプル

大分類に 動詞 を指定した品詞フィルターを用意して、これとマッチした形態素の表層形 (Surface) を表示します。

func Answer31(){
	posFilter := filter.NewPOSFilter(filter.POS{"動詞"}) // 動詞だけがセットされた品詞フィルター
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	for _, s := range sentences {
		for _, token := range s {
			if posFilter.Match(token.POS()) {
				fmt.Println(token.Surface)
			}
		}
	}
}

結果

生れ
つか
し
泣い
し
いる
始め
snip...

問32. 動詞の原形

動詞の原形をすべて抽出せよ.

動詞の原形は BaseForm として取得できます。IPADic/UniDIC には BaseForm が存在しますが、BaseForm がない辞書も想定して、Token#BaseForm()(string, bool) を返すようになっています。取得できない場合は false が返ってきます。

IPADic/UniDicでも縮約版の辞書を利用すると、品詞以外の情報をメモリに載せないので、BaseForm が取得できず false が返ってきます。

回答サンプル

サンプルでは例示のため、BaseForm()true/false をチェックしていますが、普通は無視していいでしょう。

func Answer32(){
	posFilter := filter.NewPOSFilter(filter.POS{"動詞"}) // 動詞だけがセットされた品詞フィルター
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	for _, s := range sentences {
		for _, token := range s {
			if posFilter.Match(token.POS()) {
				if b, ok := token.BaseForm(); ok {
					fmt.Println(b)
					continue
				}
				fmt.Println(token.Surface, "!!base form not found!!")
			}
		}
	}
}

結果

生れる
つく
する
泣く
する
いる
始める
snip...

問33. 「AのB」

2つの名詞が「の」で連結されている名詞句を抽出せよ.

回答サンプル

これまでの方法とほぼ同じです。大分類の 名詞 がセットされたフィルターと、形態素の表層形を組み合わせてチェックして表示しています。

func Answer33(){
	posFilter := filter.NewPOSFilter(filter.POS{"名詞"})
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	for _, s := range sentences {
		for i := 2; i < len(s); i++ {
			if posFilter.Match(s[i-2].POS()) && s[i-1].Surface == "の" && posFilter.Match(s[i].POS()) {
				fmt.Println(s[i-2].Surface, s[i-1].Surface, s[i].Surface)
			}
		}
	}
}

結果

彼 の 掌
掌 の 上
書生 の 顔
はず の 顔
顔 の 真中
穴 の 中
書生 の 掌
掌 の 裏

問34. 名詞の連接

名詞の連接(連続して出現する名詞)を最長一致で抽出せよ.

回答サンプル

品詞フィルターを用意してマッチした形態素の表記を配列に記録していきます。マッチしなくなったときに配列に何か入っていれば、それは目的の連接なので、それを出力します。

func Answer34(){
	posFilter := filter.NewPOSFilter(filter.POS{"名詞"})
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	for _, s := range sentences {
		var phrase []string
		for i := 0; i < len(s); i++ {
			if posFilter.Match(s[i].POS()) {
				phrase = append(phrase, s[i].Surface)
				continue
			}
			if len(phrase) == 0 {
				continue
			}
			fmt.Println(strings.Join(phrase, "/"))
			phrase = phrase[0:0]
		}
	}
}

結果

人間/中
一番/獰悪
時/妙
一/毛
その後/猫
一/度
ぷうぷうと/煙
snip...

ちなみに、一番長そうなのは

明治/三/十/八/年/何/月/何/日/戸締り

でした。

問35. 単語の出現頻度

文章中に出現する単語とその出現頻度を求め,出現頻度の高い順に並べよ.

回答サンプル

形態素を入れるとカウントしてくれるカウンター FreqCounter を用意しました。FreqCounter#List() を呼ぶと、リストを作って、カウントの多い順にソートします。

package chapter04

import (
	"fmt"
	"log"
	"sort"

	"github.com/ikawaha/kagome/v2/tokenizer"
)

type Freq struct{
	Surface string
	Count int
}

type FreqCounter map[string]int

func NewFreqCounter() *FreqCounter {
	return &FreqCounter{}
}

func (c FreqCounter) Add(ts ...tokenizer.Token){
	for _, v := range ts {
		i := c[v.Surface]
		c[v.Surface] = i+1
	}
}

func (c FreqCounter) List() []Freq{
	var list []Freq
	for k, v := range c{
		list = append(list, Freq{Surface: k, Count: v})
	}
	sort.Slice(list, func(i, j int) bool {
		if list[i].Count == list[j].Count {
			return list[i].Surface < list[j].Surface
		}
		return list[i].Count > list[j].Count
	})
	return list
}

func Answer35(){
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	freq := NewFreqCounter()
	for _, s := range sentences {
		freq.Add(s...)
	}
	for i, v:=range freq.List(){
		fmt.Println(i+1, v.Surface, v.Count)
	}
}

結果

1 の 9195
2 。 7486
3 て 6869
4 、 6773
5 は 6420
6 に 6244
7 を 6073
8 と 5516
9 が 5338
10 た 3989
11 で 3807
12 「 3238
13 」 3231
14 も 2479
15 ない 2390
snip...

問36. 頻度上位10語

出現頻度が高い10語とその出現頻度をグラフ(例えば棒グラフなど)で表示せよ.

形態素解析とは関係ないのでグラフで表示するところはやりませんでした。

問35のリストの上位10語を返せばいいだけですが、上位は 助詞記号 などが占めていて面白い結果ではないので、フィルターした結果を表示するようにします。

回答サンプル

品詞のフィルターを用意して Top10 を表示します。ここでは品詞のフィルターだけを利用しましたが、さらに表記のフィルターを使ってもいいかもしれません。

func Answer36(){
	posFilter := filter.NewPOSFilter([]filter.POS{
		{"助詞"},  // が も の を
		{"助動詞"}, // れる られる せる
		{"記号"}, // 。、?
		{"名詞","非自立","一般"}, // の
	}...)
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	freq := NewFreqCounter()
	for i := range sentences {
		posFilter.Drop(&sentences[i]) // フィルターに含まれるものは落とす
		freq.Add(sentences[i]...)
	}
	list := freq.List()
	topK := 10
	for i:=0; i < topK && i < len(list); i++{
		fmt.Println(i+1, list[i].Surface, list[i].Count)
	}
}

結果

1 し 2257
2 いる 1249
3 する 993
4 君 973
5 云う 937
6 主人 933
7 ない 765
8 ある 733
9 よう 696
10 この 649

問35の上位 10 形態素よりは面白みがありますが、もう少しフィルターした方がよさそうですね。

問37. 「猫」と共起頻度の高い上位10語

「猫」とよく共起する(共起頻度が高い)10語とその出現頻度をグラフ(例えば棒グラフなど)で表示せよ

形態素解析とは関係ないのでグラフで表示するところはやりませんでした。

「猫」と共起する「猫」を含む文に含まれる形態素 という解釈で頻度を数えました。

回答サンプル

「猫」表記フィルターを用意して、フィルター適用後に変化があれば、残ってる形態素を数えます。

func Answer37() {
	wordFilter := filter.NewWordFilter([]string{"猫"})
	posFilter := filter.NewPOSFilter([]filter.POS{
		{"助詞"},              // が も の を
		{"助動詞"},             // れる られる せる
		{"記号"},              // 。、?
		{"名詞", "非自立", "一般"}, // の
	}...)
	sentences, err := TokenizeTextFile("./testdata/neko.txt")
	if err != nil {
		log.Fatalf("unexpected error, %v", err)
	}
	freq := NewFreqCounter()
	for i := range sentences {
		n := len(sentences[i])         // 元の文の長さを記録しておいて
		wordFilter.Drop(&sentences[i]) // 猫が入っていたらその形態素を落とす
		if n != len(sentences[i]) {    // 長さが変わっていたら共起している形態素を足す
			posFilter.Drop(&sentences[i]) // 面白みのない形態素を落とす
			freq.Add(sentences[i]...)
		}
	}
	for i, v := range freq.List() {
		fmt.Println(i+1, v.Surface, v.Count)
	}
}

結果

1 し 83
2 吾輩 58
3 いる 46
4 ある 42
5 人間 40
6 この 38
7 する 38
8 よう 34
9 ない 33
10 云う 31

これらのプログラムのソースは github.com/ikawaha/nlp100 にあります。テストを実行すると、上記のプログラムが実行されるようになっています。

$ cd go
$ go test -v ./...