言語処理100本ノック第2章 Appendix. 厳格でないソートが行われる可能性についての検証

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

この記事は、言語処理100本ノック2020の「第2章 : UNIXコマンド」Q17~Q19を解いていて見つけた、UbuntusortコマンドとPandasのsort_values()の厳格でない挙動について検証したものです。

解答・解説の前に

この記事の

  • 主な対象者と目的
  • 実行環境
  • 解答・解説を読む際の注意

は、以前の記事で説明しています。
検索サイトから直接このページにきたなどで、これらの内容をご存知ない方は、先に読んでいただくとこの記事の内容がよりわかりやすくなると思います。

Appendix-1 : sortコマンドのUbuntuにおける厳格でない挙動について

sort -rnk 3,3
と指定すると、3列目のみを参照して並び替えるのが正しい挙動ですが、今回の実行環境であるUbuntu 20.04.4 LTSでは、3列目から最後の列、1列目から2列目の順ですべての列を参照して並び替えるという挙動になりました。

以下にその検証を載せておきます。

検証用ファイル:sample.csv

A,1,1
Z,2,10
X,2,5
A,7,12
A,4,100
A,7,10
A,7,11

検証

$ cat sample.csv | sort -k2n -t,
A,1,1
X,2,5
Z,2,10
A,7,10
A,7,11
A,7,12
A,4,100
  
$ cat sample.csv | sort -k2n,2n -t,
A,1,1
X,2,5
Z,2,10
A,4,100
A,7,10
A,7,11
A,7,12

cat sample.csv | sort -k2n,2n -t,では上三行が

A,1,1
Z,2,10
X,2,5

となるのが本来正しい挙動ですが、ここでは3列目を参照してしまっているのかcat sample.csv | sort -k2n -t,と結果が一致してしまっています。

Appendix-2 : pandasのsort_values()の厳格でない挙動について

pandas1.4.3のsort_values()は、厳格でない挙動をすることがあります。
以下にその検証結果を残しておきます。

検証用ファイル:sample2.csv

William M 42422 1916
William M 42423 1916
William M 42424 1916
William M 42422 1917
William M 42423 1917
William M 42424 1917
William M 42422 1938
William M 42423 1938
William M 42424 1938
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000

検証用Pythonコード

import pandas as pd
  
data = pd.read_table('./sample2.csv', header=None, sep='\t',
                     names=['name', 'sex', 'number', 'year'])
  
sorted_lines = data.sort_values('number')
  
sorted_lines.to_csv('sample2_output-1.csv', sep='\t', header=False, index=False)

検証用Pythonコードの出力ファイル:sample2-output.csv

William M 42422 1916
William M 42422 1917
William M 42422 1938
William M 42423 1916
William M 42423 1938
William M 42423 1917
William M 42424 1938
William M 42424 1916
William M 42424 1917
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000
John    M 50000 2000

並び替えの対象に3番目の列であるnumberしか指定していないので、3列目が同じ値のときは4列目が1916→1917→1938の順で並ぶはずですが、そうなっていません。

ただ、検証の結果わかったのですが、どうやらデータが16行以下だとこの現象は発生しないようです。
sample2.csvは17行なので、どれか一行を削ると発生しないということです。

おわりに

以上、UbuntusortコマンドとPandasのsort_values()の厳格でない挙動について検証しました。

これらの挙動を検証するきっかけになった問題は、こちらのページで解答しています。 他の問題の回答は、まとめページにリンクを貼ってあります。

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

言語処理100本ノック第2章 解答と解説 Q17~Q19まで

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

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

解答・解説の前に

この記事の

  • 主な対象者と目的
  • 実行環境
  • 解答・解説を読む際の注意

は、以前の記事で説明していますので、解答・解説を読む前にこれらを知っておきたい、という方はそちらを参照していただくようお願いします。

また、長い解説は折りたたんであります。折りたたんである解説は「解説を読む」をクリックすると見ることができます。

解答・解説

引き続き、アメリカで生まれた赤ちゃんのデータをタブ区切り形式で格納したファイルであるpopular-names.txtに対して、問題文で指示されたテキスト処理を行っていきます。
popular-names.txtのダウンロードの方法は以前の記事で解説しています。

Q17. 1列目の文字列の異なり

1列目の文字列の種類(異なる文字列の集合)を求めよ.確認にはcut, sort, uniqコマンドを用いよ.

Python

解答コード

X = set()
   
with open("popular-names.txt") as file:
    lines = file.readlines()
    
for line in lines:
    X.add(line.split()[0])
   
length = len(X)
print("1列目の文字列の種類: " + str(length))
   
# 確認用にファイル出力
with open("data/q17.txt", mode="w") as output:
    output.write(str(length) + "\n")

実行結果

1列目の文字列の種類: 136

解説

第1章でも使った集合(set)を使って解いています。

UNIXコマンド

解答コード

# Pythonの処理結果をUNIXコマンドと比較して確認
!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
  
!echo "cut -f 1 ./popular-names.txt | sort | uniq | wc -l > data/q17-bash.txt"
!cut -f 1 ./popular-names.txt | sort | uniq | wc -l > data/q17-bash.txt
  
!echo
!echo "diff data/q17-bash.txt data/q17.txt"
!diff data/q17-bash.txt data/q17.txt

実行結果

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
cut -f 1 ./popular-names.txt | sort | uniq | wc -l > data/q17-bash.txt

diff data/q17-bash.txt data/q17.txt

解説

解説を読む

  1. ファイルの一列目だけをcutコマンドで取り出す
  2. 1.の結果をsortコマンドでソートする
  3. 2.の結果からuniqコマンドで重複を取り除く
  4. wc -lコマンドで3.の結果残った行を数える
  5. ファイルとして書き出す
  6. diffコマンドでPythonの結果と比較

という流れになっています。

UNIXsortコマンドについて

テキストファイルを、指定した基準に沿って行単位で並べ替えるコマンドです。
sort オプション ファイル名
のように使います。 今回は後述のuniqコマンドのために、単純なデータを重複行が連続して並んでいる形に整理しておきたいだけなので、特にオプションは指定していません。

使用例:

sort popular-names.txt

参考: 【 sort 】コマンド――テキストファイルを行単位で並べ替える:Linux基本コマンドTips(63) - @IT

UNIXuniqコマンドについて

重複する行を1つだけ残して削除するコマンドです。
uniq ファイル名
のように使います。 ただし、元のテキストが並べ替え済であることを前提にしているので、基本的には解答や使用例のようにsortと併用することになります。

使用例:

sort popular-names.txt | uniq

参考: 【 uniq 】コマンド――重複している行を削除する:Linux基本コマンドTips(64) - @IT

sortコマンドの-uオプションについて

解答では使いませんでしたが、sortコマンドに-uオプションをつけることで、uniqコマンドを使わなくても重複する行を1つだけ残して削除してくれます。

sort -uの使用例とsort | uniqとの結果の比較:

$ cat names.txt
Mary
Anna
Emma
Elizabeth
Anna
  
$ sort -u names.txt
Anna
Elizabeth
Emma
Mary
  
$ sort names.txt | uniq
Anna
Elizabeth
Emma
Mary

このように、sort -usort | uniqは同じ結果になります。

参考: 【 sort 】コマンド――テキストファイルを行単位で並べ替える:Linux基本コマンドTips(63) - @IT

Q18. 各行を3コラム目の数値の降順にソート

各行を3コラム目の数値の逆順で整列せよ(注意: 各行の内容は変更せずに並び替えよ).確認にはsortコマンドを用いよ(この問題はコマンドで実行した時の結果と合わなくてもよい).

Python

解答コード

%%time
  
with open("popular-names.txt") as input_file:
    input_file_lines = input_file.readlines()
      
splited_lines = [line.split() for line in input_file_lines]
# 以後の別解で比較に使用する
sorted_lines = sorted(splited_lines, reverse=True, key=lambda x: int(x[2]))
  
# Ubuntuのsortコマンドは指定された列以外も比較して並び替えるので、diffで結果を検証できるように、Pythonの処理も同様にする
sorted_lines_like_unix = sorted(splited_lines, reverse=True, key=lambda x: (int(x[2]), x[0], x[1], int(x[3])))
  
joined_lines = ["\t".join(line) for line in sorted_lines]
joined_lines_like_unix = ["\t".join(line) for line in sorted_lines_like_unix]
  
print("先頭10行を確認:")
for line in joined_lines[:10]:
    print(line)
     
with open("data/q18-1.txt", mode="w") as output:
    output.write("\n".join(joined_lines_like_unix))
    output.write("\n")
  
with open("data/q18-2.txt", mode="w") as output:
    output.write("\n".join(joined_lines))
    output.write("\n")
  
print()
print("実行時間:")

実行結果

先頭10行を確認:
Linda F 99689 1947
Linda F 96211 1948
James M 94757 1947
Michael M 92704 1957
Robert M 91640 1947
Linda F 91016 1949
Michael M 90656 1956
Michael M 90517 1958
James M 88584 1948
Michael M 88528 1954

実行時間:
CPU times: user 24.9 ms, sys: 1.43 ms, total: 26.4 ms
Wall time: 22.6 ms

解説

解説を読む コード中のコメントにあるようにこの問題は解き方が色々あるのですが、順番に解説していきます。

Jupyter Notebookのマジックコマンド%%timeについて

セルの初めに%%timeと記述することで、そのセルのプログラムの実行にかかった時間を計測してくれます。
ここでは、別解との実行速度の比較のために用いています。

結果は以下のように表示されます。

実行時間:
CPU times: user 24.9 ms, sys: 1.43 ms, total: 26.4 ms
Wall time: 22.6 ms

