Open In Colab

8. ファイル・文字列操作#

この章では、テキストファイルやcsvファイル(excelファイルはおまけ\(\clubsuit\))をPythonで操作する簡単な方法を学習する。

これまでの章では、データはリストとして既に与えられた状態から解析を行ったが、実際にデータを扱う際には、既に誰かが作成した何らかのファイルをプログラムで読み込んで操作する場合も多い。この章の内容は、データの解析などというよりは、Pythonでデータ解析をするための下準備に相当する。

愚直にコードを書いている事もあり少々泥臭い部分が多いが、この章のような操作のエッセンスを押さえておけば、普通にやると膨大な時間がかかる様々な処理を高速化・自動化することができるので、頑張って学習しよう。

これまでの章で学んだfor文や自作関数、if文などを駆使する格好の練習問題でもある。

8.1. 授業で使うファイルの準備#

予め共有しておいた以下のファイルを用いる。

それぞれのファイルをダウンロードし、ご自身のGoogle Driveに指定されたディレクトリを作成し、ファイルをアップロードしてもよいが、少し時間がかかるので、Google Driveのマウントと、Linuxコマンドを駆使してファイルをダウンロードする方法を以下に示す。

本章では、ファイルの場所を指定するパスという概念がたびたび登場する。 末尾にパスの説明があるので、以下のコードが実行できたら、パスの説明を確認しよう。

  1. まずはGoogle Driveのマウントを行う:
    以降の多くのコードは、google driveの中にあるファイルを読み書きしたりする作業を伴うため、 Google Driveをマウントした状態でなければ、実行しても多くがエラーとなることに注意しよう。

from google.colab import drive
drive.mount('/content/drive')
  1. 以下のコードを一度だけ実行する:

!git clone https://github.com/SotaYoshida/Lecture_DataScience
!mkdir /content/drive/MyDrive/Pro1AdDS/
!mv Lecture_DataScience/Chapter8_data /content/drive/MyDrive/Pro1AdDS/chapter8_data
!ls /content/drive/MyDrive/Pro1AdDS/chapter8_data

\(\clubsuit\) 解説(気になる人むけ):

  • 1つめの行ではまず授業資料のGitHubレポジトリをColab環境で間借りしているgoogleのサーバー上にクローン(≒コピー)する。

  • 2行目(mkdirコマンド)でマイドライブの下にPro1AdDSというフォルダの作成を試み

  • 3行目(mvコマンド)でダウンロードしてきたレポジトリにあるChapter8_dataをさっき作ったPro1AdDSというフォルダの中に別名(先頭が小文字になっている)で移動する。

  • 最後に、どんなファイルがあるかをlsコマンドで確認している。

8.2. テキストファイルの操作#

膨大な行・数のテキストファイルに対して、人間が手で操作をするというのは非効率だし、時として非現実的となる。

誤変換を置換するくらいなら、どのテキスト/メモ帳アプリやwordでもできるが、全行(数千とか数万)に対して、決まった操作が必要な場合や複数ファイルについて同じ操作が必要になるときは、プログラムを書くと系統的な操作が可能となる。

ひとくちにテキストファイルといっても様々な形式があり、表記方法もバラバラである。 プログラムで扱う際には、そのファイルの特性に合わせて適切な処理を行う必要があることに注意しつつ学習を進めよう。

まずは data_dir の中身を表示して、python_handling_test.csv があるか確認してみる。

from pathlib import Path

# 8.1節で準備したフォルダと、書き出し先を指定する
data_dir = Path("/content/drive/MyDrive/Pro1AdDS/chapter8_data")
output_dir = Path("/content/drive/MyDrive/Pro1AdDS/outputs/chapter8")
output_dir.mkdir(parents=True, exist_ok=True)

for path in sorted(data_dir.glob("*")):
    print(path.name)

*はワイルドカード記号で、対象を任意とする命令に相当し、*.拡張子 などとして使うと、拡張子が特定のもののファイルをすべて対象とすることができる。

glob は、一致するファイルがなければ何も返さない。何も表示されないときは、print(data_dir) で場所を確認しよう。

for path in sorted(data_dir.glob("*.csv")):
    print(path.name)
python_handling_test.csv

とするとdata_dirで指定したフォルダ内にある.csv形式のファイル一覧を表示させることができる。

8.2.1. csvファイルの説明#

こちらのpython_handling_test.csvというファイルはGoogleフォームで作成したアンケートで、国数英社理(中学の5科目)に対する得意/苦手意識の調査を想定した疑似アンケートになっている。このようなアンケート調査は事務作業や卒業研究などで頻繁に見られ、何らかの意思決定に用いられることも多い。こうしたアンケート分析を行っていると、

  • 各回答項目同士の関係が知りたい

  • 明示的な項目以外の情報も抽出したい

といった要望が出てくる。今の場合でいうと、

  • 各科目ごとの得意・苦手意識の相関を調べたい

  • 夜中(あるいは日中)にアンケートを回答した夜型(昼型)の人に見られる特徴がなにかないか?

といったイメージである。そんなとき、

国語が得意(どちらかというと得意)と回答した方に質問です。
英語についてはどうでしょうか?

などと新たに設問を増やしてアンケートをやり直すというのは得策ではない。そこで、既に得られた情報からさらなる情報を引き出すことを考えたい。こんなとき、csvファイルに記載された情報を整理してプログラムで扱いやすくすることを考えてみよう。

余談1: このCSVはUTF-8で保存されている。Excelなどで開いて文字化けする場合は、読み込み時に想定された文字コードが違う可能性がある。『データの取り込み』などでUTF-8を指定してみよう。

