概要
最近、業務上の仕事を自動化する機会に恵まれました。内容としては、
- gspreadライブラリを使い、Googleスプレッドシート上のスケジュールを抜き出す
- 簡単な集計と再配置を含む操作を行い、pandasのデータフレームにまとめ直す
- まとめたデータフレームをGoogleスプレッドシートに再出力する
という比較的単純なものでした。現状の完成度は8割程度です。思った以上に手こずり、非常に得るものが多かったので、今後のためにまとめておこうと思いたちこの記事を作成しています。若干散らかっているかもしれませんが、温かい目で見ていただければ幸いです。
知識編
以下は主にライブラリだったり、プログラムの仕様だったりと、知識面に関する学びをまとめたものです。
1.)pandas.DataFrameの代入は同じオブジェクトを指す
これはデータフレームの扱いになれていらっしゃる方からすれば、当たり前のことなのだと思います。私も以前にリストを扱っているときに、同じ過ちを犯したことがあるのですが、思い至らず再びやらかしました。
状況としては、以下のようなdatetime型をキーとした辞書を空のデータフレームで初期化しようとしている場面です。「日付ごとにセクションを使っている人をまとめたいから、とりあえず空のデータフレームを登録したろ!」って感じで作りました。
import pandas as pd
import datetime
empty_user = dict(Section1=['', '', ''], Section2=['', '', ''], Section3=['', '', ''])
hour = ['9:00', '10:00', '11:00']
df = pd.DataFrame(data=empty_user, index=hour)
five_days_list = [(datetime.datetime(2020, 9, 1) + datetime.timedelta(days=1) * i) for i in range(5)]
dict_of_dataframe = {date_key : df for date_key in five_days_list}
出来上がった辞書はこんな感じです。
date_key : 2020-09-01 00:00:00
value :
Section1 Section2 Section3
9:00
10:00
11:00
date_key : 2020-09-02 00:00:00
# 以下省略
1週間くらい前の私はこれに何の疑問も抱かず次の作業に進みました。が、いざ集計したデータを登録し始めると、全く上手くいきません。1ヶ月分くらいのスケジュールのうち、3日目以降はぐっちゃぐちゃのもちゃもちゃです。
今思うと理由は単純。この作成方法は同じデータフレームを何度も使い回しているだけです。id()を使って出力すればより顕著になるでしょう。
for date_key, dataframe in dict_of_dataframe.items():
print(f"date_key : {date_key}")
print(f"dataframe_id : {id(dataframe)}")
date_key : 2020-09-01 00:00:00
dataframe_id : 2124838088520
date_key : 2020-09-02 00:00:00
dataframe_id : 2124838088520
date_key : 2020-09-03 00:00:00
# 以下省略
正しくは、pandas.DataFrameのメソッドであるcopy()を使わなければなりませんでした。
dict_of_dataframe = {date_key : df.copy() for date_key in five_days_list}
date_key : 2020-09-01 00:00:00
dataframe_id : 2124838588936
date_key : 2020-09-02 00:00:00
dataframe_id : 2124838590152
date_key : 2020-09-03 00:00:00
pandasの公式ドキュメントにばっちり記載してあるのですが、データフレームのcopy()メソッドはデフォルトでdeep=True,つまり深いコピーになっています。あくまでテンプレートとして利用したいだけなら、オブジェクトとしては別になっていないとダメですよね。気づいたときはぶん殴られるような衝撃を感じました。
なお、後述しますが、この作業には結構深めのデータの構造、およびそれに伴う多重ループ構造というカルマまで付随していました。
当初はそちらの方ばかりを気にしていたため、途中経過を出力したりpandasのドキュメントを読んだり、ループ構造を紙に書き出したりと、アレやコレやの悪戦苦闘で2日くらい苦しんでいたと思います。
2.)for:else:はとっても便利
Pythonの基礎文法をちょっとは知ったつもりになっていましたが、この子とは開発中に初めて出会いました。あるいは知っていたのに忘れていました。for文とセットでelse文を使うと、for文のループをbreakで抜けなかった時だけelse文を実行してくれます。
使い道は無数にあると思いますが、私は以下のように空きセクションが存在しないときにログで通知してもらうために利用しました。
client_salesman_dict = {datetime.datetime(2020, 9, 1, 9, 0) : [('山田様', '高橋'), ('吉沢様', '伊藤')],
datetime.datetime(2020, 9, 1, 10, 0) : [('佐々木様', '桃山')],
datetime.datetime(2020, 9, 1, 11, 0) : [('横田様', '高橋'), ('福地様', '大木'), ('中山様', '伊藤'), ('権田様', '小沢')],}
section_list = ['Section1', 'Section2', 'Section3',]
for date_dt, client_salesman_tuples in client_salesman_dict.items():
date_str = f"{date_dt.hour}:{date_dt.minute:02}"
for client_salesman_tuple in client_salesman_tuples:
client = client_salesman_tuple[0]
salesman = client_salesman_tuple[1]
for section in section_list:
section_status = df.loc[date_str, section]
print(f"client : {client}, salesman : {salesman}")
print(f"time is {date_str}")
print(f"section is {section}")
print(f"section_status is {section_status}")
if section_status:
print(f"bool of section_status is {bool(section_status)}.")
print("I will skip writing phase.")
continue
print(f"I have applied {client},{salesman} to {section}")
df.loc[date_str, section] = f"{client} {salesman}"
break
else:
print(f"There is no empty section for{client}, {salesman}.Please recheck schedule.")
client : 山田様, salesman : 高橋
time is 9:00
section is Section1
section_status is
I have applied 山田様,高橋 to Section1
client : 吉沢様, salesman : 伊藤
time is 9:00
section is Section1
section_status is 山田様 高橋
bool of section_status is True.
I will skip writing phase.
# 中略
There is no empty section for権田様, 小沢.Please recheck schedule.
むかーしC言語だかJavaだかで似たような処理をしていたときは、確かフラグを内部に立てて頑張っていたと思うのですが、Pythonなら不要のようです。地味ですが、for文自体の利用機会が多いので、今後も適宜活用していきたいところ。
3.)属性の前に_を1個で慣習的なprivate宣言、2個だと通常の方法でアクセスできなくする
前々から存在は知っていたのですが、特に意識して自分で使うことはありませんでした。振る舞いについて適当にテストすると、こんな感じになると思います。
class TestClass:
def __init__(self):
self.hoge = 1
self._fuga = 2
self.__monge = 3
def _foo1(self):
print("_foo1 is called")
def __foo2(self):
print("__foo2 is called")
t = TestClass()
# インスタンス変数
print(t.hoge)
print(t._fuga)
# print(t.__monge) ←呼べない
print(t._TestClass__monge)
# クラスメソッド
t._foo1()
# t.__foo2() ←呼べない
t._TestClass__foo2()
1
2
3
_foo1 is called
__foo2 is called
アンダースコアを先頭に2つ付けた場合は、インスタンス変数もクラスメソッドもいつものようには呼べなくなります。かといってJavaにあるようなガチガチのPrivate属性というわけではなく、
instance.__ClassName_AttributeName
で呼ぶこと自体は可能です。
また、アンダースコアを先頭に1つ付けた場合は……こちらも呼べちゃうんですよね。しかも普通に。「え、じゃあ何のためにあるの?」と思って調べたところ、以下のようなサイトが見つかりました。
【Python】アンダースコア( _ )の使い方(特殊属性、dunders)
こちらによると、どうやら
- 1個の時は内部用だということを示唆するだけで、特に動作が変わったりはしない。ただし、モジュールとしてワイルドカードを使って呼び出すときに限り、読み込まれなくなる。
- 2個の時は名前のマングリング(名前修飾)を起こすので、そのままアクセスすることはできなくなる。ただ、privateにすることが目的ではなく、親子関係にあるクラス間で名前の衝突を避けるために利用される。
らしいです。そもそもprivateを作るためのものだという認識がおかしかったんですね。今回はクラスの継承を行わずにプログラムを作成したので、アンダースコア1個の利用で十分でした。
4.)docstringは良い文化
こちらも存在だけは何となく知っていたのですが、利用したのは今回が初めてでした。作成の際には以下の記事を参考にさせていただいています。
[Python]可読性を上げるための、docstringの書き方を学ぶ(NumPyスタイル)
今回のプログラムは、自分による自分のための開発だったので「必要あるかな?」と思いつつ書いていましたが、結果としては「何を使って」「何のために」「何をするのか」をしっかりと考える切っ掛けになりました。これまでは何となく書き始めて、いったん動かしてから修正する場当たり的なやり方でしたが、docstringを書くことで少しはマシになったと思います。
考え方編
以下は知識というよりも、経験的に「こうした方が良さそうだな」と学んだ点になります。
1.)深すぎるデータ構造はアウト
最初にスケジュールをまとめ始めた時、私は以下のような辞書にデータをまとめていました。
from datetime import datetime
from datetime import datetime
schedule_dict = {'1week': {datetime(2020, 9, 1) : {datetime(2020, 9, 1, 9, 0): [('山田様', '寺田'),('吉木様', '遠藤'),],
datetime(2020, 9, 1, 10, 0): [('工藤様', '山下'),],},
datetime(2020, 9, 2) : {datetime(2020, 9, 2, 10, 0): [('鶴川様', '本田'),],
datetime(2020, 9, 2, 11, 0): [('遠藤様', '相澤'),],},
datetime(2020, 9, 2) : {datetime(2020, 9, 3, 9, 0): [('下田様', '寺田'), ('吉川様', '郷田')],
}
}
'2week': ....}
アクセスする際はこんな感じです。
schedule_dict['2week'][datetime(2020, 9, 8)][datetime(2020, 9, 8, 10, 0)]
スプレッドシート側のデータが1週間ごとに横並びになっていたので、特に何も考えず合わせたのですが、とにかく無用に深い。アクセスする際もまだるっこしいし、利用する際にfor文なんかで展開するときにもループの階層がどんどん深くなる。
結局作業途中で根を上げて、週のキーを無くし、一階層だけ浅くしました。今あらためて見直してみると、0時の日付キーもいらない気がします。必要になったらdatetime.datetime型のyear,month,dayの属性それぞれにアクセスして、作り直せば良いですからね。
よく、「無駄に深いループは避けるべき」とは言われますが、それを引き起こす「無駄に深い階層のデータを作るのも避けるべき」だということが身にしみました。体感的には、階層を一回深くなるごとに処理あたりの負担が2倍になっている気がします。脳のおメモリがガンガン吸われるんですよね……
2.)多少長くなってもきちんとした名前をつける
開発当初、部分部分をテストした時の名前をそのまま変数名に使っていました。処理を対象ごとにクラス分けしていたので、名前が衝突することもありません。データフレームならdf,日付ならdate、辞書ならdctといった具合に、なるべく短く書こうと心がけていました。
が、すぐに壁にぶち当たりました。プログラムの仕様上の問題というより、脳のメモリの方の問題です。処理が複雑化するに連れて、「あれ、この辞書って中身はなんだっけ?」「エラー吐いてるけど、この日付は文字列型じゃないの?」「index out of range???想定していた要素数のリストが来ていないの?」などの問題が多発しました。
今までは仕様を軽く理解するためのサンプルプログラムくらいしか書いていなかったので、そこまで気にすることはありませんでしが、名前はどこから飛んできてもわかるようにつけるべきなのですね。言葉にすれば当然のようで、あまり本気で考えたことがありませんでした。
datetime型の日付ならdate_dtで、str型の日付ならdate_str。あとは、何を格納しているのかを示すためにclient_salesman_tuples_listという名前を付けたり、for文での展開時に使用する名前をfor client_salesman_tuple in client_salesman_tuples_list:のように、単数形と複数形を意識して使い分けるようにしました。
おかげさまで、最初の頃よりはいくぶんわかりやすくなりました。そもそも脳が混乱するような処理をしないのが理想なのでしょうが、そのための技量が足りないなら足りないなりに、名前の方で工夫するというのは今後も意識しておきたいところです。
3.)オブジェクト指向はお役所や会社を想像する
これは本当に正しいのかよくわかりません。ただ、2週間ほど前に部分から全体へとまとめる時に、100行くらい平書きして我に帰りました。この調子だと大変なことになるよねと。ちょっと調べて出てきた以下のサイトからプチ天啓を得ました。
オブジェクト指向とは、 クラスを通して self という名前空間を適切に分割していく作業じゃないかなと感じています。
適切に分割、という言葉を眺めてしばらくボーッとしていましたが、ふと「これってお役所と同じじゃない?」という発想に至りました。
少し前に市役所に住民票を取りに行って、ちょっとだけ待たなければならないことがありました。待っている間何とは無しにお役所のサイトを見ていたのですが、実に細かく別れています。市民、商工観光、建設、都市計画etcetc...
ぱっと見、「建設と都市計画って同じじゃないの?」なんてことを見てる側は思ったりするのですが、実際は○○部の△△課ということで区分けされているわけです。お仕事の中身までは知りませんが、しっかりと分担されているのでしょう。これは「クラス分け」と同じ。
時には部署ごとの連携が必要になることもあるでしょうが、全ての情報を渡したりはしないでしょう。紙とデータが氾濫してしまいます。その仕事に必要なだけの情報を担当者が持っていって話し合うほうが合理的です。これが「継承より合成」と同じ。
さらに住民票の取得を申請した"私"は、中でどんな処理が行われているのかは全く知りません。担当の方がなんかこう、パソコンに色々入力して書類に記入しているのは何となく想像がつきますが、やってることは申請書を書いて、お金を払っているだけです。それで問題なく住民票は受け取れます。多分窓口業務をしている方も、具体的に書いた書類や入力されたデータが何に使われて、どう保管されているのか、その全ては把握されていないでしょう。これが「情報の隠蔽」と同じ。
そんな感じで、頭の中にお役所を想像したら何とかクラス分けを行うことができました。自分で書いてても、本当にこの理解で合っているのかは今ひとつ自身がないのですが、曲がりなりにも書けたので暫定的にOKとしています。
と思っていたら、Qiitaの方にタイムリーな記事が上がっていました。こちらの方がずっとわかりやすい説明だと思いますので、参照してみて下さい。
オブジェクト指向歴25年のオブジェクト指向おじさんが語るオブジェクト指向設計の処方箋
今後ここは習得していきたいよね編
最後に、知識や考え方としてはあったけど、結局上手に使えなかったことを書いていきます。
for文を浅くするためのitertoolsの活用
またまた知らなかったのですが、Pythonの標準ライブラリにはitertoolsなるライブラリがあります。わかりやすい解説はこちらの投稿を参考していただくとして、私がここで注目したのは、itertoolsの中にあるproductなるメソッドです。
このproductは、公式ドキュメントに記載のある通り、一般的な多重ループと同じような出力を行ってくれます。
import itertools
section_list = ['Section1', 'Section2', 'Section3']
time_list = ['9:00', '10:00', '11:00']
for section in section_list:
for time in time_list:
print(section, time)
print("------------")
for section, time in list(itertools.product(section_list, time_list)):
print(section, time)
出力は同じです。
Section1 9:00
Section1 10:00
Section1 11:00
Section2 9:00
Section2 10:00
Section2 11:00
Section3 9:00
Section3 10:00
Section3 11:00
------------
Section1 9:00
Section1 10:00
Section1 11:00
Section2 9:00
Section2 10:00
Section2 11:00
Section3 9:00
Section3 10:00
Section3 11:00
タプルとして受け取ることも可能です。
for tpl in list(itertools.product(section_list, time_list)):
print(tpl)
('Section1', '9:00')
('Section1', '10:00')
('Section1', '11:00')
('Section2', '9:00')
('Section2', '10:00')
('Section2', '11:00')
('Section3', '9:00')
('Section3', '10:00')
('Section3', '11:00')
本当はこれをループの階層削減に利用したかったのですが、どうにも上手く行きませんでした。上で述べたように、ループの階層の増加は脳の負担の増加に直結することを痛いほどに理解したので、今後はデータ構造の考え方と共に、itertoolsの利用も行っていきたいです。
まとめ
よくプログラミング初級者へのアドバイスとして、「とりあえずなにか作ってみたら?」というのを聞きますが、これが実に理に適った助言であることが理解できました。想像の10倍くらい苦労しましたが、それだけ得るものも多かったと感じています。
なにかご意見、アドバイス等ありましたらコメント欄によろしくお願いします。