言語処理100本ノック第2章 解答と解説 前文~Q11まで

こんにちは。福田直起です。

前回に引き続き、言語処理100本ノック2020を解いているアウトプットとして、「第2章 : UNIXコマンド」の前文~Q11までを解説していきます。
他の問題の回答は、まとめページにリンクを貼ってあります。

この記事の主な対象者と目的

  • Pythonについてif文やfor文などの基本的な使い方なら知っている人が、言語処理におけるPythonの強みが理解できるようになる
  • UNIXコマンドのpwdcdlsを駆使して目的のディレクトリにたどり着くことができる人が、言語処理100本ノックの解答に必要なUNIXコマンドについて理解できるようになる

UNIXコマンドを扱うのが初めてという方には、以下の資料がおすすめです。
2018-04-Ubuntu Linux基礎 / 2018-04 Ubuntu - Speaker Deck
資料のリンク元:
開発・運用本部向け新人研修2018の講義資料を公開しました - Cybozu Inside Out | サイボウズエンジニアのブログ

なお、この章で解説していないことは、他の章で解説していることが多いので、わからないことがあった場合は他の章の記事も参考になると思います。

実行環境

  • Ubuntu 20.04.4 LTS
  • Jupyter Notebook 4.10.0
    • IPython 8.3.0
  • Python 3.8.13
  • pandas 1.4.3

第2章の解答と解説

解答・解説を読む際の注意

Jupyter Notebookでは、1つのファイルに複数のセルというセクションのようなものを持つことができます。 今回はわかりやすさのために、Jupyter NotebookのセルをPythonコードのセルとUNIXコマンドのセルに分けています。
そして、 DRY原則に従うために「一度宣言した変数や定数、関数などはあとのセルでも使える」というJupyter Notebookの仕様を活用しています。
逆に言うと、同じ名前の変数や定数を複数のセルで使うとどんどん上書きしてしまい、セルを実行する順番によっては変数や定数の値が意図しない値になってしまうことがあります。これを防ぐために、複数のセルで同じような名前を変数や定数に使いたい場合はPATH_Q11のように名前のあとに問題番号をつけています。

また、解答でファイルを出力する場合はdataというフォルダ内に出力するようにしています。
コードをコピーして実行する場合は、先にフォルダを作っておくことを推奨します。

前文――課題データのダウンロードと確認

popular-names.txtは,アメリカで生まれた赤ちゃんの「名前」「性別」「人数」「年」をタブ区切り形式で格納したファイルである.以下の処理を行うプログラムを作成し,popular-names.txtを入力ファイルとして実行せよ.さらに,同様の処理をUNIXコマンドでも実行し,プログラムの実行結果を確認せよ.

問題ページをブラウザで開いて、右クリックしてダウンロードするといった他の方法もありますが、ここでは問題のテーマがUNIXコマンドであることと、Jupyter Notebookの機能を紹介することを目的として、Jupyter Notebook上でUNIXコマンドを実行してダウンロードする方法を解説します。

解答コード(UNIXコマンド)

!wget https://nlp100.github.io/data/popular-names.txt -O popular-names.txt

!echo "ダウンロードしたファイルの行数を確認:"
!wc -l popular-names.txt

解答コードの実行結果

--2022-07-21 17:54:55--  https://nlp100.github.io/data/popular-names.txt
nlp100.github.io (nlp100.github.io) をDNSに問いあわせています... 185.199.108.153, 185.199.109.153, 185.199.110.153, ...
nlp100.github.io (nlp100.github.io)|185.199.108.153|:443 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 200 OK
長さ: 55026 (54K) [text/plain]
`popular-names.txt' に保存中

popular-names.txt   100%[===================>]  53.74K  --.-KB/s 時間 0.005s     

2022-07-21 17:54:56 (10.2 MB/s) - `popular-names.txt' へ保存完了 [55026/55026]

ダウンロードしたファイルの行数を確認:
2780 popular-names.txt

解説

Jupyter Notebook上でのUNIXコマンドの実行について