余談2: 2000件の回答は、もちろん手作業で入力したわけでも誰かに協力してもらったわけでもなく、一定のルール(傾向)を勝手に設定した上でランダムに回答を作成しフォームから自動回答するPythonスクリプトを書いて生成したものである。こうした疑似データの自動生成も、データ分析の練習をする上で役に立つことがあるので、興味がある人は授業後にでも調べてみよう。

なお、一度の回答時刻だけで、その人が夜型か昼型かを決めることはできない。ここでは時刻を取り出す練習として考えよう。

8.2.2. テキストファイル(csv)を開いて内容を読み出す#

では、このpython_handling_test.csvファイルに書かれているテキストを取得してみよう。
方法は幾つかあるが、最も標準的なものとして、組み込み関数のopenでファイルを開いてテキストを取得する方法を試してみよう。

filename = data_dir / "python_handling_test.csv"
with open(filename, "r", encoding="utf-8") as inp:
    lines = inp.readlines()
  • 1行目で、読むファイルのパスを filename に代入する。

  • open"r" は読み込みモード、encoding="utf-8" は文字コードの指定である。

  • readlines() は全行を読み込み、各行を文字列とするリストを返す。大きなファイルでは、それに応じたメモリが必要になる。

  • with のブロックを抜けると、途中でエラーが起きてもファイルが閉じられる。

書き込みには "w"(上書き)や "a"(追記)などを使う。後で試してみよう。

print("読み込んだ行数:", len(lines))
print(lines[:5])
読み込んだ行数: 2001
['"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"\n', '"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","-1 どちらかというと苦手"\n', '"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","+0 どちらでもない"\n', '"2020/09/09 12:59:29 午後 GMT+9","女","+1 どちらかというと得意","-2 苦手","+1 どちらかというと得意","+0 どちらでもない","-2 苦手"\n', '"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どちらかというと苦手","+0 どちらでもない","-1 どちらかというと苦手"\n']

とすると、読み込んだ行数と、変数 lines の先頭5行を確認できる。ここで\nは改行記号を意味する。

ループを回して一行ずつ表示させてみる。少し行数が多いので、最初の数行だけ表示させてみる。

for i, line in enumerate(lines):
    if i == 5:
        break
    print(line)
"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"

"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","-1 どちらかというと苦手"

"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","+0 どちらでもない"

"2020/09/09 12:59:29 午後 GMT+9","女","+1 どちらかというと得意","-2 苦手","+1 どちらかというと得意","+0 どちらでもない","-2 苦手"

"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どちらかというと苦手","+0 どちらでもない","-1 どちらかというと苦手"

といった感じ。

行ごとにスペースが生じている理由については、ファイルの各行の末尾に改行が含まれているためである。 print関数はデフォルトで末尾に改行\nを挿入するのでファイルにある改行記号とあわせて2回改行してしまう。
参考リンク

必要な行番号が分かっている場合は、スライスなどを用いて必要な行だけを取り出すこともできる。

nlines = lines[:5]

nlines
['"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"\n',
 '"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","-1 どちらかというと苦手"\n',
 '"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","+0 どちらでもない"\n',
 '"2020/09/09 12:59:29 午後 GMT+9","女","+1 どちらかというと得意","-2 苦手","+1 どちらかというと得意","+0 どちらでもない","-2 苦手"\n',
 '"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どちらかというと苦手","+0 どちらでもない","-1 どちらかというと苦手"\n']

8.2.3. stripメソッド#

strip() を使うと、文字列の両端にある空白、タブ、改行などを除いた新しい文字列が得られる。文字列の途中にある空白は消えない。

a = "test character\t"
b = "test2 \n"
print("a", a, "←タブが隠れている")
print("b", b, "←改行される")
### strip関数をもちいて...
print("a.strip()", a.strip(), "b.strip()", b.strip())
a test character	 ←タブが隠れている
b test2 
 ←改行される
a.strip() test character b.strip() test2

表示が冗長にならないよう、上で作っておいた5行だけとった文字列を使いつつ、stripメソッドを適用してやると...

for i, line in enumerate(nlines):
    print(line.strip())
"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"
"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","-1 どちらかというと苦手"
"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","+0 どちらでもない"
"2020/09/09 12:59:29 午後 GMT+9","女","+1 どちらかというと得意","-2 苦手","+1 どちらかというと得意","+0 どちらでもない","-2 苦手"
"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どちらかというと苦手","+0 どちらでもない","-1 どちらかというと苦手"

文字列の右側に空白や改行コードが入っていることが明確な場合は、stripの代わりにrstrip(rstripのrはrightの意味)を使うのもいいだろう。

for line in nlines:
    print(line.rstrip())
"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"
"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","-1 どちらかというと苦手"
"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どちらかというと苦手","+2 得意","+2 得意","+0 どちらでもない"
"2020/09/09 12:59:29 午後 GMT+9","女","+1 どちらかというと得意","-2 苦手","+1 どちらかというと得意","+0 どちらでもない","-2 苦手"
"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どちらかというと苦手","+0 どちらでもない","-1 どちらかというと苦手"

ファイルによってはインデントをするために左側にタブ\tが含まれる場合もある(PythonのコードをテキストとしてPythonから読むときなどがこれに該当)。そのような場合に左側にある空白やタブのみを取り除きたければlstrip()を使って取り除くことができる。

