太陽がまぶしかったから

C'etait a cause du soleil.

言語処理100本ノック第1章を Python でテスト駆動開発

言語研究のためのプログラミング入門: Pythonを活用したテキスト処理

言語処理100本ノックに挑戦

言語処理100本ノックは,実用的でワクワクするような課題に取り組みながら,プログラミング,データ分析,研究のスキルを楽しく習得することを目指した問題集です

 Mecab での形態素解析を元にテキストマイニングの初歩をやっていたのだけど、せっかくであれば基礎的なところから学び直したいと思った時に「言語処理100本ノック」が良いと教えてもらったので挑戦することにした。

 まずは「第1章: 準備運動」の解答を作成していったが、リスト内包記法やスライスを中心に Pythonic な記法を再認識するきっかけともなってよかった。

 作成したソースコードはこちらで管理している。

テスト駆動 Python

 今回の解答コードを作成するのにあたってはテスト駆動開発を前提としている。例えば以下のようなテストケースを実装前に用意する。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import nlp_000

'''
00. 文字列の逆順
文字列”stressed”の文字を逆に(末尾から先頭に向かって)並べた文字列を得よ.
'''


def test_execute():
    arg = 'stressed'
    excepted = 'desserts'
    actual = nlp_000.execute(arg)
    print(actual)
    assert(excepted == actual)

 先に問題文が意図する入力値と期待結果を定義して、 Pytest による assert チェックが通る状態を維持しながら実装していく流れ。

 このようにすることで、まずは意図通りに動くものを泥臭く作りつつ、結果を変えずに一般化や効率化などを考慮したリファクタリングが行える。

00. 文字列の逆順

文字列”stressed”の文字を逆に(末尾から先頭に向かって)並べた文字列を得よ.

return arg[::-1]

 reversed() を使ってしまえば簡単だが、スライス記法も利用できる。スライス記法の括弧内の要素はそれぞれ start, stop, step を意味する。上記の場合、0〜最終文字を後ろから1文字づつ取得した結果となるため逆順と等しくなる。

01. 「パタトクカシーー」

「パタトクカシーー」という文字列の1,3,5,7文字目を取り出して連結した文字列を得よ.

return arg[::2]

 こちらも slice 記法の step を使えば簡単に書ける。

02. 「パトカー」+「タクシー」=「パタトクカシーー」

「パトカー」+「タクシー」の文字を先頭から交互に連結して文字列「パタトクカシーー」を得よ.

return ''.join([i + j for i, j in zip(arg1, arg2)])

 複数の文字列を同時に処理できる zip の練習。リスト内包記法で配列を生成して区切り文字を空文字として連結している。

03. 円周率

“Now I need a drink, alcoholic of course, after the heavy lectures involving quantum mechanics.” という文を単語に分解し,各単語の(アルファベットの)文字数を先頭から出現順に並べたリストを作成せよ.

return [len(s) for s in re.sub(r'[^a-zA-Z\s]', '', arg).split(' ')]

 ,.もカウント対象なのかちょっと悩んだが正規表現でアルファベットとスペース以外を削除してから単語分割して、文字数を配列化した。

04. 元素記号

“Hi He Lied Because Boron Could Not Oxidize Fluorine. New Nations Might Also Sign Peace Security Clause. Arthur King Can.” という文を単語に分解し,1, 5, 6, 7, 8, 9, 15, 16, 19番目の単語は先頭の1文字,それ以外の単語は先頭の2文字を取り出し,取り出した文字列から単語の位置(先頭から何番目の単語か)への連想配列(辞書型もしくはマップ型)を作成せよ.

def execute(arg, first):
    term = arg.split(' ')
    return {
        term[i][:1 if i + 1 in first else 2]: i + 1
        for i in range(len(term))
    }

 引数 first に先頭の1文字を使う単語順を格納している前提。単語インデックスをカウントアップして、取り出すべき文字数を後置 if 構文で取得する辞書内包記法。 文章中の「1番目」がインデックスでは 0 となるインピーダンスミスマッチを解消するための i + 1 が気持ち悪い。

05. n-gram

与えられたシーケンス(文字列やリストなど)からn-gramを作る関数を作成せよ.この関数を用い,”I am an NLPer”という文から単語bi-gram,文字bi-gramを得よ.

def n_gram(arg, n):
    return [arg[i:i + n] for i in range(len(arg) - n + 1)]

 そもそも n-gram をちゃんと理解してなかったので期待結果を作るためのチートをしてしまった。

 こちらを参考に理解と実装。要するに言えば 1要素づつ基準をずらして直後にある文字や単語をセットにする手法。文字列を対象にすると辞書なしでも簡易的に単語を切り出せるが実用性が低そう。単語単位で取得する場合は単語同士の相関関係計量に使える。引数が文字列でもリストでも同じ関数が使えるのが面白い。