参考: Built-in magic commands — IPython 8.4.0 documentation

Jupyter Notebookのマジックコマンドの対象指定について

Jupyter Notebookのマジックコマンドは、%をつけるとその行を、%%をつけるとセルを対象にコマンドの処理を実行する仕組みになっています。

前述のtimeを例にすると
%time 時間を計測したい処理
と書くことで、その行の実行時間を計測してくれます。

%%%の違いについて、以下に実例を示しておきます。

%%time
  
print("print(\"Hello!\")の処理時間:")
%time print("Hello!")
  
print()
print("セル全体の処理時間:")

実行結果:

print("Hello!")の処理時間:
Hello!
CPU times: user 25 µs, sys: 0 ns, total: 25 µs
Wall time: 28.6 µs

セル全体の処理時間:
CPU times: user 1.22 ms, sys: 0 ns, total: 1.22 ms
Wall time: 1.14 ms

参考: Introducing IPython — IPython 8.4.0 documentation

Pythonsorted関数のオプションについて
reverseオプション

reverse=TrueのようにTrueを設定すると、リストを降順でソートすることができます。
省略した場合はFalse、すなわち昇順になります。

keyオプション

リストの各項目に指定された関数を適用し、その結果によってソートするためのコマンドです。

今回はリストのリストをソートしようとしているので、後述のラムダ式によってソート基準になる項目を呼び出すのに使っています。

参考: ソート HOW TO — Python 3.8.10 ドキュメント

Pythonラムダ式について

ラムダ式とは、関数的な処理をしたいが、関数を定義するほど長い処理でもないし、使いまわしたりもしないという場合に便利な記法です。

今回はlambda x: int(x[2])のようにすることで、各リストの2番目の項目(つまり3コラム目)をintに変換した値を返すという処理を書いています。   これが前述のkeyオプションに入力されることで、ソート条件が問題文の要件を満たすというわけですね。 また、lambda x: (int(x[2]), x[0], x[1], int(x[3])))のようにカッコでくくることで、複数のkeyを指定することもできます。

参考: 6. 式 (expression) — Python 3.8.10 ドキュメント

UNIXコマンド

解答コード

!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-1.txt"
!diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-1.txt

実行結果

Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-1.txt

解説

解説を読む

UNIXコマンドにおける複数オプションの指定の仕方

UNIXコマンドにおいて複数のオプションを指定する場合、以下の二通りの方法があります。 この解答のsort -rnk 3,3を例にして説明します。

1.sort -rnk 3,3
引数が必要なオプションが1個以下の場合は、このように指定したほうが短くなります。

2.sort -r -n -k 3,3
Q16のように複数のオプションにそれぞれ引数が必要な場合は、このような書き方が必要になります。 参考までにQ16のコマンドをもう一度提示しておきます。

split --lines=556 --numeric-suffixes=1  --additional-suffix=.txt ./popular-names.txt data/q16-unix-
sortコマンドのオプションについて

ここまででオプションの指定方法が2通りあることを理解していただけたと思うので、ここからは解答のsort -rnk 3,3のコマンドで使っているオプションについて解説していきます。

-rオプション

逆順、ここでは降順でソートするオプションです。

-nオプション

文字列を数値とみなしてソートするオプションです。
数値でソートしたい際はこのオプションを入れておかないと、意図したソートにならないので注意が必要です。

-kオプション

-k ソート基準にしたい最初の列,ソート基準にしたい最後の列
のように使ってソート基準にしたい列を指定するオプションです。
注意点ですが、ソート基準にしたい最後の列を指定しないと、ソート基準にしたい最初の列から各行の最後の列までを参照して並べ替えてしまうので、一列のみを指定したい場合は解答のように3,3などとする必要があります。

参考: 【 sort 】コマンド――テキストファイルを行単位で並べ替える:Linux基本コマンドTips(63) - @IT

注意:sortコマンドのUbuntuにおける挙動について

解答の以下のコメントが気になった方もいらっしゃるかもしれません。

Ubuntuのsortコマンドは指定された列以外も比較して並び替えるので、diffで結果を検証できるように、Pythonの処理も同様にする

この問題を解いていて気づいたことなのですが、Ubuntuのバージョンによっては、sortコマンドは厳格ではない挙動をします。
これについて詳しく知りたい方は、別記事で検証・解説していますので、そちらが参考になります。

別解

別解を読む

sortedを使うのではなく、バブルソートを書く別解

Pythonでは、sorted関数を使わなくても自分でソートを書くこともできます。
以下はバブルソートを書いた解です。

Python
解答コード
%%time
  
with open("popular-names.txt") as input_file:
    input_file_lines = input_file.readlines()
    
splited_lines = [line.split() for line in input_file_lines]
  
for i in range(len(splited_lines)):
    for j in range(len(splited_lines) - i -1):
        if int(splited_lines[j][2]) < int(splited_lines[j+1][2]): 
            splited_lines[j], splited_lines[j+1] = splited_lines[j+1], splited_lines[j] 
  
joined_lines = ["\t".join(line) for line in splited_lines]
  
print("先頭10行を確認:")
for line in joined_lines[:10]:
    print(line)
    
with open("data/q18-3.txt", mode="w") as output:
    output.write("\n".join(joined_lines))
    output.write("\n")
  
print()
print("実行時間:")
実行結果
先頭10行を確認:
Linda F 99689 1947
Linda F 96211 1948
James M 94757 1947
Michael M 92704 1957
Robert M 91640 1947
Linda F 91016 1949
Michael M 90656 1956
Michael M 90517 1958
James M 88584 1948
Michael M 88528 1954

実行時間:
CPU times: user 2.49 s, sys: 0 ns, total: 2.49 s
Wall time: 2.48 s
UNIXコマンド
解答コード
# 最初の解答の結果と比較して確認
!echo "ラムダ式を使う解の結果とバブルソートを書く解の出力ファイルを比較:"
!echo "diff data/q18-2.txt data/q18-3.txt"
!diff data/q18-2.txt data/q18-3.txt
実行結果
ラムダ式を使う解の結果とバブルソートを書く解の出力ファイルを比較:
diff data/q18-2.txt data/q18-3.txt

pandasライブラリを使った別解

Pythonでデータを扱うのによく使われるpandasライブラリを使った別解も載せておきます。

Python
解答コード
%%time
  
# Pandasを使う解
import pandas as pd
  
# 問題のファイルをデータとして読み込み、以降の問題でも使用する
data = pd.read_table('./popular-names.txt', header=None, sep='\t',
                     names=['name', 'sex', 'number', 'year'])
  
# Ubuntuのsortコマンドは指定された列以外も比較して並び替えるので、diffで結果を検証できるように、Pythonの処理も同様にする
# Pandasの単列ソートとsortedを使った単列ソートの結果が合わないので、sortコマンドと同様に全列でソートする
sorted_lines = data.sort_values(['number', 'name', 'sex', 'year'], ascending=False)
  
print("先頭10行を確認:")
print(sorted_lines[:10])
  
sorted_lines.to_csv('data/q18-4.txt', sep='\t', header=False, index=False)
  
print()
print("実行時間:")
実行結果
先頭10行を確認:
         name sex  number  year
1340    Linda   F   99689  1947
1360    Linda   F   96211  1948
1350    James   M   94757  1947
1550  Michael   M   92704  1957
1351   Robert   M   91640  1947
1380    Linda   F   91016  1949
1530  Michael   M   90656  1956
1570  Michael   M   90517  1958
1370    James   M   88584  1948
1490  Michael   M   88528  1954

実行時間:
CPU times: user 26.6 ms, sys: 0 ns, total: 26.6 ms
Wall time: 21.6 ms
解説

解説を読む

read_table()

データをpandasに読み込ませるためのメソッドです。
データはDataFrameという型になります(Pythonでは型を意識する必要はあまりありませんが、公式ドキュメントなどでよく出てくるので覚えておくと良いと思います)。 解答の
read_table('./popular-names.txt', header=None, sep='\t', names=['name', 'sex', 'number', 'year'])
を例に引数やオプションを解説します。

引数'./popular-names.txt'は、データとして読み込ませたいファイルへのパスを指定しています。
header=Noneオプションは、ヘッダーのないファイルを読み込みたい場合につけるオプションです。デフォルトだと一行目をヘッダーとして扱おうとするので注意が必要です。
sepオプションは、区切り文字を指定しています。
namesオプションは、リストやタプルで各列に名前をつけるオプションです。データの列に名前をつけて、コードの可読性を高められるのがpandasの大きなメリットのひとつなので、header=Noneオプションを設定した場合は指定したほうがいいでしょう。

参考: pandas.read_table — pandas 1.4.3 documentation

sort_values()

データをソートするためのメソッドです。   解答の
sort_values(['number', 'name', 'sex', 'year'], ascending=False)
を例に解説します。

引数['number', 'name', 'sex', 'year']の部分では、ソートの基準にする列と順序をリストで指定しています。
問題文的には本来numberだけを指定すればいいのですが、Pandasのソートも一列だけを指定しているのに、他の列を参照してソートしているような挙動をすることがあるので、今回はすべての列を指定しています。
これについては別記事で検証・解説しています。  
ascending=Falseオプションは、データを降順にソートするよう指定するオプションです。つけなかった場合は、昇順でのソートになります。

参考: pandas.DataFrame.sort_values — pandas 1.4.3 documentation

to_csv()

データをファイルに書き出すメソッドです。
名前にcsvと入っていますが、拡張子や区切り文字は自由に指定することができます。
解答の
to_csv('data/q18-4.txt', sep='\t', header=False, index=False)
を例に解説します。