もちろんPythonではインデントが文法なので、インデントを一律で消す、といった操作は必要ないが、特定の状況では、lstripも使えると便利な場合がある。 例えばアンケートフォームなどで、誰かが入力した文字列を処理する際などは、その人が入力の前後に空白を入れてしまうこともあるので、stripを使って空白を消してから処理するのが無難である。

8.2.4. splitメソッド#

また、1,2,3,4,5,6といったコンマやスペースで区切られたものをリストに格納したい場合には、split関数が便利になる。split関数は引数に何も指定しなければ、スペースや改行もしくはタブごとに文字列を区切ったリストを返す。

sample_text = "This is a\nsample\ttext."
sample_text.split()
['This', 'is', 'a', 'sample', 'text.']

その他にもカンマで分割する、とか、特定の記号で分割するような操作もできる。

sample_text = "1, 2, 3, 4, 5"
sample_text.split(",")
['1', ' 2', ' 3', ' 4', ' 5']

さきほどのcsvファイルで試してみよう。何も指定しないと、区切りが悪いので

for line in nlines:
    print(line.split())
['"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"']
['"2020/09/09', '12:59:25', '午後', 'GMT+9","女","+2', '得意","-1', 'どちらかというと苦手","+2', '得意","+2', '得意","-1', 'どちらかというと苦手"']
['"2020/09/09', '12:59:27', '午後', 'GMT+9","男","+2', '得意","-1', 'どちらかというと苦手","+2', '得意","+2', '得意","+0', 'どちらでもない"']
['"2020/09/09', '12:59:29', '午後', 'GMT+9","女","+1', 'どちらかというと得意","-2', '苦手","+1', 'どちらかというと得意","+0', 'どちらでもない","-2', '苦手"']
['"2020/09/09', '12:59:31', '午後', 'GMT+9","男","+0', 'どちらでもない","-2', '苦手","-1', 'どちらかというと苦手","+0', 'どちらでもない","-1', 'どちらかというと苦手"']

カンマで区切ると項目ごとに近い形で分かれる。ただし、このCSVには引用符も含まれている。カンマで分けるだけでは一般のCSVを正しく読めないので、文字列操作の練習と、CSVとしての読み込みは区別しよう。

for line in nlines:
    print(line.split(","))
['"タイムスタンプ"', '"性別"', '"国語が"', '"数学が"', '"英語が"', '"社会が"', '"理科が"\n']
['"2020/09/09 12:59:25 午後 GMT+9"', '"女"', '"+2 得意"', '"-1 どちらかというと苦手"', '"+2 得意"', '"+2 得意"', '"-1 どちらかというと苦手"\n']
['"2020/09/09 12:59:27 午後 GMT+9"', '"男"', '"+2 得意"', '"-1 どちらかというと苦手"', '"+2 得意"', '"+2 得意"', '"+0 どちらでもない"\n']
['"2020/09/09 12:59:29 午後 GMT+9"', '"女"', '"+1 どちらかというと得意"', '"-2 苦手"', '"+1 どちらかというと得意"', '"+0 どちらでもない"', '"-2 苦手"\n']
['"2020/09/09 12:59:31 午後 GMT+9"', '"男"', '"+0 どちらでもない"', '"-2 苦手"', '"-1 どちらかというと苦手"', '"+0 どちらでもない"', '"-1 どちらかというと苦手"\n']

8.2.5. replaceメソッド#

replace関数で文字の置換が可能:

text = "abcdあいうえお"
text = text.replace("abcd", "1234")
print("置換や→", text)
print("除去にも→", text.replace("4", ""))
置換や→ 1234あいうえお
除去にも→ 123あいうえお

8.2.6. \(\clubsuit\) map関数#

ファイルから読み込んだデータは文字列として読み込まれるため、数値を扱う場合は、int関数などを使って整数型に変換する必要が生じる。

Pythonには、mapという組み込み関数があり、map(操作,対象)という風に使って、対象の各要素に対して一括で操作を適用することができる。

['1', ' 2', ' 3', ' 4', ' 5', ' 6']などの文字列のリストに対して、
整数型に変換するint関数を作用させるという操作を一度に行うことができる。

注: map関数の返り値はmap objectというものであり、単純にprintしても中身が見れない。 元のようなリストの形で使いたい場合はlist()を使ってリストに変換するステップが必要になる。

tmp = ["1", " 2", " 3", " 4", " 5", " 6"]
print("map=>", map(int, tmp), "list化してやると...", list(map(int, tmp)))
map=> <map object at 0x106d7ecb0> list化してやると... [1, 2, 3, 4, 5, 6]

もちろん、リスト内包表記を使っても同様のことができる。既に用意された機能を使うのはもちろんいいことだが、たまにはこうして愚直な操作をしてみるのも、Pythonの基本的な操作を理解する上で役に立つこともある。

tmp = ["1", " 2", " 3", " 4", " 5", " 6"]
print("リスト内包表記=>", [int(x) for x in tmp])
リスト内包表記=> [1, 2, 3, 4, 5, 6]

世の中には、アンケート結果や産業データがCSVで公開されている場合が多い。必要な値をリストなどに取り出せれば、今まで行った解析やグラフ描画に進める。

ただし、CSVでは引用符に囲まれた項目の中にカンマや改行が入ることもある。split(",") や引用符の一律削除では壊してしまうので、実際の読み込みには csv モジュールや pandas を使おう。読み込んだ各項目を整える場面では、上の文字列操作が役に立つ。

8.2.7. テキストファイルの書き出し#

次に、テキストファイルを書き込んで保存してみよう。

csvファイルの先頭5行を、上で紹介したreplaceなどを適用してみて、書き出すとする。

