晴耕雨読

working in the fields on fine days and reading books on rainy days

[Elixir] 言語処理100本ノック(第1章)

はじめに

自然言語処理と Python のトレーニングのため、 東北大学の乾・岡崎研究室 Web ページにて公開されている言語処理100本ノックを Elixir で挑戦していきます。

第1章 : 準備運動

00. 文字列の逆順

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

input = "stressed"

String.reverse(input)
|> IO.inspect
# => "desserts"
コメント

文字列を反転する関数 String.reverse/1 があるので、それを使えば一発です。

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

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

input = "パタトクカシーー"

String.codepoints(input)
|> Enum.take_every(2)
|> Enum.join
|> IO.inspect
# => "パトカー"
コメント

リストの先頭から2つおきに要素を取ってくる Enum.take_every/2 を使うと、1,3,5,7番目の要素を取ることができます。 ただし、この関数は Enumerable を引数に取るので String.codepoints/1 で文字列を文字配列にする必要があります。

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

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

input1 = "パトカー"
input2 = "タクシー"

Enum.zip(String.codepoints(input1), String.codepoints(input2))
|> Enum.flat_map(fn {v1, v2} -> [v1, v2] end)
|> Enum.join
|> IO.inspect
# => "パタトクカシーー"

コメント

2つの配列を1つの配列にマージすると言えば zip です。 Enum.zip/1 を使うと、2つのリストのi番目の要素をもつタプルのリストを作ることができます。

また、Enum.flat_map/2 は Enum.map + List.flatten と同じで、map した後に flatten します。

03. 円周率

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

input = "Now I need a drink, alcoholic of course, after the heavy lectures involving quantum mechanics."

input
|> String.replace(~r/[,.]/, "")
|> String.split
|> Enum.map(fn word -> String.length(word) end)
|> IO.inspect
# => [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9]

コメント

与えられた文章を単語区切りにするには、 空白 " " 区切りの String.split/1 を使います。 また、与えられた文章のカンマとコロンは不要なので、事前に String.replace で取り除いています。

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文字を取り出し, 取り出した文字列から単語の位置(先頭から何番目の単語か)への連想配列(辞書型もしくはマップ型)を作成せよ.

input = "Hi He Lied Because Boron Could Not Oxidize Fluorine. New Nations Might Also Sign Peace Security Clause. Arthur King Can."
singles = [1, 5, 6, 7, 8, 9, 15, 16, 19]

input
|> String.split
|> Enum.with_index(1) # index from 1
|> Enum.map(fn {word, index} ->
    if index in singles do
      {String.first(word), index}
    else
      {String.slice(word, 0..1), index}
    end
  end)
|> Enum.into(%{})
|> IO.inspect
# => %{"Al" => 13, "Ar" => 18, "B" => 5, "Be" => 4, "C" => 6, "Ca" => 20, "Cl" => 17,
#      "F" => 9, "H" => 1, "He" => 2, "K" => 19, "Li" => 3, "Mi" => 12, "N" => 7,
#      "Na" => 11, "Ne" => 10, "O" => 8, "P" => 15, "S" => 16, "Si" => 14}

コメント

今回の課題ではループのときに添え字の番号が必要なので Enum.with_index/2 で添え字と要素がセットになったタプルのリストを作る(こんな感じ:[{0, elem1}, {1, elem2}, {2, elem3}])。 Enum.with_index の第二引数は添え字のオフセットを設定することができます。 添え字が 0 から始まるのは色々面倒なので、オフセットは 1 にします。

また、リストをマップに変換するには Enum.into/2 という関数があります。 これを使うことで、例えば [{key1, value1}, {key2, value2}]%{key1 => value1, key2 => value2} に変換することができます。 という訳で、[{"H", 1}, {"He", 2}, ...] のようなリストを作って、それを Enum.into(%{}) に渡すことで、課題を完成させます。

ちなみに、問題文の通りに解くと、マグネシウム(Mg)が Mi となって、正しくないのですが、 この問題の作者は気づいているのでしょうか。

P.S. Enum.into/3 を使えば、Enum.map/2 + Enum.into/2 と同じことができるのですが、 個人的には Enum.into/3 を使ったときの方がコードとしては読みにくいので、使いませんでした。

05. n-gram

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

defmodule Ngram do
  # ngram(["a", "b", "c"], 2) -> [["a", "b"], ["b", "c"]]
  def ngram(input, len) when is_list(input) do
    _ngram(input, len, length(input) - len, [])
  end

  # ngram("abc", 2) -> ["ab", "bc"]
  def ngram(input, len) when is_binary(input) do
    _ngram(String.codepoints(input), len, String.length(input) - len, [])
    |> Enum.map(&List.to_string/1)
  end

  defp _ngram(_,     _,   n, acc) when n <  0 do acc end
  defp _ngram(input, len, n, acc) when n >= 0 do
    result = input |> Enum.slice(n..(n + len - 1))
    _ngram(input, len, n-1, [result | acc])
  end
end

input = "I am an NLPer"