解答コードのように、!を行頭に記述することによって、その行をUNIXコマンドとして実行することができます。

ただし、この方法はOSのコマンドを実行しているという点に注意しましょう。
具体的に言うと、私はUNIX系OSUbuntu)でJupyter Notebookを動かしているのでUNIXコマンドが実行できるのですが、Windows上で動かしている場合は、WindowsUNIXではないのでUNIXコマンドは実行できません。

UNIXwgetコマンドについて

wget ダウンロードしたいファイルのURL とすることで、現在のディレクトリにファイルをダウンロードすることができます。

デフォルトでは同名のファイルがあった場合popular-names.txt.1のように連番が付きます。
つまり、複数回実行されてしまったときにファイルが増えていってしまうので注意してください。
ここでは-O ファイル名というオプションをつけることで、上書きしてファイルを増やさないように設定しています。

使用例:

wget https://nlp100.github.io/data/popular-names.txt
wget https://nlp100.github.io/data/popular-names.txt -O popular-names.txt

参考: 【 wget 】コマンド――URLを指定してファイルをダウンロードする:Linux基本コマンドTips(24) - @IT

UNIXwcコマンドについて

wc -l ファイルまでのパス
とすることで、ファイルの行数を確認できます。
ここでは、正常にダウンロードできたことを確認するために行数を表示しています。

使用例:

wc -l popular-names.txt

また、wcコマンドには他にもファイルの文字数などを表示する機能もあります。
詳しく知りたい方は、以下のサイトが参考になります。

参考: 【 wc 】コマンド――テキストファイルの文字数や行数を数える:Linux基本コマンドTips(62) - @IT

Q10. 行数のカウント

行数をカウントせよ.確認にはwcコマンドを用いよ.