filename = output_dir / "write_practice.csv"
with open(filename, "w", encoding="utf-8") as oup:
    for line in nlines:
        line = line.rstrip("\r\n")  # 行末の改行だけを除去
        line = line.replace("どちらか", "どっちか")
        print(line, file=oup)
print(filename)

# 保存したファイルを読み直して、内容を確認する
with open(filename, "r", encoding="utf-8") as inp:
    print(inp.read())
/Users/sotair/lec_uu_drive/lecture_UtsunomiyaUniv/Lecture_DataScience_Polished/outputs/chapter8/write_practice.csv
"タイムスタンプ","性別","国語が","数学が","英語が","社会が","理科が"
"2020/09/09 12:59:25 午後 GMT+9","女","+2 得意","-1 どっちかというと苦手","+2 得意","+2 得意","-1 どっちかというと苦手"
"2020/09/09 12:59:27 午後 GMT+9","男","+2 得意","-1 どっちかというと苦手","+2 得意","+2 得意","+0 どちらでもない"
"2020/09/09 12:59:29 午後 GMT+9","女","+1 どっちかというと得意","-2 苦手","+1 どっちかというと得意","+0 どちらでもない","-2 苦手"
"2020/09/09 12:59:31 午後 GMT+9","男","+0 どちらでもない","-2 苦手","-1 どっちかというと苦手","+0 どちらでもない","-1 どっちかというと苦手"

上の出力で、保存したファイルの内容をチェックしてみよう。

なお、filenameに元ファイルと同じものを指定するとopen(filename,"w")を実行した時点でファイルが上書きされて空ファイルになるので注意。

今の例ではもちろん、手で置き換えたりするほうが遥かに速いが、こうしたPythonによるファイル操作を覚えておくと

  • ファイル自体が大量にあり、同じ操作を繰り返す場合

  • 単一のテキストファイルに大量の行に渡って情報がある場合

など、手作業が非現実的な様々な状況でも、楽ちんに作業を終わらせることができる(かもしれない)。

一度、プログラミングを用いたファイル操作をする発想を持つと、もうそれなしでは戻れない...かもしれない。

8.2.8. 文字コードに関連した余談#

Windows環境で作成されたテキストファイルを扱う際は読み込みで、文字コードによるエラーが出るかもしれない。 文字コードとは、英数字のみならず、日本語や中国語などの文字をコンピュータで扱うための規則のことで、こうした文字・記号などとコンピュータ内部での数値(バイト列)との対応を定めたものである。 最も一般的なのはUTF-8と呼ばれるものであるが、Windows環境ではShift-JISという文字コードが使われることもある。 これが原因で、テキストファイルを読み込む際に文字化けが生じることがある。

最近ではメモ帳でもUTF-8(世界標準)を採用しているよう(→MicrosoftのWindows blogの記事)だが、古いテキストファイルだとShift-JISになっているかも。そういうときは、open(file, "r", encoding = "shift_jis")など、ファイルを開くときにencodingを明示的に指定する必要がある。明示的にUTF-8で保存したいときはopen(file, "w", encoding = "utf-8")などとする。
参考: 公式ドキュメント
ここまで勉強してきた皆さんには「そんなの、パソコンに存在するShift-JISで書かれたテキストファイルを全てUTF-8に変換するPythonスクリプト書けばいいんじゃね?」という発想があることを期待している。ただし、一括変換する前に元ファイルを残し、読み込み時の文字コードと変換結果を確かめよう。

8.3. csvについての余談#

csvファイルは、カンマ区切りのテキストファイルであり、表形式のデータを保存するのに適している。

ちなみに...pandasライブラリを使うとcsvをサクッと読み込むことができる

import pandas as pd

df = pd.read_csv(data_dir / "python_handling_test.csv", encoding="utf-8")
df
タイムスタンプ 性別 国語が 数学が 英語が 社会が 理科が
0 2020/09/09 12:59:25 午後 GMT+9 +2 得意 -1 どちらかというと苦手 +2 得意 +2 得意 -1 どちらかというと苦手
1 2020/09/09 12:59:27 午後 GMT+9 +2 得意 -1 どちらかというと苦手 +2 得意 +2 得意 +0 どちらでもない
2 2020/09/09 12:59:29 午後 GMT+9 +1 どちらかというと得意 -2 苦手 +1 どちらかというと得意 +0 どちらでもない -2 苦手
3 2020/09/09 12:59:31 午後 GMT+9 +0 どちらでもない -2 苦手 -1 どちらかというと苦手 +0 どちらでもない -1 どちらかというと苦手
4 2020/09/09 12:59:34 午後 GMT+9 +0 どちらでもない -2 苦手 +0 どちらでもない -1 どちらかというと苦手 -1 どちらかというと苦手
... ... ... ... ... ... ... ...
1995 2020/09/10 3:35:51 午前 GMT+9 +0 どちらでもない +2 得意 +0 どちらでもない +1 どちらかというと得意 +2 得意
1996 2020/09/10 3:35:53 午前 GMT+9 +1 どちらかというと得意 +1 どちらかというと得意 +1 どちらかというと得意 +1 どちらかというと得意 +1 どちらかというと得意
1997 2020/09/10 3:35:55 午前 GMT+9 -1 どちらかというと苦手 +1 どちらかというと得意 +0 どちらでもない -1 どちらかというと苦手 +2 得意
1998 2020/09/10 3:35:57 午前 GMT+9 -1 どちらかというと苦手 +1 どちらかというと得意 +0 どちらでもない -1 どちらかというと苦手 +2 得意
1999 2020/09/10 3:35:59 午前 GMT+9 -1 どちらかというと苦手 +1 どちらかというと得意 +0 どちらでもない -2 苦手 +1 どちらかというと得意