input |> Ngram.ngram(2) |> IO.inspect
# => ["I ", " a", "am", "m ", " a", "an", "n ", " N", "NL", "LP", "Pe", "er"]

input |> String.split |> Ngram.ngram(2) |> IO.inspect
# => [["I", "am"], ["am", "an"], ["an", "NLPer"]]

コメント

多分、この課題が一番難易度が高いと思います。 説明がめんどくさいので簡単に説明をすると、 ngram 関数は _ngram で末尾再帰しています。 ngram 関数は文字列かリストのどちらかが第一引数に入るのでパターンマッチングで判別しています。 第一引数が文字列の場合は String 対応の関数を使って処理します。 使うときは Ngram.ngram(文字列またはリスト, gramの長さ) としてください。 以上。

06. 集合

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

defmodule Ngram do
  # ngram(["a", "b", "c"], 2) -> [["a", "b"], ["b", "c"]]
  def ngram(input, len) when is_list(input) do
    _ngram(input, len, length(input) - len, [])
  end

  # ngram("abc", 2) -> ["ab", "bc"]
  def ngram(input, len) when is_binary(input) do
    _ngram(String.codepoints(input), len, String.length(input) - len, [])
    |> Enum.map(&List.to_string/1)
  end

  defp _ngram(_,     _,   n, acc) when n <  0 do acc end
  defp _ngram(input, len, n, acc) when n >= 0 do
    result = input |> Enum.slice(n..(n + len - 1))
    _ngram(input, len, n-1, [result | acc])
  end
end

input1 = "paraparaparadise"
input2 = "paragraph"

x = Ngram.ngram(input1, 2) |> MapSet.new
y = Ngram.ngram(input2, 2) |> MapSet.new

# 和集合
IO.inspect MapSet.union(x, y)
# => #MapSet<["ad", "ag", "ap", "ar", "di", "gr", "is", "pa", "ph", "ra", "se"]>

# 積集合
IO.inspect MapSet.intersection(x, y)
# => #MapSet<["ap", "ar", "pa", "ra"]>

# 差集合
IO.inspect MapSet.difference(x, y)
# => #MapSet<["ad", "di", "is", "se"]>

コメント

Ngram モジュールは課題06で使用したものをそのまま使いました。 和集合と積集合と差集合は MapSet(重複を許さない Map )の MapSet.union/2MapSet.intersection/2MapSet.difference/2 を使えば一発です。

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

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

template = fn (x, y, z) -> "#{x}時の#{y}は#{z}" end

IO.inspect template.(12, "気温", 22.4)
# => "12時の気温は22.4"

コメント

これは文字列内での変数展開を使えば一発でできる。

08. 暗号文

与えられた文字列の各文字を,以下の仕様で変換する関数 cipher を実装せよ.

  • 英小文字ならば(219 - 文字コード)の文字に置換
  • その他の文字はそのまま出力

この関数を用い,英語のメッセージを暗号化・復号化せよ.

defmodule Crypto do
  def cipher(input) do
    String.to_charlist(input)
    |> Enum.map(fn ch ->
        cond do
          ch >= 97 && ch <= 122 ->
            219 - ch
          true ->
            ch
        end
      end)
    |> List.to_string
  end
end

input = "In the beginning God created the heavens and the earth."

result1 = Crypto.cipher(input)
IO.inspect result1
# => "Im gsv yvtrmmrmt Glw xivzgvw gsv svzevmh zmw gsv vzigs."

result2 = Crypto.cipher(result1)
IO.inspect result2
# => "In the beginning God created the heavens and the earth."

コメント

文字列から数字の配列(C言語でいうと char 型配列)を作るには、 String.to_charlist/1 を使います。

あとは、英小文字の ASCII の範囲は 97〜122 なので、この範囲では (219 - 文字コード) を計算し、 それ以外はそのままにする関数を、先ほどの数字の配列に適用させます。

最後に List.to_string もしくは Enum.join で文字列に戻します。

09. Typoglycemia

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

input = "I couldn't believe that I could actually understand what I was reading : the phenomenal power of the human mind ."

String.split(input)
|> Enum.map(fn word ->
    cond do
      String.length(word) <= 4 ->
        word
      true ->
        max = String.length(word) - 1
        (word |> String.at(0)) <>
          (word |> String.slice(1..max-1) |> String.codepoints |> Enum.shuffle |> List.to_string) <>
          (word |> String.at(max))
    end
  end)
|> IO.inspect
# (実行するごとに結果は変わります)
# => ["I", "c'unoldt", "bieevle", "that", "I", "cloud", "aaluctly", "usnetdrnad",
#     "what", "I", "was", "raidneg", ":", "the", "pehnnaomel", "poewr", "of", "the",
#     "hmuan", "mind", "."]

コメント

これも文章を単語区切りにしたあと、おなじみの map を使って単語が4文字以下ならそのまま、 5文字以上なら 単語の1番目の文字と、シャッフルした 2〜length-1 までの文字と、単語の最後の文字 をくっつけた文字列を返します。


第2章につづく