引数'data/q18-4.txt'は、データの書き出し先のパスを指定しています。
sepオプションは、区切り文字を指定しています。
header=Falseオプションは、デフォルトでは一行目に追加されるヘッダー列を追加しないためのオプションです。
index=Falseオプションは、デフォルトでは左端列に追加されるインデックス列を追加しないためのオプションです。

参考: pandas.DataFrame.to_csv — pandas 1.4.3 documentation

UNIXコマンド
解答コード
# UNIXコマンドと比較して確認
!echo "UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:"
!echo "diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-4.txt"
!diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-4.txt
実行結果
UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:
diff <(cat ./popular-names.txt | sort -rnk 3,3) data/q18-4.txt

各解答の実行速度の比較

まず、3つの解答の実行速度を比較します。

sorted関数

実行時間:
CPU times: user 24.9 ms, sys: 1.43 ms, total: 26.4 ms
Wall time: 22.6 ms

バブルソートを書く

実行時間:
CPU times: user 2.49 s, sys: 0 ns, total: 2.49 s
Wall time: 2.48 s

pandasを使う

実行時間:
CPU times: user 26.6 ms, sys: 0 ns, total: 26.6 ms
Wall time: 21.6 ms

僅差ですが、今回はpandasを使うのが一番早いですね。
一方、バブルソートを書くのは速度的には不利なようです。

ソートアルゴリズムとその計算量の比較

それでは、なぜそれぞれの方法で速度に違いが出たのでしょうか。
それは、それぞれの方法で使われているソートアルゴリズムの違いにあります。

sorted関数のアルゴリズムには、公式ドキュメントによると、Timsortというアルゴリズムが使われています。

バブルソートを書く解のアルゴリズムは、当然バブルソートですね。

pandassort_values()は、標準ではクイックソートが使われます。
ちなみに、ソートアルゴリズムkindオプションで指定することもできます。
どう指定すればどんなソートアルゴリズムが選べるかは、以下の公式ドキュメントが参考になります。
pandas.DataFrame.sort_values — pandas 1.4.3 documentation

それでは、それぞれの計算量を見てみましょう。

方法 ソートアルゴリズム 平均計算時間 最悪計算時間
sorted関数 Timsort O(NlogN) O(NlogN)
バブルソートを書く バブルソート O(n2) O(n2)
pandasを使う クイックソート O(NlogN) O(n2)

計算時間はO(NlogN)よりO(n^2)の方が長いため、平均計算時間と最悪計算時間がO(n^2)であるバブルソートが速度的に不利であることがわかります。
また、最悪計算時間同士の比較では、Timsortがもっとも速度的に有利ですね。
従って、今回扱った方法の中では、Timsortを採用しているsorted関数が最も効率よくソートが行えると言えるでしょう。

それぞれの方式のメリット・デメリット

ここまでの内容から、それぞれの方式のメリット・デメリットを挙げておきます。

  • sorted
    • 実行速度:比較的速い
    • メリット:外部ライブラリをインポートする必要がない
    • デメリット:pandasと違って、列に名前をつけられない
  • ソートを自分で書く
    • 実行速度:比較的遅い
    • メリット:あまりない
    • デメリット:アルゴリズムが適切でないと処理速度が遅くなる
  • pandasを使う
    • 実行速度 : 比較的速い
    • メリット:データの列に名前をつけて扱うことができ、コードの可読性が高くなる
    • デメリット:pandasでのデータの扱いについて基本的な知識が必要

ソートを自分で書くような用途ではPythonよりC++のような高速な言語の方が向いているため、Pythonでソートを書くメリットはあまりないのですが、今回は問題を解くにおいて、既成の関数やライブラリをなるべく使わずに解くことにも意味があると考えたため、ソートを自分で書く方法でも解答しました。

Q19. 各行の1コラム目の文字列の出現頻度を求め,出現頻度の高い順に並べる

各行の1列目の文字列の出現頻度を求め,その高い順に並べて表示せよ.確認にはcut, uniq, sortコマンドを用いよ.

Python

解答コード

%%time
  
def count_names(lines: list) -> dict:
    name_count_dict = {}
    
    for item in lines:
        if item in name_count_dict:
            name_count_dict[item] = name_count_dict[item] + 1
            
        else:
            name_count_dict[item] = 1
    
    return name_count_dict
    
def sort_names(name_count_dict: dict) -> list:
    # タプルのリストにし、降順にソート
    return sorted(name_count_dict.items(), reverse=True, key=lambda x: (int(x[1]), x[0])) 
    
def create_name_count_ranking(lines: list) -> list:
    name_count_dict = count_names(lines)
    name_count_ranking = sort_names(name_count_dict)
    
    return name_count_ranking

  
with open("popular-names.txt") as input_file:
    input_file_lines = input_file.readlines()
    
lines = [line.split()[0] for line in input_file_lines]
name_count_ranking = create_name_count_ranking(lines)
  
print("先頭10行を確認:")
print(name_count_ranking[:10])
  
with open("data/q19-1.txt", mode="w") as output:
    for line in name_count_ranking:
        line_str = str(line[1]) + " " + line[0] + "\n"
        output.write(line_str)
  
print()
print("実行時間:")

実行結果

先頭10行を確認:
[('James', 118), ('William', 111), ('Robert', 108), ('John', 108), ('Mary', 92), ('Charles', 75), ('Michael', 74), ('Elizabeth', 73), ('Joseph', 70), ('Margaret', 60)]

実行時間:
CPU times: user 7.6 ms, sys: 2.34 ms, total: 9.94 ms
Wall time: 6.62 ms

解説

解説を読む 「各行の1列目の文字列」とは名前のことなので、「データ内で名前の人気ランキングを作る」という風に解釈できる問題です。
従って、以下の3つの関数を実装して解きました。

  1. 各行の1列目の文字列のみを切り出した内容を、名前がキーで出現数が値の辞書型として集計するcount_names
  2. 名前がキーで出現数が値の辞書型をタプルのリストにし、降順にソートするsort_names
  3. 上記2つの関数を適切な順番で呼び出すcreate_name_count_ranking

解答コードにはcount_namessort_namesを直接呼び出す処理はないので、create_name_count_rankingに内包してしまってもエラーになったりはしません。 しかし、関数の役割は最小にするほうが可読性が高いコードになるので、このように3つの関数に分けています。

UNIXコマンド

解答コード

!echo "UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:"
!echo "diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-1.txt"
!diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-1.txt

実行結果

UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:
diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-1.txt

解説

解説を読む UNIXコマンドは、

  1. ファイルの一列目だけをcutコマンドで取り出す
  2. 1.の結果をsortコマンドでソートする
  3. 2.の結果からuniq -cコマンドで重複行を取り除きつつ、重複行になっている項目それぞれの数をカウントする(オプションについては以下で解説します)
  4. 3.の結果をsort -rnコマンドでソートする
  5. sed 's/^ *\| *$//g'コマンドで余計な空白を取り除く
  6. diffコマンドでPythonの結果と比較

という流れになっています。

uniqコマンドの-cオプションについて

重複行を取り除きつつ、重複行になっている項目それぞれの数をカウントするオプションです。 結果のフォーマットは118 Jamesのように数→項目の順になります。今回はPythonも同じように出力するようにしています。

参考: 【 uniq 】コマンド――重複している行を削除する:Linux基本コマンドTips(64) - @IT

別解

別解を読む

collectionsライブラリを使った別解

解答では辞書型を用いた集計関数を実装していますが、Pythoncollectionsライブラリには、 リスト内に同じ項目が何回出てくるかの辞書を作ってくれる関数があります。

Python
解答コード
%%time
  
import collections
  
with open("popular-names.txt") as input_file:
    input_file_lines = input_file.readlines()
    
lines = [line.split()[0] for line in input_file_lines]
count_tuples = sorted(collections.Counter(lines).items(), key=lambda i: (i[1], i[0]), reverse=True)
  
print("先頭10行を確認:")
print(count_tuples[:10])
  
with open("data/q19-2.txt", mode="w") as output:
    for line in count_tuples:
        line_str = str(line[1]) + " " + line[0] + "\n"
        output.write(line_str)
  
print()
print("実行時間:")
実行結果
先頭10行を確認:
[('James', 118), ('William', 111), ('Robert', 108), ('John', 108), ('Mary', 92), ('Charles', 75), ('Michael', 74), ('Elizabeth', 73), ('Joseph', 70), ('Margaret', 60)]

実行時間:
CPU times: user 5.29 ms, sys: 1.62 ms, total: 6.92 ms
Wall time: 4.43 ms
解説

collections.Counter(集計対象のリスト)のように記述することで、指定したリストに同じ値が何回出てくるかを辞書形式(keyが列の値、valueが出現回数)で集計してくれます。
使用例

import collections
  
sample_list = [1, 2, 3, 4, 5, 1, 3, 1]
sample_dict = collections.Counter(sample_list)
  
print(sample_dict[1])

参考: collections --- コンテナデータ型 — Python 3.8.10 ドキュメント

UNIXコマンド
解答コード
!echo "UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:"
!echo "diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-2.txt"
!diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-2.txt
実行結果
UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:
diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-2.txt

pandasライブラリを使った別解

Q18で使用したpandasライブラリにも、この問題を解くのに便利な関数が存在するので、それを使った別解を紹介します。

Python
解答コード
%%time
  
import pandas as pd
  
# Q18で読み込んだデータを使用
value_counts = data['name'].value_counts()
count_tuples = sorted(value_counts.items(), key=lambda i: (i[1], i[0]), reverse=True)
  