2000 rows × 7 columns

ただし、csvファイルのフリをしたいい加減なファイルの場合はエラーになることもあるので、独自にコードを書いて例外的な処理をすることも必要になる。 世の中には、空白やタブで区切られたテキストファイルに、.csvという拡張子を付与した極悪なファイルも存在する。

さて、csvから読み出したデータをもう少し扱いやすいように整形していこう。

ファイルをみると、最初の0行目はどういうデータが入っているか(データの項目)を表している。
1-2000行目には2000人分の回答が詰まっている。

これによると、

0列目: 回答した時刻
1列目: 性別
2列目: 国語
3列目: 数学
4列目: 英語
5列目: 社会
6列目: 理科

らしい。いろいろなデータの整理方法があると思うがここでは、

  • ここではpandasの代わりに標準の csv モジュールで項目を読み込み

  • 処理A 0列目の時刻を24時間表記にして表示する

  • 処理B 2-6列目の各科目の得意・苦手意識を、文字列を除去して数値[-2,-1,0,1,2]として扱う

import csv


# 「年月日 時刻(h:m:s) 午前/午後 GMT+9」という構造を知った上での実装
def make_time_24h(input_str):
    parts = input_str.split()
    time = parts[1]
    ampm = parts[2]
    h, m, s = map(int, time.split(":"))
    if ampm not in ("午前", "午後") or not (1 <= h <= 12 and 0 <= m < 60 and 0 <= s < 60):
        raise ValueError(f"時刻の表記を確認してください: {input_str}")
    h = h % 12  # 午前12時は0時、午後12時は12時
    if ampm == "午後":
        h += 12
    return f"{h:02d}:{m:02d}:{s:02d}"


filename = data_dir / "python_handling_test.csv"
with open(filename, "r", encoding="utf-8", newline="") as inp:
    csv_rows = list(csv.reader(inp))  # 引用符やカンマの区切りはcsv.readerに任せる

nlines = []
for nth, row in enumerate(csv_rows[1:]):
    # 処理A: 回答時刻を24時間表記にする
    time = make_time_24h(row[0])
    M_or_F = row[1]
    # 処理B: 各科目の値を文字列から取り出す
    points = [int(row[k].split()[0]) for k in range(2, 7)]
    nline = [time, M_or_F] + points
    nlines.append(nline)

    if nth % 400 == 0:
        print("編集前", row)
        print("編集後", nline)
        print()
編集前 ['2020/09/09 12:59:25 午後 GMT+9', '女', '+2 得意', '-1 どちらかというと苦手', '+2 得意', '+2 得意', '-1 どちらかというと苦手']
編集後 ['12:59:25', '女', 2, -1, 2, 2, -1]

編集前 ['2020/09/09 1:13:03 午後 GMT+9', '男', '+1 どちらかというと得意', '-1 どちらかというと苦手', '+1 どちらかというと得意', '+0 どちらでもない', '+0 どちらでもない']
編集後 ['13:13:03', '男', 1, -1, 1, 0, 0]

編集前 ['2020/09/09 1:26:25 午後 GMT+9', '男', '+0 どちらでもない', '+0 どちらでもない', '+0 どちらでもない', '+0 どちらでもない', '+0 どちらでもない']
編集後 ['13:26:25', '男', 0, 0, 0, 0, 0]

編集前 ['2020/09/10 3:08:57 午前 GMT+9', '男', '+0 どちらでもない', '+1 どちらかというと得意', '+2 得意', '+1 どちらかというと得意', '+1 どちらかというと得意']
編集後 ['03:08:57', '男', 0, 1, 2, 1, 1]

編集前 ['2020/09/10 3:22:30 午前 GMT+9', '女', '+0 どちらでもない', '+0 どちらでもない', '+0 どちらでもない', '+0 どちらでもない', '+1 どちらかというと得意']
編集後 ['03:22:30', '女', 0, 0, 0, 0, 1]

最後に、各項目の得点を適当なリスト(あるいはnp.array)に整形しておけば、種々の分析を行うことができる。

import numpy as np

points = [[] for i in range(5)]
for tmp in nlines:
    for i in range(5):
        points[i] += [tmp[2 + i]]

print("points", np.array(points))
print("各科目の平均スコア:", [np.mean(points[i]) for i in range(5)])
points [[ 2  2  1 ... -1 -1 -1]
 [-1 -1 -2 ...  1  1  1]
 [ 2  2  1 ...  0  0  0]
 [ 2  2  0 ... -1 -1 -2]
 [-1  0 -2 ...  2  2  1]]
各科目の平均スコア: [-0.0005, 0.0035, 0.687, -0.0085, 0.237]

相関分析は別の章で扱ったので、具体例は省略する。

もちろん、このようなデータを扱う際には、pandasライブラリを使うともっと簡単にデータを扱うことができるが、 このような泥臭い?基本的な操作を覚えておくことは、データ解析の基礎を固める上で非常に重要である。

8.4. \(\clubsuit\) 複雑なエクセルファイルの操作#

家計調査のデータが入ったエクセルファイルkakei.xlsxを使用する。
このファイルも冒頭の準備で用意できているので、そのまま次のセルを実行しよう。

# 読み込むファイルのパス
filename = data_dir / "kakei.xlsx"

まずはxlsxファイルをPythonで読み込んで、どんな"シート"があるのかを確認してみよう。