解答コード(Python

with open("popular-names.txt") as input_file:
    lines_q10_1 = input_file.readlines()
    
print("行数:")
print(len(lines_q10_1))

解答コード(Python)の実行結果

行数:
2780

解答コード(UNIXコマンド)

print("Pythonで計算した行数:")
print(len(lines_q10_1))

!echo
!echo "UNIXコマンド\"cat ./popular-names.txt | wc -l\"で確認した行数:"
!cat ./popular-names.txt | wc -l

解答コード(UNIXコマンド)の実行結果

Pythonで計算した行数:
2780

UNIXコマンド"cat ./popular-names.txt | wc -l"で確認した行数:
2780

解説

ダウンロードの際にも使ったwcコマンドを使って、行数を確認しています。

withを使ってPythonでファイルを読み込む方法について

Pythonでは
with open(開きたいファイルまでのパス) as ファイルに割り当てたい変数名:
という記述をしたあと、解答のようにインデントして処理を書いていくことで、ファイルを読み込んで処理を行うことができます。 どこからどこまでの処理の間、どのファイルを開いているかがインデントによりわかりやすくなっていることと、withのあとのインデントした部分を抜ける際に、自動でファイルを閉じてくれることがメリットです。

参考: 組み込み関数 — Python 3.8.10 ドキュメント

ファイルを閉じないと、Pythonで書き込んだつもりの内容がファイルに反映されない、常駐アプリケーションとして運用した場合に他のアプリケーションの動作を妨害してしまったり、OSの制限を超えてファイルを開いてしまってエラー落ちする、といったリスクがあるので、注意が必要です。
詳しく知りたい方は以下のPythonの公式ドキュメントを読んでみてください。
7. 入力と出力 — Python 3.8.10 ドキュメント

今回の回答では、readlines()というメソッドを使って、開いたファイルを一行ごとに区切ったリストに変換してから、そのリストに対して処理を行っています。

使用例:

with open("popular-names.txt") as input_file:
    lines_q10_1 = input_file.readlines()

# ここでファイルが閉じられる

print(lines_q10_1[0:10])

参考: 7. 入力と出力 — Python 3.8.10 ドキュメント

UNIX|(パイプ)について

UNIXコマンドでは、
cat ./popular-names.txt | wc -l のように|(パイプ)でコマンドをつなぐことで、左のコマンドの結果を、右のコマンドの入力として処理をすることができます。 今回の問題は、wc -l ./popular-names.txtとしても解けるのですが、それだと結果が
2780 popular-names.txt
のように表示されてしまい、ぱっと見で比較しづらいと思いました。
なので、行数だけを表示できるように一旦catコマンドで開いてから、その結果の行数を表示するような処理の流れにしています。

参考: Linuxコマンドで使われるパイプ(|)の使い方を詳しくご紹介!

別解:withを使わずにファイルを開く方法

先程紹介したwithはやや応用的な方法で、Pythonにおけるファイルの最もシンプルな扱いは次のような書き方になります。

# ファイルを読み込んで変数に割り当てる
input_file = open(開きたいファイルまでのパス)

# ファイルを使った処理を書く
lines = input_file.readlines()

# ファイルを閉じる
input_file.close()

しかし、この方法だとclose()を通らない、つまりファイルが閉じないまま処理が終わってしまう場合があるというリスクがあります。

このリスクを回避する方法の一つは、先ほどのwithを使うことですが、もう一つ方法があります。
それが、例外処理に用いるtry finallyを用いる方法です。
例として、このQ10try finallyで解いた別解を以下に示します。

try:
    input_file = open("popular-names.txt")
    lines_q10_2 = input_file.readlines()
    
finally:
    if "input_file" in globals():
        input_file.close()

print("行数:")
print(len(lines_q10_2))

実行結果

行数:
2780

tryした処理の後は必ずfinallyの処理に進むため、このように書いておけばファイルを閉じ忘れる心配がありません。
ただし、この方法だと今度はファイルを開けないなどのエラーが起きた際に開いていないファイルを閉じてしまうことになってしまいエラーの原因になるため、そうならないように以下のglobals()を使っています。

Pythonglobals()について

Pythonglobals()グローバル変数の辞書を返す関数です。
変数が定義されているかどうか確認する場合に使うことが多いです。
以下の例のように変数名と値が入っています。

{'sample_list': [1, 2, 3, 4, 5, 1, 3, 1], 'sample_str': 'Hello, World'}

(実際には、システム変数など他の値も入っています)

今回は以下のように使って、ファイルを開けなくてエラーになった際にclose()を通らないようにしています。

if "input_file" in globals():
    input_file.close()

この関数を使う際の注意点としては、変数名を単なる文字列として扱わなければならないので、あとで変数名を変えたくなった際にIDEリファクタリング機能を使ってもカバーされるとは限らず、修正漏れが発生してしまう可能性があることです。こうなると、ファイルがclose()されなくなるという問題が発生してしまいます。

参考: 組み込み関数 — Python 3.8.10 ドキュメント

Pythonglobals()locals()について

globals()に近い働きをする関数として、ローカル変数の辞書を返すlocals()という関数もあります。
Pythonの変数は関数内でしかローカル変数としてみなされないので、関数を定義していない今回の解答では、正確さを重視してglobals()を使っています。
ただ、実際に業務でのプログラミングをする際は関数を定義してその中に処理を書くことが多いので、locals()を使ったほうがコードの再利用性が高まると思います。
今回の解答に関しては、私の環境ではglobals()locals()に置き換えても期待した動作をすることを確認できています。

Pythonでどういう場合にグローバル変数とローカル変数のどちらになるかについて知りたい方は、公式ドキュメントの以下の部分が参考になります。
プログラミング FAQ — Python 3.8.10 ドキュメント

globals()ではなくNoneを用いる方法

globals()を使った方法はリファクタリングの妨げになる可能性があるので、他のやり方も考えてみました。

input_file = None

try:
    input_file = open("popular-names.txt")
    lines_q10_2 = input_file.readlines()
    
finally:
    if input_file is not None:
        input_file.close()
    
    input_file = None
    
print("行数:")
print(len(lines_q10_2))

input_fileの初期値としてNoneをあらかじめ宣言しておくことで、ファイルを正常に開けなかった場合にはinput_fileNoneのままになるため、input_fileNoneかどうかをファイルを閉じる処理をするかどうかの判定基準にすることができます。 また、finally節の最後でinput_fileNoneを代入することで、あとの処理でもしinput_fileという変数名を使った場合でも安全に処理できるようにもなっています。

参考(Noneについて): 組み込み定数 — Python 3.8.10 ドキュメント

Q11. タブをスペースに置換

タブ1文字につきスペース1文字に置換せよ.確認にはsedコマンド,trコマンド,もしくはexpandコマンドを用いよ.

解答コード(Python

with open("popular-names.txt") as input_file:
    lines = input_file.readlines()

# 以降の解答でも使用
PATH_Q11 = "data/q11.txt"

with open(PATH_Q11, mode="w") as output_file:
    for line in lines:
        output_file.write(line.replace("\t", " "))

print("ファイルの書き出しが完了しました")

解答コード(Python)の実行結果

ファイルの書き出しが完了しました

解答コード(UNIXコマンド)

!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff <(sed -e "s/\t/ /g" ./popular-names.txt)" $PATH_Q11
!diff <(sed -e "s/\t/ /g" ./popular-names.txt) $PATH_Q11

解答コード(UNIXコマンド)の実行結果

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
diff <(sed -e s/t/ /g ./popular-names.txt) data/q11.txt

解説

Pythonでのファイルの書き込みについて

open(書き込みたいファイルまでのパス, mode="w")
のように、オプションmode="w"を追加することでファイルを書き込みモードで開きます。 書き込みたいファイルが存在しない場合は、新規に作成します。
すでに存在する場合は元々存在していたファイルを上書きする、つまり元のファイルの内容は消えてしまうので注意が必要です。

参考: 組み込み関数 — Python 3.8.10 ドキュメント

書き込み内容の指定は、
output_file.write(書き込みたい内容)
のように行います。
自動で改行が入らないので注意しましょう(今回はすでに改行が入っているデータを置換しているだけなので、そのままループさせて書き込んでいます)。

使用例:

with open(PATH_Q11, mode="w") as output_file:
    for line in lines:
        output_file.write(line.replace("\t", " "))
        # 改行が入っていないデータの場合は、以下のような行を入れる
        output_file.write("\n")

参考: 7. 入力と出力 — Python 3.8.10 ドキュメント

Jupyter NotebookにおいてUNIXコマンドのためにPythonの変数を呼び出す方法

Jupyter Notebookにおいては、
$変数名
のように記述することで、Pythonで定義した変数をUNIXコマンドで使用するために呼び出すことができます。

使用例:

HELLO_WORLD = "Hello World!"

!echo $HELLO_WORLD

UNIXdiffコマンドについて

UNIXコマンドでは、
diff 比較したいファイル1 比較したいファイル2
を実行することで、2つのファイルを比較することができます。

使用例:

diff fileA.txt fileB.txt

差分がない場合は何も出力しないので、今回はその仕様を「PythonUNIXコマンドによる処理結果が一致している」ということを示すために利用しています。
差分がある場合の出力の見方は、以下のサイトが参考になります。

参考: 【 diff 】コマンド(基本編)――テキストファイルの差分を出力する:Linux基本コマンドTips(102) - @IT 

ここでは、UNIXのプロセス置換という仕組みを使って、コマンドの結果をファイルであるかのようにdiffコマンドに読み込ませています。 プロセス置換は、<(コマンド)のように書くことで使うことができます。

使用例:

diff <(sed -e s/t/ /g ./popular-names.txt) data/q11.txt

参考: Linuxでのプロセス置換 - Qiita

また、UNIXではコマンド >書き込みたいファイルまでのパスのように記述することで、コマンドの結果をファイルに出力することもできます。
これを「リダイレクト」と呼びます。
今回は以後の問題でも上記のプロセス置換を主に使って解いていきますが、Q11をUNIXコマンドで解いた結果をリダイレクトしてファイル出力し、Pythonの結果と比較する方法も別解として載せておきます。

!echo "以下のコマンドで一旦ファイルに出力:"
!echo "sed -e "s/\t/ /g" ./popular-names.txt"
!sed -e "s/\t/ /g" ./popular-names.txt > data/q11-bash.txt

!echo
!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff data/q11-bash.txt" $PATH_Q11
!diff data/q11-bash.txt $PATH_Q11

実行結果

以下のコマンドで一旦ファイルに出力:
sed -e s/t/ /g ./popular-names.txt

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
diff  data/q11-bash.txt data/q11.txt

UNIXsedコマンドについて

指定したファイルに対して様々な処理ができるコマンドですが、ここでは
sed -e "s/置換前の文字列/置換後の文字列/g" 置換したいファイル のように指定することで、文字置換に用いています。
使用例:

sed -e s/t/ /g ./popular-names.txt

他にどのようなことができるか知りたい方は、以下のサイトが参考になります。

参考: 【 sed 】コマンド(基礎編その4)――文字列を置き換える/置換した行を出力する:Linux基本コマンドTips(56) - @IT

別解:trコマンドとexpandコマンド

今回は提示されている中で最も使える場面が多いと考えてsedコマンドを使いましたが、問題文で提示されている他の2つのコマンドの使い方と、それを使った別解も載せておきます。
どちらも用途が合えばsedコマンドよりシンプルに書けるので、覚えておくとターミナル上で直接コマンドを打ち込んで作業する場合などに役立つと思います。

trコマンド

tr 置換前の文字列 置換後の文字列
のように使って、文字を置換するコマンドです。 以下のような形で使うことが多いです。

# リダイレクトを用いる
tr "\t" " "  <./popular-names.txt

# catコマンドからパイプで繋ぐ
cat ./popular-names.txt | tr "\t" " "

今回は問題ありませんが、1文字を1文字に置き換えることしかできないことに注意が必要です。
例えば、tr "abc" "def"のように指定すると、

  • "abc"は"def"
  • "a"も"d"
  • よって"ab"も"de"

のように置換されてしまいます。

また、

  • tr "abc" "defg"のように指定すると、"abc"は"defg"ではなく"def"
  • tr "abc" "xy"のように指定すると、"abc"は"xy"ではなく"xyy"
    • つまり、"bc"は"yy"
    • "c"も"y"

と置換されてしまいます。

解答

!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff <(tr \"\t\" \" \"  <./popular-names.txt)" $PATH_Q11
!diff <(tr "\t" " "  <./popular-names.txt) $PATH_Q11

実行結果

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
diff <(tr "\t" " "  <./popular-names.txt) data/q11.txt

参考: 【 tr 】コマンド――テキストファイルの文字を置換する/削除する:Linux基本コマンドTips(52) - @IT

expandコマンド

expand 置換対象のファイル
のように使うことで、指定したいファイルのタブ文字をスペースに置換するコマンドです。 ただし、デフォルトだとタブ1文字につきスペース8文字で置換されてしまいます。
今回の問題では、「タブ1文字につきスペース1文字」という指定があるので、
expand -t 1 置換対象のファイル
のように-tオプションでスペース1文字であることを明示する必要があります。

使用例:

expand -t 1 ./popular-names.txt

解答

!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff <(expand -t 1 ./popular-names.txt)" $PATH_Q11
!diff <(expand -t 1 ./popular-names.txt) $PATH_Q11

実行結果

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
diff <(expand -t 1 ./popular-names.txt) data/q11.txt

参考: 【 expand 】コマンド/【 unexpand 】コマンド――タブと空白を変換する:Linux基本コマンドTips(61) - @IT

おわりに

以上、言語処理100本ノックの第2章の前文~Q11について解説しました。
この記事で解説した技法はのちの問題でも多用するので、しっかりと覚えておくとスムーズに解けると思います。

他の問題の回答は、まとめページからリンクを貼ってあります。

また、ブレインズコンサルティングでは一緒に働いてくれる仲間を募集しています。 ご興味のある方は、ぜひ一度採用サイトをご覧ください。