06. 集合

paraparaparadise”と”paragraph”に含まれる文字bi-gramの集合を,それぞれ, XとYとして求め,XとYの和集合,積集合,差集合を求めよ.さらに,’se’というbi-gramがXおよびYに含まれるかどうかを調べよ.

def _set(arg1, arg2):
    x = set(nlp_005.char_bi_gram(arg1))
    y = set(nlp_005.char_bi_gram(arg2))
    return x, y


def union(arg1, arg2):
    x, y = _set(arg1, arg2)
    return x | y


def intersection(arg1, arg2):
    x, y = _set(arg1, arg2)
    return x & y


def difference(arg1, arg2):
    x, y = _set(arg1, arg2)
    return x - y


def find(arg1, t):
    return t in nlp_005.char_bi_gram(arg1)

 先に作った bi_gram() を再利用して文字bi-gram の set を生成したら、あとは set の構文の問題。

07. テンプレートによる文生成

引数x, y, zを受け取り「x時のyはz」という文字列を返す関数を実装せよ.さらに,x=12, y=”気温”, z=22.4として,実行結果を確認せよ

return f'{x}時の{y}は{z}'

 python3 から使えるようになったテンプレート記法の良さ。

08. 暗号文

与えられた文字列の各文字を,以下の仕様で変換する関数cipherを実装せよ.
・英小文字ならば(219 - 文字コード)の文字に置換
・その他の文字はそのまま出力
この関数を用い,英語のメッセージを暗号化・復号化せよ.

return ''.join([chr(219 - ord(s)) if s.islower() else s for s in arg])

 文字コード変換の関数はチート。こんなの知らんて。

 処理対象の開始文字コードが α、終了文字コードを β とすると α <= γ <= β において(α + β) - γ という式で γ の逆数となる文字コードに変換できる。

 アスキーコードでは 97〜122までが英小文字なので、例えば b であれば (97 + 122) - 98 = 121y。これは対称的な変換であるため、もう一度計算すれば (97 + 122) - 121 = 98b と復号できる。

09. Typoglycemia

スペースで区切られた単語列に対して,各単語の先頭と末尾の文字は残し,それ以外の文字の順序をランダムに並び替えるプログラムを作成せよ.ただし,長さが4以下の単語は並び替えないこととする.適当な英語の文(例えば”I couldn’t believe that I could actually understand what I was reading : the phenomenal power of the human mind .”)を与え,その実行結果を確認せよ.

def execute(arg, seed=None):
    if seed is not None:
        random.seed(seed)

    term = arg.split(' ')
    rand = [i for i in range(len(term)) if is_rand(term, i)]
    random.shuffle(rand)

    return ' '.join([
        term[rand.pop()] if is_rand(term, i) else term[i]
        for i in range(len(term))
    ])


def is_rand(term, i):
    return i not in (0, len(term) - 1) and len(term[i]) > 4

 テスト駆動開発をするのに当たって乱数の結果が異なると検証に再現性がなくなるため、 random.seed を外部から入力して乱数固定できるようにしている。自分の場合は以下の手順で答えを出すように実装した。

  • 単語分割
  • 単語リストの中から並び替え対象単語のインデックスリストを作成
  • インデックスリストをシャッフル
  • 改めて単語リストを走査
    • 並び替え対象単語だったらシャッフル済みのインデックスリストから最後尾のインデックスを取得及び削除してまだ利用されていない並び替え対象単語を取得
    • 並び替え対象単語でなかったらそのインデックスの単語をそのまま利用
  • 結果リストを連結

 もっとスマートな書き方もありそうだけど、今のところ pop() と判定関数を使うやり方で処理効率と簡略化を両立できそうに感じている。pop() にしなければ if i in rand で並び替え対象判定が簡易化できるが、rand 用の取得カウンタを用意する必要があるので冗長なプログラムとなってしまう。

 また shuffle() されたリストの最後尾 pop() ではなく pop(random.randInt(0, len(rand))) を一回一回やることで処理行数を減らせるが、リストの中間要素削除が重いのと流石に読みづらい。

まずは準備運動

 そんなわけで、言語処理100本ノックの「第1章: 準備運動」について解答した。

 今回で N-gram が分かってきたので、改めて青空文庫の解析などにも活用できる。まだまだ序の口だし、もっと良い方法があるのかもしれないけれどひとつひとつ解いていって自然言語処理の基礎を身につけたい。