%pip -q install openpyxl

import pandas as pd

with pd.ExcelFile(filename) as input_file:
    sheet_names = input_file.sheet_names
print("pandas: シート名", sheet_names)
Note: you may need to restart the kernel to use updated packages.
pandas: シート名 ['サンプルデータ', '課題(データベース)', '課題(解答)', 'Sheet1', 'Sheet2', 'Sheet3', 'Sheet4', 'Sheet5', 'Sheet6', 'Sheet7', 'Sheet8', 'Sheet9', 'Sheet10', 'Sheet11', 'Sheet12', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'S9', 'S10', 'S11', 'S12']

たくさんシートがあることが分かった。

次に、Sheet1の中身をのぞいてみよう。まずは行と列の数を取得してみる:

Sheet1 = pd.read_excel(filename, sheet_name="Sheet1")
print("行,列の数", Sheet1.shape)
行,列の数 (715, 114)

0〜4番目(先頭5行)の行にはどんな値がセルに入っているのかな...と思ったら

for i in range(5):
    print(list(Sheet1.iloc[i]))
[nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104]
[nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan]
[nan, nan, nan, nan, nan, nan, nan, '第4-1表\u3000都市階級・地方・都道府県庁所在市別1世帯当たりの支出金額,購入数量及び平均価格', nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan]
[nan, nan, nan, nan, nan, nan, nan, '平成29年(2017年) 1月', nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan]
[nan, nan, nan, nan, nan, nan, nan, '二人以上の世帯', nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan]

などとする。このように、扱いたいファイルの"構造"を知ることがやりたい操作を系統的に実行するための第一歩になる。

このエクセルを実際に開くとSheet1からSheet12までが複数都市の家計調査のデータでS1からS12までが気候データになっていて、 1-12までの数字が2017年の1月から12月までに対応していることが分かる。

実際のデータを触っていると「2006年までとそれ以降とでデータファイル(.xlsx)の"構造"が違う」といったことも出てくるので、 最初は特定のものに合わせたコードを作り、徐々に汎用性の高い(例外に対応できる)コードにしていくのが良い。

このエクセルを使って実際に作業をするには、pandas等のライブラリの細かい使い方を説明することになるため、授業ではやらず、以下の"おまけ"にいれておく。

8.4.1. \(\clubsuit\clubsuit\) おまけ#

以下のコードは、プログラミングの"ありがたみ"を感じてもらうためのお試し用。 (昔書いたかなり読みにくいコードなのであまり真剣に読まないで...) 例外処理も完璧でないのだが、許してほしい。

大量の画像ファイルをドライブに生成するので、以下を読んだ上で実行してください

以下のコードたちを何もいじらずに実行すると、全都市の月別平均気温と全品目の世帯平均支出のうち、相関係数の絶対値が0.95を超えるものをプロットして画像として保存します。

pthreの値を小さくすると、生成される画像の数がとんでもなく増えるのでやらないでください。
(0.9 → 291通り, 0.8 → 1234通り, 0.7 → 2871通り, 0.6 → 5233通り, 0.5 → 8375通り, 0.0 → 32876通り)

欠損値を含む系列や、1年を通じて値が一定の系列は、ここでは相関を計算せず除外する。件数だけでなく、どのデータを除いたかにも注意しよう。また、多数の組合せから強い相関だけを拾っているので、それだけで因果関係を主張することはできない。

cor_pic_dir = output_dir / "kakei_cor_pic"
cor_pic_dir.mkdir(parents=True, exist_ok=True)
%pip -q install japanize-matplotlib
Note: you may need to restart the kernel to use updated packages.
import os
import time

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
import japanize_matplotlib


class ebook:
    def __init__(self, inpf):
        self.input_file = pd.ExcelFile(inpf)
        self.sname = self.input_file.sheet_names
        self.ns = len(self.sname)
        print("pandas: シート名", self.sname)
        print("self.ns", self.ns)

        self.s_kakei = [i for i, sheetname in enumerate(self.sname) if "Sheet" in sheetname]
        self.s_kikou = [
            i
            for i, sheetname in enumerate(self.sname)
            if sheetname.startswith("S") and "Sheet" not in sheetname
        ]

    def indices(self):
        return self.s_kakei, self.s_kikou

    def readkakei(self, ikakei):
        ws = self.input_file.parse(sheet_name=self.sname[ikakei])
        city_indices = []
        city_names = []
        values_kakei = []
        items = []
        header_found = False

        for row_index in range(ws.shape[0]):
            row = list(ws.iloc[row_index])

            if not header_found:
                hits = [
                    col_index
                    for col_index, value in enumerate(row)
                    if isinstance(value, str) and "市" in value
                ]
                if len(hits) > 5:
                    header_found = True
                    city_indices = hits
                    city_names = [row[col_index] for col_index in city_indices]
                continue

            if row_index < 22:
                continue

            if isinstance(row[8], str) and row[8] != "":
                values_kakei.append([row[col_index + 1] for col_index in city_indices])
                items.append(row[8])

        if not header_found:
            raise ValueError("家計データの都市ヘッダーが見つかりませんでした。")

        return city_indices, city_names, values_kakei, items

    def readkikou(self, ikikou):
        ws = self.input_file.parse(sheet_name=self.sname[ikikou], header=None)
        quantities = []
        values_kikou = []
        places = None
        header_found = False

        for row_index in range(ws.shape[0]):
            row = list(ws.iloc[row_index])

            if not header_found:
                if any(isinstance(value, str) and "市" in value for value in row):
                    places = row[1:]
                    header_found = True
                continue

            quantities.append(row[0])
            values_kikou.append(row[1:])

        if not header_found:
            raise ValueError("気候データの都市ヘッダーが見つかりませんでした。")

        return places, values_kikou, quantities