print("先頭10行を確認:")
print(count_tuples[:10])
  
with open("data/q19-3.txt", mode="w") as output:
    for line in count_tuples:
        line_str = str(line[1]) + " " + line[0] + "\n"
        output.write(line_str)
  
print()
print("実行時間:")
実行結果
先頭10行を確認:
[('James', 118), ('William', 111), ('Robert', 108), ('John', 108), ('Mary', 92), ('Charles', 75), ('Michael', 74), ('Elizabeth', 73), ('Joseph', 70), ('Margaret', 60)]

実行時間:
CPU times: user 5.1 ms, sys: 1.57 ms, total: 6.67 ms
Wall time: 6.47 ms
解説

pandasではdata['列名'].value_counts()のように記述することで、指定列に同じ値が何回出てくるかを辞書形式(keyが列の値、valueが出現回数)で集計してくれます。

参考: pandas.DataFrame.value_counts — pandas 1.4.3 documentation

UNIXコマンド
解答コード
!echo "UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:"
!echo "diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn |sed 's/^ *\| *$//g') data/q19-3.txt"
!diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn | sed 's/^ *\| *$//g') data/q19-3.txt
実行結果
UNIXコマンドで同様の処理をした結果とPythonの出力ファイルを比較:
diff <(cut -f 1 ./popular-names.txt | sort | uniq -c | sort -rn |sed 's/^ *\| *$//g') data/q19-3.txt

おわりに

以上、言語処理100本ノックの第2章のQ17~Q19について解説しました。
今回はPythonの結果とUNIXコマンドの結果を合わせるのにかなり苦労しましたが、いい勉強になったと感じています。
他の問題の回答は、まとめページにリンクを貼ってあります。

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

言語処理100本ノック第2章 解答と解説 Q14~Q16まで

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

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

解答・解説の前に

この記事の

  • 主な対象者と目的
  • 実行環境
  • 解答・解説を読む際の注意

は、以前の記事で説明していますので、解答・解説を読む前にこれらを知っておきたい、という方はそちらを参照していただくようお願いします。

また、長い解説は折りたたんであります。折りたたんである解説は「解説を読む」をクリックすると見ることができます。

実行環境

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

解答・解説

引き続き、アメリカで生まれた赤ちゃんのデータをタブ区切り形式で格納したファイルであるpopular-names.txtに対して、問題文で指示されたテキスト処理を行っていきます。
popular-names.txtのダウンロードの方法は以前の記事で解説しています。

Q14. 先頭からN行を出力

自然数Nをコマンドライン引数などの手段で受け取り,入力のうち先頭のN行だけを表示せよ.確認にはheadコマンドを用いよ.

Python

解答コード

%%writefile data/q14.py
# このセルを実行すると、dataフォルダ内に.pyファイルが書き出される
# 次のセルでその.pyファイルにコマンドライン引数を与えて実行する

import typer

# typerでコマンドライン入力を引数に割り当てている
# typerは大文字をコマンドライン入力の割当先に使うとエラーになるので、小文字のnを使う
def output_head(n :int = typer.Argument(..., min=1)):
    with open("popular-names.txt") as file:
        lines = file.readlines()
        
    # nの値がファイルの行数より大きい場合、エラー
    if n > len(lines):
        typer.echo("[ERROR]入力された値が大きすぎます", err=True)
        raise typer.Abort()
        
    print(f'Pythonで先頭{n}行を出力:')
    for line in lines[:n]:
        print(line.replace("\n", ""))

if __name__ == '__main__':
    typer.run(output_head)

実行結果

Writing data/q14.py

解説

解説を読む 問題の条件を満たすために、Jupyter Notebookのマジックコマンドという機能と、Pythontyperというライブラリを使用しています。

Jupyter Notebookのマジックコマンドについて

Jupyter Notebookでは、UNIXコマンドなどのOSのコマンド以外にも、マジックコマンドという便利なコマンドを使える機能があります。
この解答に使った2つのコマンドを紹介します。

%%writefile 書き出し先のパス
このコマンドは、このコマンドを書いたセルの内容をそのまま.pyファイルとして書き出します。
今回は、以下のtyperライブラリを使ったコマンドラインアプリケーションとして実行するために、一旦書き出しています。

使用例:

%%writefile hello.py

print("Hello, World!")

%run 実行したいPythonコードのパス
このコマンドはPythonコードを実行します。
解答のように、コマンドライン引数やオプションを指定することができます。

使用例:

%run hello.py
Pythontyperライブラリについて

オプション解析処理を行うなど、コマンドラインアプリケーションを作りやすくしてくれるライブラリです。

Python3.6以降が必須です。
また、標準ライブラリではないので、
pip install typer
でインストールする必要があります。

以下に、今回使ったものを中心にいくつかtyperの便利な点を紹介します。

def output_head(n :int = typer.Argument(..., min=1)):
...の部分には初期値を指定することもできますが、今回は...を指定することで「初期値なし、入力必須」としています。
また、min=1で、最小値に1を指定しています。
これらの指定に違反した場合(型にintを指定しているので、整数以外が入力された場合も含む)はtyperがエラーメッセージを出してくれるので、便利です。

typer.echo(エラーメッセージ, err=True)
このように記述することで、エラーメッセージを出力することができます。
今回は、ファイルの行数をコマンドラインで指示された行数が上回った場合にエラーメッセージを出力するために使っています。
print()と異なる点としては、err=Trueオプションを付けることで標準エラー出力に出力できるので、エラーログを別ファイルに出力したい場合にも対応できます。

使用例:

typer.echo("[ERROR]入力された値が大きすぎます", err=True)

raise typer.Abort()
プログラムを終了し、標準出力に”Aborted!"というメッセージを表示します。 異常終了であることが明示できます。

if __name__ == '__main__':
    typer.run(最初にしたい処理が書かれている関数名)

最後にこれを記述して、最初にしたい処理が書かれている関数を呼び出します。

参考: オプション解析モジュール typer を使いこなそう - Qiita

標準入力を受け取る別解

Pythonでは
N = input()
のように書くことで、実行時にプログラムを入力待ちにして、ユーザーからの入力結果を変数Nに格納することができます。

これを使った別解を、以下に記載しておきます。

import sys
import re

def output_head(N :int):
    with open("popular-names.txt") as file:
        lines = file.readlines()
        
    # nの値がファイルの行数より大きい場合、エラー
    if N > len(lines):
        raise ValueError
    
    for line in lines[:N]:
        print(line.replace("\n", ""))


# 標準入力を受け取る
try:
    N = input()
    
    # 標準入力が自然数でない場合はエラー
    if not re.match(r"^[1-9][0-9]*$", N): 
        raise ValueError
        
except ValueError:
    print("[ERROR]自然数を入力してください")
    sys.exit(1)

print("Pythonで先頭" + N + "行を出力:")
try:
    output_head(int(N))

except ValueError:
    print("[ERROR]入力された値が大きすぎます")
    sys.exit(1)

なお、入力が自然数かどうかの判定に今回は正規表現を用いています。 正規表現については第3章で詳しく解説する予定ですが、今知りたいという方には以下のPythonの公式ドキュメントが参考になります。

re --- 正規表現操作 — Python 3.8.10 ドキュメント

UNIXコマンド

解答コード

# Python関数の処理結果をUNIXコマンドと比較して確認

# 入力する自然数Nを定義
N = 5

%run data/q14.py $N

!echo
!echo "以下のUNIXコマンドで同様の処理をした結果:"
!echo "head -n" $N "popular-names.txt"
!head -n $N popular-names.txt

実行結果

Pythonで先頭5行を出力:
Mary F 7065 1880
Anna F 2604 1880
Emma F 2003 1880
Elizabeth F 1939 1880
Minnie F 1746 1880

以下のUNIXコマンドで同様の処理をした結果:
head -n 5 popular-names.txt
Mary F 7065 1880
Anna F 2604 1880
Emma F 2003 1880
Elizabeth F 1939 1880
Minnie F 1746 1880

解説

解説を読む

UNIXheadコマンドについて

head -n 行数 対象ファイル
を実行することで、ファイルの先頭から任意の行数を表示するコマンドです。
また、オプションなしにhead 対象ファイルとすることで、先頭10行を表示します。

使用例:

head -n 5 popular-names.txt
head popular-names.txt

次の問題で使用するtailと並んで、最も使うUNIXコマンドの一つです。

参考: 【 head 】コマンド/【 tail 】コマンド――長いメッセージやテキストファイルの先頭だけ/末尾だけを表示する:Linux基本コマンドTips(3) - @IT

Q15. 末尾のN行を出力

自然数Nをコマンドライン引数などの手段で受け取り,入力のうち末尾のN行だけを表示せよ.確認にはtailコマンドを用いよ.

Python

解答コード

%%writefile data/q15.py
# このセルを実行すると、dataフォルダ内に.pyファイルが書き出される
# 次のセルでその.pyファイルにコマンドライン引数を与えて実行する

import typer

# typerでコマンドライン入力を引数に割り当てている
# typerは大文字をコマンドライン入力の割当先に使うとエラーになるので、小文字のnを使う
def output_tail(n :int = typer.Argument(..., min=1)):
    with open("popular-names.txt") as file:
        lines = file.readlines()
        
    # nの値がファイルの行数より大きい場合、エラー
    if n > len(lines):
        typer.echo("[ERROR]入力された値が大きすぎます", err=True)
        raise typer.Abort()
        
    print(f'Pythonで先頭{n}行を出力:')
    for line in lines[-n:]:
        print(line.replace("\n", ""))