# 月ごとの色を決める関数
def seasoncolor(month):
    if month <= 2 or month == 12:
        return "blue"
    if 3 <= month <= 5:
        return "green"
    if 6 <= month <= 8:
        return "red"
    if 9 <= month <= 11:
        return "orange"
    return "black"


# 指定した品目・地点の散布図を描く関数
def plot_cor(x, y, item, quantity, place, corrcoef, output_dir):
    fig = plt.figure(figsize=(4, 4))
    ax = fig.add_subplot(1, 1, 1)
    ax.set_facecolor("#e0e0e0")
    ax.set_title(place + "   r=" + str("%8.2f" % corrcoef).strip())
    ax.set_xlabel(item)
    ax.set_ylabel(quantity)
    ax.grid(True, axis="both", color="w", linestyle="dotted", linewidth=0.8)

    for month_index in range(len(x)):
        tcol = seasoncolor(month_index + 1)
        ax.scatter(
            x[month_index], y[month_index], marker="o", s=5, color=tcol, zorder=20000, alpha=0.7
        )
        ax.text(x[month_index], y[month_index], str(month_index + 1) + "月", color="k", fontsize=8)

    output_path = os.path.join(output_dir, f"corr_{item}vs{quantity}_at_{place}.png")
    plt.savefig(output_path, dpi=300, bbox_inches="tight")
    plt.close(fig)


# 指定した品目・地点の相関を調べる関数
def calcor(
    places,
    items,
    vs_kakei,
    tplaces,
    quantities,
    vs_kikou,
    iT,
    pthre,
    printlog=False,
    pltmode=True,
    output_dir=None,
):
    hit = 0
    num_pic = 0
    correlations = []
    quantity = quantities[iT]

    for j_k, place in enumerate(places):
        for j_t, tplace in enumerate(tplaces):
            if place != tplace:
                continue

            for item_index, item in enumerate(items):
                kvalue = np.asarray(
                    [vs_kakei[month][item_index][j_k] for month in range(len(vs_kakei))],
                    dtype=float,
                )
                tvalue = np.asarray(
                    [vs_kikou[month][iT][j_t] for month in range(len(vs_kikou))], dtype=float
                )

                if (
                    not np.isfinite(kvalue).all()
                    or not np.isfinite(tvalue).all()
                    or np.ptp(kvalue) == 0
                    or np.ptp(tvalue) == 0
                ):
                    continue

                corrcoef = np.corrcoef(kvalue, tvalue)[0][1]
                correlations.append([corrcoef, item, quantity, place])

                if printlog:
                    print("@", place, " ", item, kvalue, " VS ", quantity, ",", tvalue)

                if abs(corrcoef) > pthre:
                    hit += 1
                    if pltmode:
                        if output_dir is None:
                            raise ValueError(
                                "pltmode=True のときは output_dir を指定してください。"
                            )
                        plot_cor(kvalue, tvalue, item, quantity, place, corrcoef, output_dir)
                        num_pic += 1

    print("hit:", hit, " number of picture", num_pic)
    return correlations


def main():
    ti = time.time()
    inpf = data_dir / "kakei.xlsx"
    oupdir = cor_pic_dir
    iT = 6  # iT=6: 日平均気温
    printlog = False  # 条件にhitした都市の品目と気候データを逐次printするかどうか
    pthre = 0.95  # corrplotを描く相関係数の絶対値のthreshold(下限)
    pltmode = True  # T: plotする F: 計算のみ

    if pltmode:
        os.makedirs(oupdir, exist_ok=True)

    wb = ebook(inpf)
    s_kakei, s_kikou = wb.indices()
    vs_kakei = []
    vs_kikou = []

    for month_index, ind_kakei in enumerate(s_kakei):
        _, places, v_kakei, items = wb.readkakei(ind_kakei)
        tplaces, v_kikou, quantities = wb.readkikou(s_kikou[month_index])
        vs_kakei.append(v_kakei)
        vs_kikou.append(v_kikou)

    correlations = calcor(
        places,
        items,
        vs_kakei,
        tplaces,
        quantities,
        vs_kikou,
        iT=iT,
        pthre=pthre,
        printlog=printlog,
        pltmode=pltmode,
        output_dir=oupdir,
    )

    wb.input_file.close()
    tf = time.time()
    print("Elapsed time[sec]:", tf - ti)
    return correlations


if __name__ == "__main__":
    correlations = main()
pandas: シート名 ['サンプルデータ', '課題(データベース)', '課題(解答)', 'Sheet1', 'Sheet2', 'Sheet3', 'Sheet4', 'Sheet5', 'Sheet6', 'Sheet7', 'Sheet8', 'Sheet9', 'Sheet10', 'Sheet11', 'Sheet12', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'S9', 'S10', 'S11', 'S12']
self.ns 27
hit: 32  number of picture 32
Elapsed time[sec]: 17.00475025177002

8.5. 電子ファイルのフォーマット#

プログラムでデータを機械的に読み出して活用することで、人間が到底出来ないような作業効率を実現することができる場合も多い。 そんな光の側面ばかりなら良いが、実際にはそう上手くは行かないことも多い。

業務のデジタル化・デジタルトランスフォーメーションなどといった標語とは裏腹に、世の中にあふれるcsv,スプレッドシートなどは、 csvと謳っておいて、実際にはカンマ区切りではなくタブ区切りであったり、機械判読を全く想定していないデータの書き方・並べ方となっているものが多く、 プログラムを書ける人にとっては苦痛な状況も多い。