if __name__ == '__main__':
    typer.run(output_tail)

実行結果

Writing data/q15.py

解説

Pythonコードでやっていることは、ほぼ上記のQ14と同じです。

UNIXコマンド

解答コード

# Python関数の処理結果をUNIXコマンドと比較して確認

# 入力する自然数Nを定義
N = 5

%run data/q15.py $N

!echo
!echo "以下のUNIXコマンドで同様の処理をした結果:"
!echo "tail -n" $N "popular-names.txt"
!tail -n $N popular-names.txt

実行結果

Pythonで先頭5行を出力:
Benjamin M 13381 2018
Elijah M 12886 2018
Lucas M 12585 2018
Mason M 12435 2018
Logan M 12352 2018

以下のUNIXコマンドで同様の処理をした結果:
tail -n 5 popular-names.txt
Benjamin M 13381 2018
Elijah M 12886 2018
Lucas M 12585 2018
Mason M 12435 2018
Logan M 12352 2018

解説

解説を読む

UNIXtailコマンドについて

tail -n 行数 対象ファイル
を実行することで、ファイルの末尾から任意の行数を表示するコマンドです。
また、オプションなしにtail 対象ファイルとすることで、末尾10行を表示します。

使用例:

tail -n 5 popular-names.txt
tail popular-names.txt

参考: 【 head 】コマンド/【 tail 】コマンド――長いメッセージやテキストファイルの先頭だけ/末尾だけを表示する:Linux基本コマンドTips(3) - @IT

Q16. ファイルをN分割する

自然数Nをコマンドライン引数などの手段で受け取り,入力のファイルを行単位でN分割せよ.同様の処理をsplitコマンドで実現せよ.

Python

解答コード

%%writefile data/q16.py
# このセルを実行すると、dataフォルダ内に.pyファイルが書き出される
# 次のセルでその.pyファイルにコマンドライン引数を与えて実行する

import typer
import numpy

# typerでコマンドライン入力を引数に割り当てている
# typerは大文字をコマンドライン入力の割当先に使うとエラーになるので、小文字のnを使う
def output_split(n :int = typer.Argument(..., min=2)) -> int:
    with open("popular-names.txt") as file:
        lines = file.readlines()
        
    # nの値がファイルの行数より大きい場合、エラー
    if n > len(lines):
        typer.echo("[ERROR]入力された値が大きすぎます", err=True)
        raise typer.Abort()
        
    for i, splited_lines in enumerate(numpy.array_split(lines, n), 1):
            
        with open(f'data/q16-{i}.txt', mode="w") as output:
            for line in splited_lines:
                output.write(line)
                
    typer.echo("ファイルの書き出しが完了しました")

if __name__ == '__main__':
    typer.run(output_split)

実行結果

Writing data/q16.py

解説

解説を読む

Pythonnumpyライブラリについて

ざっくり言うと、Pythonにおいて、標準ライブラリだと手間がかかる計算処理を簡単にしてくれるライブラリです。

どんなライブラリなのかを公式ドキュメントより引用しておくと、

NumPy is the fundamental package for scientific computing in Python.
(訳:NumPyはPythonにおける計算科学のための基本的なパッケージです)

とのことです。

今回はnumpy.array_split(target_list, n)というtarget_listn分割した配列を生成してくれるメソッドを使っています。

使用例:

import numpy

sample_list = [1, 2, 3, 4, 5, 6, 7, 8, 9]

print(numpy.array_split(sample_list, 3))

# 実行結果:[array([1, 2, 3]), array([4, 5, 6]), array([7, 8, 9])]


# 配列数が分割数の倍数でない場合の例
sample_list2 = [1, 2, 3, 4, 5, 6, 7]

print(numpy.array_split(sample_list2, 3))

# 実行結果:[array([1, 2, 3]), array([4, 5]), array([6, 7])]

参考: numpy.array_split — NumPy v1.23 Manual

UNIXコマンド

解答コード

# Python関数の処理結果をUNIXコマンドと比較して確認

# 入力する自然数Nを定義
N = 5

%run data/q16.py $N
!echo

!echo "上のセルのPythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"

!echo "Pythonの最初の出力ファイルの行数と同じ行数ごとに分割する"
cat_first_split = !cat data/q16-1.txt | wc -l
first_split_len = cat_first_split[0]
!echo "Pythonの最初の出力ファイルの行数:" $first_split_len
!echo

UNIX_FILE_PREFIX="data/q16-unix-"

!echo "split --lines=$first_split_len --numeric-suffixes=1  --additional-suffix=.txt ./popular-names.txt" $UNIX_FILE_PREFIX
!split --lines=$first_split_len --numeric-suffixes=1  --additional-suffix=.txt ./popular-names.txt $UNIX_FILE_PREFIX

unix_output_file_names = !ls data/q16-unix-*
for i, file_name_unix in enumerate(sorted(unix_output_file_names), 1):
    file_name_python = "data/q16-" + str(i) + ".txt"
    
    !echo
    !echo "diff " $file_name_python $file_name_unix
    !diff $file_name_python $file_name_unix

実行結果

上のセルのPythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
split --lines=556 --numeric-suffixes=1  --additional-suffix=.txt ./popular-names.txt data/q16-unix-

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

上のセルのPythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:
Pythonの最初の出力ファイルの行数と同じ行数ごとに分割する
Pythonの最初の出力ファイルの行数: 556

split --lines=556 --numeric-suffixes=1  --additional-suffix=.txt ./popular-names.txt data/q16-unix-

diff  data/q16-1.txt data/q16-unix-01.txt

diff  data/q16-2.txt data/q16-unix-02.txt

diff  data/q16-3.txt data/q16-unix-03.txt

diff  data/q16-4.txt data/q16-unix-04.txt

diff  data/q16-5.txt data/q16-unix-05.txt

解説

解説を読む UNIXコマンドは、Jupyter Notebookの仕様を生かして、Pythonのfor文の中でUNIXコマンドを実行しています。
(UNIXコマンドにもfor文を書く方法はあるのですが、Jupyter Notebookの仕様だとPythonのfor文を使うほうがわかりやすいためです)

UNIXsplitコマンドについて

オプションに従ってファイルを分割するコマンドです。
split オプション 分割するファイルまでのパス 分割ファイル名の接頭辞
のように使います。
今回使っているオプションを解説していきます。

--lines

分割する行数を指定します。

--numeric-suffixes

分割後のファイル名を指定するオプションの一つです。
分割後のファイルにつく連番がいくつから始まるかを指定します。
今回のファイル名の一つであるq16-unix-01.txtのうち、01の部分が01始まりになるようにしているということですね。

--additional-suffix

分割後のファイル名を指定するオプションの一つです。
分割後のファイルにつく接尾辞を指定します。
拡張子をつけたい場合に使うことが多いと思います。
今回のファイル名の一つであるq16-unix-01.txtのうち、.txtの部分を指定しているということですね。

使用例: 分割対象ファイルq16-sample.txtの内容

1行目
2行目
3行目
4行目
5行目
6行目

実行するコマンドと結果

split --lines=3 --numeric-suffixes=1  --additional-suffix=.txt ./q16-sample.txt splited-q16-sample-
# 1行目~3行目までの"splited-q16-sample-01.txt"と、4行目~6行目までの"splited-q16-sample-02.txt"に分割される。

# ファイルの行数が分割する行数の倍数でない場合の例
split --lines=4 --numeric-suffixes=1  --additional-suffix=.txt ./q16-sample.txt splited-q16-sample-
# 1行目~4行目までの"splited-q16-sample-01.txt"と、5行目~6行目までの"splited-q16-sample-02.txt"に分割される。

参考: 【 split 】コマンド――ファイルを分割する:Linux基本コマンドTips(162) - @IT

UNIXコマンドの結果をPythonの変数に格納する

変数名 = !UNIXコマンド と宣言ことで、UNIXコマンドの結果をPythonの変数に格納しています。
ここではsplitコマンドで書き出したファイル名のリストを取得するためにlsコマンドを使っています。

使用例:

pwd_result = !pwd
!echo "カレントディレクトリ:" $pwd_result

Q11で説明したように、変数名の前に$をつけることでUNIXコマンドで使用するために呼び出すことができます。

Pythonsorted関数について

sorted(ソートしたいリスト)
のように書くことで、項目をソートしたリストを返す関数です。

使用例:

sample_list = [1, 2, 3, 4, 5, 1, 3, 1]

print(sorted(sample_list))

ここでは単純に連番のファイル名のリストを番号順に並べたいだけなので、特にオプションを設定していません。

参考: ソート HOW TO — Python 3.8.10 ドキュメント

おわりに

以上、言語処理100本ノックの第2章のQ14~Q16について解説しました。
typerは非常に便利なライブラリなので、覚えておくといつか役に立つと思います。

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

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

言語処理100本ノック第2章 解答と解説 Q12~Q13まで

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

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

解答・解説の前に

この記事の

  • 主な対象者と目的
  • 実行環境
  • 解答・解説を読む際の注意

は、以前の記事で説明していますので、解答・解説を読む前にこれらを知っておきたい、という方はそちらを参照していただくようお願いします。

また、長い解説は折りたたんであります。折りたたんである解説は「解説を読む」をクリックすると見ることができます。

解答・解説

Q12. 1列目をcol1.txtに,2列目をcol2.txtに保存

各行の1列目だけを抜き出したものをcol1.txtに,2列目だけを抜き出したものをcol2.txtとしてファイルに保存せよ.確認にはcutコマンドを用いよ.

Python

解答コード

(別解があるため、指定されたファイル名のあとに連番をつけています)

# 以降の解答でも使用
PATH_COL1_1 = "data/col1-1.txt"
PATH_COL2_1 = "data/col2-1.txt"

with open("popular-names.txt") as input_file:
    lines = input_file.readlines()
    
with open(PATH_COL1_1, mode="w") as col1, open(PATH_COL2_1, mode="w") as col2:
    for line in lines:
        split = line.split()
        col1.write(split[0] + "\n")
        col2.write(split[1] + "\n")
        
print("ファイルの書き出しが完了しました")

実行結果

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

解説

解説を読む

Pythonで複数のファイルを同時に開く方法

with open(ファイルまでのパス) as 変数名, open(ファイルまでのパス) as 変数名:
のようにすることで、一行で複数のファイルを同時に開くことができ、ネストが深くなるのを防ぐことができます。

使用例:

with open(PATH_COL1_1, mode="w") as col1, open(PATH_COL2_1, mode="w") as col2:
    for line in lines:
        split = line.split()
        col1.write(split[0] + "\n")
        col2.write(split[1] + "\n")

また、以下にこの問題をtry finallyを使って解いた別解も載せておきます。

PATH_COL1_2 = "data/col1-2.txt"
PATH_COL2_2 = "data/col2-2.txt"

input_file = None
col1 = None
col2 = None

# try finallyを使う例
try:
    input_file = open("popular-names.txt")
    
    col1 = open(PATH_COL1_2, mode="w")
    col2 = open(PATH_COL2_2, mode="w")
    
    for line in input_file:
        split = line.split()
        col1.write(split[0] + "\n")
        col2.write(split[1] + "\n")
        
finally:
    if input_file is not None:
        input_file.close()
    
    input_file = None
    
    if col1 is not None:
        col1.close()
        
    col1 = None
    
    if col2 is not None:
        col2.close()
    
    col2 = None
    
print("ファイルの書き出しが完了しました")

withだとネストが深くなるか、一行が長くなるかのどちらかの問題を抱えることになってしまうので、今回のように複数のファイルを同時に開きたい場合は、try finallyを使うほうがわかりやすいかもしれません。 このtry finallyでファイルを開く方法は、以前の記事で詳しく解説しています。

解答コードに戻る

UNIXコマンド

解答コード

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

!echo
!echo "diff <(cut -f 2 popular-names.txt)" $PATH_COL2_1
!diff <(cut -f 2 popular-names.txt) $PATH_COL2_1

実行結果

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

diff <(cut -f 2 popular-names.txt) data/col2-1.txt

解説

解説を読む

UNIXcutコマンドについて

cut -f 列番号 対象ファイル
のように指定することで、タブ区切りのファイルから指定した列を切り出すことができるコマンドです。 Pythonなどのプログラミング言語に慣れている方は、列番号で一列目を指定したいなら0、二列目なら1……のように指定するものだと思うかもしれませんが、cutコマンドでは一列目なら1、二列目なら2を指定する必要があることに注意しましょう。のちの問題で使うsortコマンドなど、他のUNIXコマンドも同じような仕様のものがあります。

使用例:

cut -f 2 popular-names.txt

参考: 【 cut 】コマンド――行から固定長またはフィールド単位で切り出す:Linux基本コマンドTips(60) - @IT

解答コードに戻る

Q13. col1.txtとcol2.txtをマージ

12で作ったcol1.txtとcol2.txtを結合し,元のファイルの1列目と2列目をタブ区切りで並べたテキストファイルを作成せよ.確認にはpasteコマンドを用いよ.

Python

解答コード

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

# Q12で定義した定数を使用
with open(PATH_COL1_1) as col1:
    lines_col1 = col1.readlines()
    
with open(PATH_COL2_1) as col2:
    lines_col2 = col2.readlines()

with open(PATH_Q13, mode="w") as q13:
    for line1, line2 in zip(lines_col1, lines_col2):
        # 1列目のファイルの改行をタブ文字に置換することで、タブ区切りとしている
        q13.write(line1.replace("\n", "\t") + line2)
        
print("ファイルの書き出しが完了しました")

実行結果

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

解説

コード中にもコメントしていますが、1列目のファイルの改行をタブ文字に置換することで、タブ区切りで並べたテキストファイルを出力するという工夫をしています。

UNIXコマンド

解答コード

!echo "Pythonの出力ファイルとUNIXコマンドで同様の処理をした結果を比較:"
!echo "diff <(paste" $PATH_COL1_1 $PATH_COL2_1")" $PATH_Q13_1
!diff <(paste $PATH_COL1_1 $PATH_COL2_1) $PATH_Q13_1

実行結果

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

解説

解説を読む

UNIXpasteコマンドについて

paste 結合したいファイル1 結合したいファイル2 のようにすることで、ファイル2つを行単位で結合した結果を出力することができるコマンドです。

使用例:

paste fileA.csv fileB.csv

行数の異なるファイルを結合した場合、以下のように一方だけにある行は片方だけのまま出力されます。

# 2行あるファイルと1行しかないファイルをpasteした例
paste paste_test.csv paste_test2.csv 
John    M 50000 2000    John    M 50000 2000
John    M 50000 2000

参考: 【 paste 】コマンド――複数のファイルを行単位で連結する:Linux基本コマンドTips(101) - @IT

解答コードに戻る

おわりに

以上、言語処理100本ノックの第2章のQ12とQ13について解説しました。
今回紹介したcutpasteは、データが保存されたテキストファイルを扱う際に覚えておくと役に立つと思います。

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

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

言語処理100本ノック第2章 解答と解説のまとめ

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

この記事では、言語処理100本ノック2020の「第2章 : UNIXコマンド」の10問をPythonUNIXコマンドで解き、複数回に分けて解説したものをまとめています。

各記事へのリンクと内容の概要

今回は読みやすさを考慮して、出題意図を推察しながら以下の図のように記事を分割しました。

1. データのダウンロードと整形

前編

(前文、Q10、Q11) blog.brains-consulting.tech

UNIXコマンドを使って課題ファイルをダウンロードし、Pythonでファイルを読み書きする方法や、Q11の問題文で言及されている3つのコマンドについて解説しています。

後編

(Q12、Q13)
blog.brains-consulting.tech

PythonUNIXコマンドを使って、データがタブ区切りで格納されている課題ファイルを分割したり結合したりする方法を解説しています。

2. データ内容の確認とデータの分割

(Q14、Q15、Q16)
blog.brains-consulting.tech

これらの問題ではコマンドライン引数を受け取るアプリケーションを作ることを要求されています。今回私はPythonのライブラリであるtyperを使って解いたので、それについて解説しています。
また、Q16で扱うUNIXsplitコマンドについては、オプションを詳しく解説しています。

3. 複数プロセスが必要なデータ処理

(Q17、Q18、Q19)
blog.brains-consulting.tech

Pythonでこれらの問題を解く際に外部ライブラリを使う方法、使わない方法の速度比較を含めたメリット・デメリットを解説しています。
UNIXコマンドでは、複数のコマンドをパイプでつなげてより複雑な処理を行う方法について解説しています。

Appendix. 厳格でないソートが行われる可能性についての検証

blog.brains-consulting.tech

Q17~Q19を解いていて見つけたUbuntusortコマンドと、Pandasのsort_values()の厳格でない挙動についての検証のAppendixとなります。

おわりに

以上、言語処理100本ノックの第2章について解説しました。
複数記事にわたってかなり長い記事を執筆することになりましたが、お役に立てたなら幸いです。

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

言語処理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について解説しました。
この記事で解説した技法はのちの問題でも多用するので、しっかりと覚えておくとスムーズに解けると思います。

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

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

言語処理100本ノック第1章 解答と解説

初めまして。今年度からブレインズコンサルティングに入社した福田直起です。

私は今、Pythonを使った言語処理を学ぶために、言語処理100本ノック2020に取り組んでいます。
そのアウトプットとして、これからこのテックブログに回答と解説を発表していきます。
今回は「第1章 : 準備運動」の10問を解説していきます。

この記事のレベル感

Pythonについてif文やfor文などの基本的な使い方なら知っている初心者が、記事を通して言語処理におけるPythonの強みがある程度理解できるようになる」
というレベル感で解説していきます。

実行環境

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

問題を解くための前提知識

文字列の抽出

pythonでは文字列をリストのように扱うことができます。
これを利用すると、以下のように文字列の一部をリストのインデックスのように指定して、抽出することができます。

str = "abcde"
# 最初の文字を抽出
print(str[0])
# 実行結果:"a"

# -(マイナス)を指定すると、文字列を末尾から参照できる。最後の文字が-1
print(str[-1])
# 実行結果:"e"

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

第1章の解答と解説

Q0. 文字列の逆順

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

解答コード

word = "stressed"
print(word[::-1])

解答コードの実行結果

desserts

解説

Pythonでは、リストに対するループ処理、すなわち文字列に対する連続した抽出処理を、
list[start:stop:step]

  • start: 開始位置
  • stop: 終了位置
  • step: 処理のステップ(何文字ごとに抽出するか)

のように書くことができます。

この問題では、stepに負の数である-1を指定することで、指定された文字列を末尾から順番に一文字ずつ抽出して並べています。

これを利用すれば、他にも以下のようなことができます。

str = "abcde"

# step部分は省略可(stepには1が自動的に指定される)
print(str[1:3])
# 実行結果:"bcd"

# startやstopも省略可
# startなら0、stopなら文字列の末尾が自動的に指定される
print(str[:3])
# 実行結果:"abc"

print(str[1:])
# 実行結果:"bcde"

# stepを指定して一文字飛ばしで文字列を抽出する例
print(str[1:4:2])
# 実行結果:"bd"