総務省統計局は令和2年2月に、政府統計(e-Stat)に関して統計表における機械判読可能なデータの表記方法の統一ルールの策定というものを出している。 これが最適な提案かはさておき、データの記述に法則性と機械判読性をもたせる意識を全員が持つことが重要なように思う。

お掃除ロボットが床を綺麗にするためには、まずお掃除ロボットが走れるよう掃除する(床に物が散乱していない)という条件が求められる、という話だ(そうなの?)。

8.6. ファイルパスの指定#

ファイルがコンピュータ上でどこにあるかを指し示す文字列はファイルパス(パス, path)と呼ばれる。
"/content/drive/MyDrive/XXX.png"もファイルパスの一例になっている。

たとえば...

[Sota]というユーザの[ドキュメント] (あるいは[書類])フォルダに
[csv_file]というフォルダがあり[test.csv]というcsvファイルが入っている

とするとそのファイルを指し示すパスは
Windowsの場合→ C:\Users\Sota\Documents\csv_file\test.csv
macOSの場合→ /Users/Sota/Documents/csv_file/test.csv となる。

注:

  • Windowsの場合→"C"の部分は皆さんのディスク環境に依存

  • Google Colab.環境では、Unix(Mac)やLinuxと同様の方式(スラッシュを使用)

  • バックスラッシュ\はWindowsの日本語環境では¥円記号で表示される
    (プログラムなどを書く人には厄介な仕様だったりする)

コンピュータには、ホームディレクトリというものが指定されておりWindowsなら C:\Users\ユーザー名,Macなら /Users/ユーザー名に通常設定されていて、ユーザーがよく使うデスクトップや写真・ドキュメントなどのフォルダはホームディレクトリ直下に配置されている。また、ホームディレクトリは~/で簡略化して指定することもできる。 OSにもよるが...ライトユーザーはホームディレクトリより上の階層を触らないことが推奨されている。理由は、システムファイルなどが入っているため。

パスの指定の仕方にはその他にも方法があり、ピリオドやスラッシュを駆使して現在のディレクトリからの[相対パス]で指定する事もできる。たとえば...

Home/
├── Documents/
│   ├── AdDS2021/
│   │   └── Report1/
│   ├── AdDS2020/
│   │   ├── Report1/
│   │   │   ├── StudentA/
│   │   │   └── StudentB/
│   │   └── Report2/
│   └── AdDS2019/
└── Pictures/

こういう階層構造になっていて、現在Home/Documents/AdDS2020/Report1という ディレクトリにいるとすると、そこから

  • StudentAへの相対パスは ./StudentAStudentA

  • Report2への相対パスは ../Report2

  • AdDS2019への相対パスは ../../AdDS2019

  • Pictures内のhonyarara.jpgへの相対パスは../../../Pictures/honyarara.jpg

といった感じ。前述のように愚直にReport1フォルダを指定するときは/Users/Sota/Documents/AdDS2020/Report1といった感じで、これを相対パスと対比させて絶対パスと呼んだりする。

Pythonコードの文字列にWindowsのパスを書く場合は、r"C:\Users\Sota\Documents\csv_file\test.csv" のようなraw文字列にするか、"C:/Users/Sota/Documents/csv_file/test.csv" のように / を使うと、\ が特殊な意味に解釈されるのを避けられる。~open が自動で展開するわけではないので、Pythonでは Path("~/Documents").expanduser() などを使う。

8.6.1. ファイル名で気をつけたい文字#

授業のノートブックでは、拡張子を除き、ファイル名に基本的に半角英数字とアンダースコアを使っている。これは別に作成者(吉田)がイキってる訳ではない。コマンドやプログラムから扱う際の手間を減らすためである。

空白や括弧、* などは、コマンドで特別な意味を持つことがある。/ はパスの区切りなので、ファイル名の一部としては使えない。Windowsでは使えない文字がほかにもある。

一方、ハイフンや拡張子のピリオド、日本語の名前が一律に禁止というわけではない。使える文字と、扱いやすい名前の付け方は区別しよう。例を見てみる。

Colabでは、先頭に ! を付けてLinuxコマンドを実行できる。

!ls hogehoge.pdf  # ファイルの情報を表示
!mkdir hogehoge   # ディレクトリを作成

たとえば半角スペースが入ったtest␣.pdfというファイルがあったとする。
これをlsコマンドで表示させようとして

!ls test .pdf

という命令を行うと、test␣.pdfという指定ではなくtest.pdfという2つのファイルが存在するかどうかを尋ねる命令になってしまう。
この場合、test␣.pdfの有無を調べたければ、別途バックスラッシュを入れて「記号としての空白です」と機械に教えなくてはならない。このように、特殊文字の意味を打ち消すことをエスケープと呼ぶ。引用符で囲んで !ls "test .pdf" と書く方法もある。

!ls test\ .pdf

といった具合に、人間側の手間が必要になってしまう。

ファイル名そのものが変わるのではなく、コマンドに渡す際の書き方に注意が必要ということ。Pythonの Pathopen には、空白を含む名前もそのまま文字列として渡せる。

こういうことは小中高や大学でも理由付きで教えてくれなかったりするので、プログラミングをやって初めて気がつく(気にするようになった)という人も多いかもしれない。

機械判読可能なデータの表記方法やファイル名の命名規則などは、プログラムを書く上での基本的な知識として覚えておくと良い。