この書き方を使わずに、この問題をwhile文を使って解こうとすると、以下のようにかなり長いコードを書く必要があります。
言語処理にPythonを使う大きな強みと言える構文だと思います。

word = "stressed"

result = ""
count = len(word) - 1

while count >= 0:
    result += word[count]
    count -= 1
    
print(result)

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

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

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

解答コード

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

# 1,3,5,7文字目なので、1文字目から2文字ごとに取り出せばよい
print(word[::2])

解答コードの実行結果

パトカー

解説

指定されている文字の位置が2文字ごとであることから、一つ前の問題と同様、stepで1文字飛ばしを指定して必要な部分を抽出しています。

この問題をfor文を使って解いてみて、この構文の便利さを実感するのもいいかもしれません。

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

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

解答の前に

新しい用語が出てくるので、解答より前に解説しておきます。
marged_words = [char1 + char2 for char1, char2 in zip(word1, word2)]
解答でこのような式が出てきますが、この式の右辺のような書き方を「リスト内包表記」と言います。
詳しい使い方は、解答コードの後で解説します。

解答コード

word1 = "パトカー"
word2 = "タクシー"

# リスト内包表記を使って2つの単語を先頭から交互に結合する
marged_words = [char1 + char2 for char1, char2 in zip(word1, word2)]

# 結合したリストを出力用に文字列に変換する
result = "".join(marged_words)

print(result)

解答コードの実行結果

パタトクカシーー

解説

Python命名規則について

Pythonで変数名や関数名に複数の単語を使いたい場合、
marged_words
のようにアンダーバー区切りの小文字(スネークケースと呼ばれます)を使うのが一般的です。

zip()について

Pythonでは、
for item1, item2 in zip(list1, list2)
のように記述することで、一行のfor文で複数のリストを同時にループさせることができます。
もちろん、この問題の解答で実際に使用したように、文字列にも使えます。

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

リスト内包表記について

Pythonでは、リスト内包表記という書き方によって、一行でリストを定義することができます。
例として、以下のような場合に力を発揮します。

  1. この問題のように、zip()と併用して複数のリストを互い違いに結合する
  2. 単語のリストから、各単語の先頭の文字を抜き出す

リスト内包表記は、
list = [各項目に適用する式 for文]
のように書くことで使うことができます。

分かりやすいように、上の「2.単語のリストから、各単語の先頭の文字を抜き出す」をリスト内包表記を用いてコード化したものを、以下に例示しておきます。

コード
words = ["When", "Where", "Who", "What", "Why", "How"]

first_chars = [word[0] for word in words]

print(first_chars)
実行結果
['W', 'W', 'W', 'W', 'W', 'H']

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

join()について

Pythonでは、
"".join(list)
のように記述することで、リストを文字列として結合することができます。

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

リスト内包表記を使わない別解

リスト内包表記の便利さを実感してもらうために、以下にリスト内包表記を使わない別解を提示しておきます。

word1 = "パトカー"
word2 = "タクシー"

marged_words = []

for char1, char2 in zip(word1, word2):
    marged_words.append(char1 + char2)

result_str = "".join(marged_words)

print("リスト内包表記を使わない方法の結果:")
print(result_str)

一行で書く別解

今回は分かりやすさを重視して避けましたが、この問題はほぼ一行で解答することもできます。

word1 = "パトカー"
word2 = "タクシー"

print("リスト内包表記を使って一行で書く方法の結果:")
print("".join([char1 + char2 for char1, char2 in zip(word1, word2)]))

Q3. 円周率

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

解答コード

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

# 文字数カウントにはカンマとピリオドが不要なので取り除く
sentence = sentence.replace(",", "").replace(".", "")

# 文を単語に分割する
splits = sentence.split()

ans = [len(split) for split in splits]
print(ans)

解答コードの実行結果

[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9]

解説

replace()について

Pythonでは、
str.replace("置換元の文字列", "置換後の文字列")
のように記述することで、変数str内の文字列を置換することができます。
今回は不要な文字を空文字に置換することで削除しています。

また、この構文は返り値が置換処理した後の文字列なので、解答コードのように
replace().replace()
とメソッドを一行に連続で書くことで、一行で複数の置換処理をさせることもできます。

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

split()について

Pythonでは、
list = str.split("区切り文字")
のように記述することで、変数str文字列を区切り文字で区切ったリストに変換することができます。
区切り文字を指定しない場合、スペースなどの空白文字で区切られるため、今回はそれを利用しています。

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

正規表現を用いる別解

今回はreplace()を使いましたが、正規表現を用いた別解を残しておきます。
正規表現については、第2章で詳しく扱う予定です。

import re

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

# 文字数カウントにはカンマとピリオドが不要なので取り除く
sentence = re.sub("[,.]", "", sentence)

# 文を単語に分割する
splits = sentence.split()

ans = [len(split) for split in splits]
print(ans)

Q4. 元素記号

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

解答コード

sentence = "Hi He Lied Because Boron Could Not Oxidize Fluorine. New Nations Might Also Sign Peace Security Clause. Arthur King Can."

# 文を単語に分割する
splits = sentence.split()

result = {}
special_numbers = [1, 5, 6, 7, 8, 9, 15, 16, 19]

# iの初期値を1に設定する
for i, split in enumerate(splits, 1):
    
    # 問題文で指定された番号の単語の際は先頭1文字
    if i in special_numbers:
        result[split[0]] = i
        
    # 問題文で指定されていない番号の単語の際は先頭2文字
    else:
        result[split[:2]] = i

print(result)

解答コードの実行結果

{'H': 1, 'He': 2, 'Li': 3, 'Be': 4, 'B': 5, 'C': 6, 'N': 7, 'O': 8, 'F': 9, 'Ne': 10, 'Na': 11, 'Mi': 12, 'Al': 13, 'Si': 14, 'P': 15, 'S': 16, 'Cl': 17, 'Ar': 18, 'K': 19, 'Ca': 20}

解説

enumerate()について

Pythonfor文のインデックス(現在位置)が必要な場合、
for インデックスを入れる変数名, リストの各アイテムを入れる変数名 in enumerate(list, インデックスの初期値):
のようにすればインデックスをループ内で使うことができます。
解答では、インデックスの初期値を1にしていますが、インデックスの初期値は省略可能で、その場合の初期値は0になります。

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

Q5. n-gram

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

n-gramとは?

解答を提示する前に、n-gramという新しい用語が出てきたので、それを解説します。

任意の文字列や文書を連続したn個の文字で分割するテキスト分割方法.特に,nが1の場合をユニグラム(uni-gram),2の場合をバイグラム(bi-gram),3の場合をトライグラム(tri-gram)と呼ぶ.

引用元:コトバンク

定義だけだとわかりにくいので、
I work in BCI
という文章をN-gramにして例示します。

uni-gram(N=1)

文字:
[('I',), (' ',), ('w',), ('o',), ('r',), ('k',), (' ',), ('i',), ('n',), (' ',), ('B',), ('C',), ('I',)]

単語:
[('I',), ('work',), ('in',), ('BCI',)]

bi-gram(N=2)

文字:
[('I', ' '), (' ', 'w'), ('w', 'o'), ('o', 'r'), ('r', 'k'), ('k', ' '), (' ', 'i'), ('i', 'n'), ('n', ' '), (' ', 'B'), ('B', 'C'), ('C', 'I')]

単語:
[('I', 'work'), ('work', 'in'), ('in', 'BCI')]

tri-gram(N=3)

文字:
[('I', ' ', 'w'), (' ', 'w', 'o'), ('w', 'o', 'r'), ('o', 'r', 'k'), ('r', 'k', ' '), ('k', ' ', 'i'), (' ', 'i', 'n'), ('i', 'n', ' '), ('n', ' ', 'B'), (' ', 'B', 'C'), ('B', 'C', 'I')]

単語:
[('I', 'work', 'in'), ('work', 'in', 'BCI')]

N-gramについて理解していただけたでしょうか。
それでは、解答と解説に入ります。

解答コード

# 以降の問題でも使用する
def n_gram(target: list, n: int) -> list:
    result = []
    
    # 最後の文字や単語の(1-n)つ前までループを回す
    for i, item in enumerate(target[:len(target) + 1 - n]):
        # n-gramの一部として、連続したn個の文字のリストを作成する
        list = target[i : i + n]
        
        # 結果表示の際に見やすいのでタプルに変換する
        result.append(tuple(list))
    
    return result

target = "I am an NLPer"

print("文字bi-gram:")
print(n_gram(target, 2))
print()

# 単語ごとのリストに分割
words = target.split()

print("単語bi-gram:")
print(n_gram(words, 2))

解答コードの実行結果

文字bi-gram:
[('I', ' '), (' ', 'a'), ('a', 'm'), ('m', ' '), (' ', 'a'), ('a', 'n'), ('n', ' '), (' ', 'N'), ('N', 'L'), ('L', 'P'), ('P', 'e'), ('e', 'r')]

単語bi-gram:
[('I', 'am'), ('am', 'an'), ('an', 'NLPer')]

解説

Q1で解説したとおり、文字列をリストとして扱えるというPythonの特徴と、list[start:stop]を使うことで、「最後の文字や単語の(1-n)つ前までループを回す」という処理を簡潔に書くことができています。

while文を使った別解

この問題で指定されている関数はwhile文を使って定義することもできます。

def n_gram(target: str, n: int) -> list:
    result = []
    count = 0
    
    while count + n - 1 < len(target):
        # n-gramの一部として、連続したn個の文字のリストを作成する
        list = target[count : count + n]
        
        # 結果表示の際に見やすいのでタプルに変換する
        result.append(tuple(list))
        
        count += 1
    
    return result

Q6. 集合

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

解答コード

target1 = "paraparaparadise"
target2 = "paragraph"

# Q5で作った関数を使用する
X = set(n_gram(target1, 2))
Y = set(n_gram(target2, 2))

print("X:")
print(X)
print()

print("Y:")
print(Y)
print()


print("XとYの和集合:")
print(X | Y)
print()

print("XとYの積集合:")
intersection = X & Y
print(intersection)
print()

print("XとYの差集合:")
print(X - Y)
print()

print("XおよびYに’se’というbi-gramが含まれるか:")
print("se" in intersection)

解答コードの実行結果

X:
{('r', 'a'), ('d', 'i'), ('p', 'a'), ('s', 'e'), ('a', 'p'), ('a', 'r'), ('a', 'd'), ('i', 's')}

Y:
{('r', 'a'), ('a', 'g'), ('g', 'r'), ('p', 'a'), ('p', 'h'), ('a', 'p'), ('a', 'r')}

XとYの和集合:
{('r', 'a'), ('a', 'g'), ('g', 'r'), ('d', 'i'), ('p', 'a'), ('s', 'e'), ('p', 'h'), ('a', 'p'), ('a', 'r'), ('a', 'd'), ('i', 's')}

XとYの積集合:
{('a', 'p'), ('r', 'a'), ('a', 'r'), ('p', 'a')}

XとYの差集合:
{('d', 'i'), ('a', 'd'), ('s', 'e'), ('i', 's')}

XおよびYに’se’というbi-gramが含まれるか:
False

解説

問題文の「XおよびY」という言葉が少し曖昧で、「XとYの積集合」と「XとYの和集合」のどちらともとれるように感じました。
ここでは、「XとYの和集合」なら「XおよびY」ではなく「XまたはY」や「XかY」と書くだろうという理由から、「XとYの積集合」と解釈して解いています。

Pythonsetについて

Pythonではsetを使うことで、集合を定義することができます。
setは順番を保持しませんが、同じ項目を重複して持たないので、listより便利な場合があります。
example_set = set(example_list)
とすることで、リストを集合に変換できます。

またPythonでは、解説コードのように和集合や積集合なども簡単に求めることができます。

この問題では使っていませんが、集合を宣言する場合は
empty_set = set()
のように宣言する必要があります。
リストを
empty_list = []
と宣言するように
empty_set = {}
と宣言してしまうと、集合ではなく辞書型の宣言になってしまうので注意が必要です。

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

Pythonで変数などを宣言する際の注意

Pythonで変数やsetlistを宣言する際に、気をつけたいことがあります。
例があったほうがわかりやすいので、以下にサンプルコードを提示します。

list = []
set = list(list)
# 以下のようなエラーが出る
# TypeError: 'list' object is not callable

PythonJavaなどに比べると、予約語(そのままで変数名にされてしまうと、プログラムがうまく処理できなくなるので、エラーになる語)の範囲が狭めに設定されており、setlistといった語をそのまま変数名にすることができてしまいます。
しかし、これらはset()list()といった組み込みメソッドで使う語であり、これらの語をそのまま変数名にしてしまうと、組み込みメソッドの方が使えなくなってしまいます。
エラー内容からミスした内容ががわかりにくいタイプのエラーの出方をするので、気をつけてください。

Pythonの組み込みメソッド(組み込み関数)について知りたい方は、以下の公式ドキュメントの該当ページを参考にしてください。
組み込み関数 — Python 3.8.10 ドキュメント

また、予約語について詳しく知りたい方は、以下のサイトを参考にしてください。
Javaを例に出したので、そちらの予約語も載せておきます。
Pythonのキーワード(予約語)一覧を確認するkeyword | note.nkmk.me
Java | Javaの予約語

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

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

解答コード

def time_syntax(x: str, y: str, z: str) -> str:
    return f'{x}時の{y}は{z}'

print(time_syntax(12, "気温", 22.4))

解答コードの実行結果

12時の気温は22.4

解説

Pythonf文字列について

簡素な記法で変数入りの文字列をフォーマットできるため、この問題のように変数混じりの文字列を使いたい場合に便利です。
以下のように、変数名を{}でくくって使います。
f'私の名前は{name}です。'

比較的最近(Python3.6以降)でのみ使用可能なので、古い環境では使えないことには注意が必要です。

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

Q8. 暗号文

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

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

文字コードとは

文字コードという単語が初めて出てきたので、解説しておきます。

コンピュータ上では、文字はデジタル符号で表される。この符号を文字コードという。キャラクターコードともいう。アルファベットのように字種の数が少ない場合は符号として必要なビット数が少なくてすみ、1バイト(8ビット)が1文字に割り当てられているが、字種の多い漢字を使う日本語などでは1文字に数バイト必要である。 引用元:コトバンク

つまり文字コードは数値なので、この問題では文字列の暗号化のために、文字を一旦文字コード化して引き算しようとしているわけですね。

文字コードは複数の種類がありますが、この問題ではPythonで扱いやすいUnicodeが入力されることを想定して解答します。

解答コード

def cipher(target: str) -> list:
    encrypted_list = []
    
    # for文で処理するために入力文字列をリスト化
    target_list = list(target)
    
    for target in target_list:
        
        # 英小文字ならば(219 - 文字コード)の文字に置換
        if target.islower():
            encrypted_list.append(chr(219 - ord(target)))
        
        # その他の文字はそのまま出力
        else:
            encrypted_list.append(target)
    
    return "".join(encrypted_list)

target1 = "para2para1para3dise"
print("入力文:")
print(target1)

encrypted_target1 = cipher(target1)
print()
print("暗号化した文:")
print(encrypted_target1)

print()
print("復号化した文:")
print(cipher(encrypted_target1))

解答コードの実行結果

入力文:
para2para1para3dise

暗号化した文:
kziz2kziz1kziz3wrhv

復号化した文:
para2para1para3dise

解説

Pythonにおける大文字・小文字の判定

Pythonには、以下のように標準で大文字・小文字を判定するメソッドがあります。

# 大文字かを判定
str1 = "A"
print(str1.isupper())
# 実行結果:"True"

# 小文字かを判定
str2 = "a"
print(str2.islower())
# 実行結果:"True"

Pythonでの文字コードの使い方

Pythonでは、
ord("文字コードに変換したい文字")
とすることで、文字をUnicode文字コードに変換できます。

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

逆に、Unicode文字コードを文字列に戻したい場合は
chr(文字コード)
を使います。

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

Q9. Typoglycemia

スペースで区切られた単語列に対して,各単語の先頭と末尾の文字は残し,それ以外の文字の順序をランダムに並び替えるプログラムを作成せよ.ただし,長さが4以下の単語は並び替えないこととする.

適当な英語の文(例えば”I couldn’t believe that I could actually understand what I was reading : the phenomenal power of the human mind .”)を与え,その実行結果を確認せよ.

解答コード

import random

# 結果が毎回同じになるように、乱数シードを固定する
random.seed(314) 

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

# 文章を単語に分割する
splits = target.split()
result_list = []

for split in splits:
    # 長さが4を超える単語のみ順序をランダムに並び替える
    if len(split) >= 5:
        
        # 並び替え対象の部分をリスト化
        randomize_target_split = split[1:-1]
        
        # ランダムに並び替える
        randomized_split = random.sample(randomize_target_split, len(randomize_target_split))
        
        # 先頭や末尾と結合する
        result_list.append(split[0] + "".join(randomized_split) + split[-1])

    else:
        result_list.append(split)

result = " ".join(result_list)
print(result)

解答コードの実行結果

I cudonl’t bleevie that I colud actlaluy urdetsnnad what I was rineadg : the pamnneohel pewor of the hamun mind .

解説

Pythonrandomライブラリの使い方

Pythonでランダムな処理を行いたい場合に使うライブラリです。 以下に、解答コードで使用したものを中心に、コードを例示しながらrandomライブラリのメソッドについて解説します。

import random

# 結果が毎回同じになるように、乱数シードを固定するrandom.seed()
random.seed(314) 

example_list = [1, 2, 3, 4, 5, 6, 7]

# 重複なしでリストから要素を取り出すrandom.sample()
print(random.sample(example_list, len(example_list)))
# 結果:[2, 4, 1, 7, 5, 6, 3]

# 重複ありでリストから要素を取り出すrandom.choices()
print(random.choices(example_list, k=len(example_list)))
# 結果:[3, 3, 7, 1, 5, 1, 6]
# random.sample()と違い、重複ありなので、同じ要素が複数回取り出される

コードの再現性を担保するために、random.seed()で結果を固定しています。

参考: random --- 擬似乱数を生成する — Python 3.8.10 ドキュメント

join()で区切り文字を入れる

Q2でも解説したjoin()ですが、区切り文字を入れてリストを文字列として結合することもできます。
"区切り文字".join(list)

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

Google Colaboratoryによる解答の公開

Google Colaboratoryを利用してJupyter Notebookを共有する形式でも解答を公開しています。 https://colab.research.google.com/drive/1QbWjJJgoeL53udr3gKtOV0_NZsXDR9cb?usp=sharing

おわりに

以上、言語処理100本ノックの第1章について解説しました。
私が本格的にPythonに取り組んだのは今回の言語処理100本ノックが初めてだったのですが、Pythonで言語処理をする際に便利な記法を多数学ぶことができたと感じています。 この章で解説した技法はのちの章でも多用するので、しっかりと覚えておくとスムーズに解けると思います。