Qiita
Python
pandas
データ分析
seaborn

Python, pandasによるデータ分析の実践 (Qiita記事データ編)

はじめに

この記事では、Qiitaの記事データを題材としたpandasによるデータ分析の実践例を紹介する。

Qiita APIを利用したデータの取得については以下の記事を参照。

対象とするのは2018年8月15日未明に取得した、それまでの全記事データ(32万件程度)。

以降のサンプルコードでは特定の列のみを読み込んで使う。

import collections
import itertools
import os

import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

result_dir_path = 'results'

df = pd.read_csv(
    os.path.join(result_dir_path, 'summary.csv'),
    usecols=['title', 'created_at', 'likes_count', 'comments_count', 'tags_str', 'url', 'id']
)

print(len(df))
# 322129

最終的に以下のようなグラフを作成する。

記事数上位10タグのシェアの推移。

tags_bar_plot_stacked_normalized.png

曜日別・時間帯別の記事数の分布(ヒートマップ)。

heatmap.png


※普段は自分のサイトに書いているのですが、Qiitaのデータを使わせてもらった内容はQiitaに書くのが筋だろうと思いこちらに書くことにしました。

pandas.DataFrameの基本的な扱い

行名・列名、行番号・列番号によるデータの選択

df[列名]で列を選択できる。列名をリストで指定して複数列を選択することも可能。スペースの都合上、head()メソッドで先頭5行のみ出力する。

print(df['title'].head())
# 0    HTTP::Request -> AnyEvent::HTTP -> HTTP::Response
# 1    http://qiita.com/items/239/chunk の string= 使う ...
# 2    さておき、#.とかload-time-valueは(割とあやしい)使いでがありそうで良いです...
# 3    文字列をエスケープするときに"&"をエスケープしないと、セキュリティ的にまずい状況なることっ...
# 4                                          CLあやしいTips 
# Name: title, dtype: object

print(df[['created_at', 'likes_count']].head())
#                   created_at  likes_count
# 0  2011-09-28T16:18:38+09:00            1
# 1  2011-09-28T14:41:56+09:00            1
# 2  2011-09-28T08:51:27+09:00            1
# 3  2011-09-27T23:57:21+09:00            1
# 4  2011-09-27T22:29:04+09:00            2

df[スライス]で行を選択できる。スペースの都合上、[列名]で一列分のみ出力する。

print(df[5:10]['title'])
# 5    Rubyで文字列strのn番目(nは0オリジン)から後ろの部分文字列を取り出したいときに、次...
# 6    Evernoteに書くときに緯度経度が欲しくなったのでちょっと調べた.これでlat/long取れる
# 7        GoogleAppEngine/Python開発についての解説本のおすすめはありませんか?
# 8                                          slime便利コマンド
# 9          WebGLを勉強したいのですが、日本語のドキュメントやチュートリアルとかありませんか?
# Name: title, dtype: object

at, iat, loc, ilocで行名・列名、行番号・列番号によってデータを選択できる。使い分けは以下の通り。

  • 位置の指定方法
    • at, loc : 行名、列名
    • iat, iloc : 行番号、列番号
  • 選択できるデータ
    • at, iat : 単独の要素の値
    • loc, iloc : 単独および複数の要素の値
      • ※単独の要素を選択するのであればat, iatのほうが高速

set_index()id列をインデックスに設定したDataFrameを例とする。

df_id = df.set_index('id')

print(df_id['title'].head())
# id
# c96f56f31667fd464d40    HTTP::Request -> AnyEvent::HTTP -> HTTP::Response
# 94e9fdf602d14fbbb58b    http://qiita.com/items/239/chunk の string= 使う ...
# 427a15cad55a2ff6c0b9    さておき、#.とかload-time-valueは(割とあやしい)使いでがありそうで良いです...
# 1e647cf795b42f9da4cc    文字列をエスケープするときに"&"をエスケープしないと、セキュリティ的にまずい状況なることっ...
# afcdd03b456c59bd9320                                          CLあやしいTips 
# Name: title, dtype: object

print(df_id.columns)
# Index(['title', 'created_at', 'likes_count', 'comments_count', 'tags_str',
#        'url'],
#       dtype='object')

at, iat, loc, ilocでの選択結果。loc, ilocではリストやスライスで範囲を指定できる。

print(df_id.at['c96f56f31667fd464d40', 'title'])
# HTTP::Request -> AnyEvent::HTTP -> HTTP::Response

print(df_id.iat[0, 0])
# HTTP::Request -> AnyEvent::HTTP -> HTTP::Response

print(df_id.loc[['c96f56f31667fd464d40', 'afcdd03b456c59bd9320'], ['likes_count', 'comments_count']])
#                       likes_count  comments_count
# id                                               
# c96f56f31667fd464d40            1               0
# afcdd03b456c59bd9320            2               3

print(df_id.iloc[:3, [1, 3]])
#                                      created_at  comments_count
# id                                                             
# c96f56f31667fd464d40  2011-09-28T16:18:38+09:00               0
# 94e9fdf602d14fbbb58b  2011-09-28T14:41:56+09:00               1
# 427a15cad55a2ff6c0b9  2011-09-28T08:51:27+09:00               0

より詳しくは以下の記事を参照。

条件によるデータの抽出

query()

条件に応じてデータを抽出するにはquery()メソッドを使う。

対象の列の列名に対して比較演算子で条件を指定した文字列をquery()の引数に渡す。andorを使った複数条件も可能。

print(df.query('likes_count > 5000')['title'])
# 316                           Markdown記法 チートシート
# 35420         ペアプログラミングして気がついた新人プログラマの成長を阻害する悪習
# 49770     非デザイナーエンジニアが一人でWebサービスを作るときに便利なツール32選
# 78460                       うまくメソッド名を付けるための参考情報
# 146751           ロシアの天才ハッカーによる【新人エンジニアサバイバルガイド】
# Name: title, dtype: object

print(df.query('likes_count > 500 and comments_count > 100')['title'])
# 316                  Markdown記法 チートシート
# 83947               IT業界で横行する恥ずかしい英語発音
# 131276                      ズンドコキヨシまとめ
# 239255    ビットコイン自動裁定取引システムを開発・トレードした結果
# Name: title, dtype: object

print(df.query('tags_str == "Ruby"')['title'].head())
# 5     Rubyで文字列strのn番目(nは0オリジン)から後ろの部分文字列を取り出したいときに、次...
# 23    Rubyでは Object#send で指定された名前のメソッドを呼び出せるけれど、これはR...
# 36                                     extendがよく分かってない。
# 67                                              感謝しました。
# 68                                    句読点を切り替えるのを書きました.
# Name: title, dtype: object

ブール値のリストで抽出

列名にスペースやピリオドなどが含まれているとquery()は使えない。

より汎用的には、元のDataFrameの行数と等しい要素数のブール値のリストやSeriesを指定することで、Trueの行を抽出できる。

print((df['tags_str'] == 'Ruby').head(10))
# 0    False
# 1    False
# 2    False
# 3    False
# 4    False
# 5     True
# 6    False
# 7    False
# 8    False
# 9    False
# Name: tags_str, dtype: bool

print(df[df['tags_str'] == 'Ruby']['title'].head())
# 5     Rubyで文字列strのn番目(nは0オリジン)から後ろの部分文字列を取り出したいときに、次...
# 23    Rubyでは Object#send で指定された名前のメソッドを呼び出せるけれど、これはR...
# 36                                     extendがよく分かってない。
# 67                                              感謝しました。
# 68                                    句読点を切り替えるのを書きました.
# Name: title, dtype: object

strアクセサによる文字列メソッド(.str.xxx())を使うと文字列の列に対する様々な処理が可能。例えばstr.contains()は文字列が特定の文字列を含んでいる場合にTrueとするメソッド。

これを利用すると、==による完全一致ではなく部分一致の条件で行を抽出できる。

print(df['tags_str'].str.contains('Ruby').head(10))
# 0    False
# 1    False
# 2    False
# 3    False
# 4    False
# 5     True
# 6    False
# 7    False
# 8    False
# 9    False
# Name: tags_str, dtype: bool

print(df[df['tags_str'].str.contains('Ruby')]['title'].head())
# 5     Rubyで文字列strのn番目(nは0オリジン)から後ろの部分文字列を取り出したいときに、次...
# 15            NArrayを拡張したほうがいいのかな。あれはタイプを指定してnewするわけだし。
# 16    Arrayクラスを拡張したコードを幾つか書いているけれど、Arrayインスタンスに格納されて...
# 17                              スリープソートをRubyで汎用的に書いてみる。
# 23    Rubyでは Object#send で指定された名前のメソッドを呼び出せるけれど、これはR...
# Name: title, dtype: object

tags_strに対する'Ruby'の完全一致および部分一致によって抽出される行数は以下の通り。

print(len(df.query('tags_str == "Ruby"')))
# 3736

print(len(df[df['tags_str'].str.contains('Ruby')]))
# 18878

これはQiitaサイトで「Ruby」タグを検索した結果の件数(17000件強)と一致しない。

tags_strはカンマ区切りの文字列であるため、完全一致では複数タグが設定された記事が漏れてしまい、部分一致では「RubyOnRails」タグなどがカウントされてしまうのが原因。

タグの扱いについては後述する。

ソート

sort_values()に列名を指定することで、その列の値を基準に並び替えることができる。

print(df[['title', 'likes_count']].sort_values('likes_count', ascending=False)[:10].reset_index(drop=True))
#                                               title  likes_count
# 0                    ロシアの天才ハッカーによる【新人エンジニアサバイバルガイド】         7339
# 1                                 Markdown記法 チートシート         6753
# 2                 ペアプログラミングして気がついた新人プログラマの成長を阻害する悪習         6064
# 3             非デザイナーエンジニアが一人でWebサービスを作るときに便利なツール32選         5851
# 4                               うまくメソッド名を付けるための参考情報         5090
# 5                              Gitでやらかした時に使える19個の奥義         4930
# 6                数学を避けてきた社会人プログラマが機械学習の勉強を始める際の最短経路         4741
# 7           【まとめ】これ知らないプログラマって損してんなって思う汎用的なツール 100超         4642
# 8  もう保守されない画面遷移図は嫌なので、UI Flow図を簡単にマークダウンぽく書くエディタ作った         4344
# 9         新人プログラマに知っておいてもらいたい人類がオブジェクト指向を手に入れるまでの軌跡         4214

「いいね」数ランキングなどが簡単に出力できる。ここではiterrows()でforループを回して行ごとに処理を行う。

for i, row in df.sort_values('likes_count', ascending=False)[:10].reset_index().iterrows():
    print('- No.{0}: [{1[title]}]({1[url]}) ({1[likes_count]} likes)'.format(i + 1, row))

歴代いいね数ランキングの結果。

時系列データの扱い

日時情報を含む時系列データの扱いもpandasの得意分野。

時系列データとして設定

'created_at'列に記事の作成日時が文字列として格納されている。

この列をインデックスに指定してpd.to_datetime()で変換することでDatetimeIndexとなり、時系列データとして扱われる。

タイムゾーンをpd.to_datetime()の引数utc=True, tz_convert(), tz_localize()で処理している。utc=Trueでタイムゾーンを「UTC」と指定して変換した後にtz_convert()で「Asia/Tokyo」にタイムゾーンを変更、さらに、以降ではタイムゾーンを使わないので tz_localize(None)としてタイムゾーン情報を削除している。

print(df['created_at'][0])
# 2011-09-28T16:18:38+09:00

print(type(df['created_at'][0]))
# <class 'str'>

df_ts = df.set_index('created_at')

df_ts.index = pd.to_datetime(df_ts.index, utc=True).tz_convert('Asia/Tokyo').tz_localize(None)

print(df_ts.index)
# DatetimeIndex(['2011-09-28 16:18:38', '2011-09-28 14:41:56',
#                '2011-09-28 08:51:27', '2011-09-27 23:57:21',
#                '2011-09-27 22:29:04', '2011-09-27 10:20:28',
#                '2011-09-26 13:10:17', '2011-09-25 16:24:46',
#                '2011-09-25 15:01:57', '2011-09-24 00:49:49',
#                ...
#                '2018-07-31 09:56:12', '2018-07-31 09:51:58',
#                '2018-07-31 09:45:02', '2018-07-31 09:44:14',
#                '2018-07-31 09:43:06', '2018-07-31 09:23:30',
#                '2018-07-31 09:18:31', '2018-07-31 09:10:51',
#                '2018-07-31 09:02:54', '2018-07-31 09:00:52'],
#               dtype='datetime64[ns]', name='created_at', length=322129, freq=None)

df_ts.sort_index(inplace=True)

print(df_ts['title'].head())
# created_at
# 2011-09-16 03:01:10                                        RubyでFizzBuzz
# 2011-09-16 13:38:20                 Emacsをずっと開いてるとだんだん重くなることがあり,気になっています
# 2011-09-16 13:39:42                                    句読点を切り替えるのを書きました.
# 2011-09-16 13:43:07                                              感謝しました。
# 2011-09-16 13:44:24    UINavigationController から自分自身を外して別のViewControl...
# Name: title, dtype: object

時系列データとして扱うと、日時指定や日時のスライスで行を選択できる。便利。

print(df_ts['2011-10-01']['title'])
# created_at
# 2011-10-01 11:07:58             Eloquent JavaScript を翻訳中。果たして需要はあるのだろうか。
# 2011-10-01 14:34:54         (1)「最近投稿されたアイテム」がタグによっては上部の緑の線より下が何も表示されません。
# 2011-10-01 17:10:41                                        リストの中身が昇順かどうか
# 2011-10-01 17:29:45    Google+で見かけたコメントに,「Pythonでリストの何番目の要素が最大/最小か求める...
# 2011-10-01 22:08:06    MacのMySQL GUiクライアントでSequel Proというのが便利.DBちょっと覗き...
# Name: title, dtype: object

print(df_ts['2015-01-01 00:00':'2015-01-01 01:00']['title'])
# created_at
# 2015-01-01 00:14:32          Free Pascal であけましておめでとうございます!
# 2015-01-01 00:42:22                 Dockerライフサイクルをハンズオンで学ぶ
# 2015-01-01 00:46:17    VimfilerでQuick Lookを使ってファイルをプレビューする
# 2015-01-01 00:51:22                                 S3 事始め
# Name: title, dtype: object

リサンプリング

年ごと、四半期ごと、月ごとなど、期間ごとにデータを集約したい場合はresample()を使う。

resample()には年、四半期、月などに対応する頻度コードを指定し、さらにsum()(合計)やmean()(平均)などのメソッドを呼ぶことでデータを処理する。記事数をカウントするために'post_count'列を新たに追加し1を代入している。

df_ts['post_count'] = 1

print(df_ts.resample('YS').sum())
#             likes_count  comments_count  post_count
# created_at                                         
# 2011-01-01        11527             578         563
# 2012-01-01       125271            3150        6797
# 2013-01-01       412339            7833       15825
# 2014-01-01      1278658           18913       39936
# 2015-01-01      1358820           24384       56889
# 2016-01-01      1216427           27054       70561
# 2017-01-01       724694           28125       74528
# 2018-01-01       405493           16128       57030

print(df_ts.resample('YS').mean())
#             likes_count  comments_count  post_count
# created_at                                         
# 2011-01-01    20.474245        1.026643         1.0
# 2012-01-01    18.430337        0.463440         1.0
# 2013-01-01    26.056177        0.494976         1.0
# 2014-01-01    32.017678        0.473583         1.0
# 2015-01-01    23.885461        0.428624         1.0
# 2016-01-01    17.239367        0.383413         1.0
# 2017-01-01     9.723782        0.377375         1.0
# 2018-01-01     7.110170        0.282799         1.0

print(df_ts.resample('QS').sum())
#             likes_count  comments_count  post_count
# created_at                                         
# 2011-07-01          138              48          75
# 2011-10-01        11389             530         488
# 2012-01-01        16071             727        1054
# 2012-04-01        31233             665        1874
# 2012-07-01        36416             795        1670
# 2012-10-01        41551             963        2199
# 2013-01-01        64548            1378        2983
# 2013-04-01        88184            1678        3320
# 2013-07-01        98427            2246        3656
# 2013-10-01       161180            2531        5866
# 2014-01-01       217767            3489        7144
# 2014-04-01       321725            4803        9686
# 2014-07-01       350838            4927       10336
# 2014-10-01       388328            5694       12770
# 2015-01-01       312881            5717       12181
# 2015-04-01       316221            5658       12631
# 2015-07-01       296997            5650       13401
# 2015-10-01       432721            7359       18676
# 2016-01-01       349902            7031       16766
# 2016-04-01       297488            6097       15648
# 2016-07-01       249191            5947       15657
# 2016-10-01       319846            7979       22490
# 2017-01-01       180904            6753       16764
# 2017-04-01       147739            6818       16517
# 2017-07-01       139676            6221       15787
# 2017-10-01       256375            8333       25460
# 2018-01-01       173046            6933       21312
# 2018-04-01       169695            6430       24079
# 2018-07-01        62752            2765       11639

上の結果から分かるように、mean()は対象期間のデータ一件あたりの平均値となる。

一日あたりの平均値を求めるには日数を別途カウントして割る(ほかにいい方法があるのかもしれないが知らない)。

df_days = pd.DataFrame({'days_count': 1},
                       index=pd.date_range(df_ts.index[0], df_ts.index[-1], freq='D'))

print(df_days.resample('YS').sum())
#             days_count
# 2011-01-01         107
# 2012-01-01         366
# 2013-01-01         365
# 2014-01-01         365
# 2015-01-01         365
# 2016-01-01         366
# 2017-01-01         365
# 2018-01-01         227

print((df_ts.resample('YS').sum().T / df_days.resample('YS').sum()['days_count']).T)
#             likes_count  comments_count  post_count
# created_at                                         
# 2011-01-01   107.728972        5.401869    5.261682
# 2012-01-01   342.270492        8.606557   18.571038
# 2013-01-01  1129.695890       21.460274   43.356164
# 2014-01-01  3503.172603       51.816438  109.413699
# 2015-01-01  3722.794521       66.805479  155.860274
# 2016-01-01  3323.571038       73.918033  192.789617
# 2017-01-01  1985.463014       77.054795  204.186301
# 2018-01-01  1786.312775       71.048458  251.233480

被除数をDataFrame、除数をSeriesとする場合、DataFrameのcolumns(列名)とSeriesのindexが一致していると一括で処理されるため、.Tで転置を行っている。

これらの結果を見ると、一日あたりの記事数は増加しているが、一記事あたりの「いいね」数やコメント数が減少傾向にあることが分かる。ユーザー間のコミュニケーションが減っているのは運営サイドとしてはなんとかしたいポイントかもしれない。

resample()のあとに適用できるメソッドには、sum()mean()のほかにOHLC(Open: 始値、High: 高値、Low: 安値、Close: 終値)を出力するohlc()もある。株価のデータを処理する場合は非常に便利。

移動平均を算出したい場合はrolling()を使う。

マルチインデックス、GroupBy

リサンプリングは連続した期間を区切ってデータを集約するが、曜日別や時間別にデータを集約したい場合もある。そのような場合はマルチインデックス(階層型インデックス)を指定すると便利。

DatetimeIndexは属性yearweekday, hourなどで、年や曜日、時間の値を取得できる。それらをset_index()でリストで指定することでマルチインデックスのDataFrameとなる。weekday0が月曜で6が日曜。

df_multi = df_ts.set_index([df_ts.index.year, df_ts.index.month, df_ts.index.weekday,
                            df_ts.index.hour, df_ts.index])
df_multi.index.names = ['year', 'month', 'weekday', 'hour', 'date']

print(df_multi['title'].str[:30].head(20))
# year  month  weekday  hour  date               
# 2011  9      4        3     2011-09-16 03:01:10                     RubyでFizzBuzz
#                       13    2011-09-16 13:38:20    Emacsをずっと開いてるとだんだん重くなることがあり,気に
#                             2011-09-16 13:39:42                 句読点を切り替えるのを書きました.
#                             2011-09-16 13:43:07                           感謝しました。
#                             2011-09-16 13:44:24    UINavigationController から自分自身を
#                             2011-09-16 13:45:52                        文字列化についてです
#                             2011-09-16 13:46:02                 PerlのFizzBuzz教えて!
#                             2011-09-16 13:56:03                     awkは奥(awk)深すぎ
#                       14    2011-09-16 14:10:42    CoreTextを使ってNSAttributedString
#                             2011-09-16 14:14:41    最近使ってるObjective-Cの便利ライブラリ。クロージ
#                             2011-09-16 14:16:48    @yaotti コードにpermalink があって wge
#                             2011-09-16 14:18:17    XCodeのプロジェクトに他のプロジェクトを静的ライブラリと
#                             2011-09-16 14:23:24    NSNotificationCenter。所謂Observe
#                             2011-09-16 14:39:22                         キータで聞いたった
#                             2011-09-16 14:56:09    JavaScript で new Regexp("[\s\S
#                       15    2011-09-16 15:03:02    Node.js上でのおすすめWebアプリケーションフレームワ
#                             2011-09-16 15:40:38    Qiitaが緑色ではなく紫色を使っていたら、emacsの話題
#                             2011-09-16 15:45:53    CRubyでFizzBuzzを書いてみた。JRubyでは動か
#                       16    2011-09-16 16:03:31           もっとソーシャルなgistみたいに使うのかな?
#                             2011-09-16 16:06:04    FacebookのGraph APIで特定のURLを渡して、
# Name: title, dtype: object

マルチインデックスの場合、sum()mean()などのメソッドの引数levelに対象の階層の列名を指定することで、その階層の値ごとにデータが集約される。

levelにはリストで指定することも可能で、例えば年ごとの曜日別の集計などが簡単に処理できる。

print(df_multi.sum(level='month').sort_index())
#        likes_count  comments_count  post_count
# month                                         
# 1           429876           10339       24840
# 2           439354           10348       25629
# 3           445889           11341       27735
# 4           460904           10672       27496
# 5           467723           10927       28059
# 6           443658           10550       28200
# 7           461725           10892       28457
# 8           372658            8840       23274
# 9           400052            8867       20490
# 10          388668            8535       21635
# 11          379596            9101       22975
# 12          843126           15753       43339

print(df_multi.sum(level='weekday').sort_index())
#          likes_count  comments_count  post_count
# weekday                                         
# 0             865072           18376       46818
# 1             831175           18933       48118
# 2             834613           19202       49858
# 3             846123           19383       49098
# 4             794135           18602       46568
# 5             634303           15795       38730
# 6             727808           15874       42939

print(df_multi.sum(level=['year', 'weekday']).sort_index())
#               likes_count  comments_count  post_count
# year weekday                                         
# 2011 0                637              40          62
#      1                507              42          89
#      2                841              59         100
#      3                726              45          72
#      4               7756             299         114
#      5                654              47          72
#      6                406              46          54
# 2012 0              16490             397         894
#      1              20758             489        1004
#      2              25332             569        1181
#      3              16102             441        1116
#      4              17205             498        1087
#      5              14792             412         763
#      6              14592             344         752
# 2013 0              59357            1126        2176
#      1              59528            1069        2381
#      2              66403            1258        2613
#      3              66047            1315        2583
#      4              56691            1349        2442
#      5              53512             937        1744
#      6              50801             779        1886
# 2014 0             196297            2705        5839
#      1             191499            2927        6011
#      2             203721            3113        6562
#      3             192436            2805        6279
#      4             192853            2915        5899
#      5             136089            2175        4485
#      6             165763            2273        4861
# 2015 0             209829            3416        8162
#      1             205036            3803        8564
#      2             206591            3607        8963
#      3             226439            3713        9113
#      4             191561            3699        8246
#      5             150534            3288        6695
#      6             168830            2858        7146
# 2016 0             190717            4018       10243
#      1             186006            4037       10456
#      2             171077            3869       10528
#      3             180857            4288       10730
#      4             170601            3828       10244
#      5             142972            3310        8857
#      6             174197            3704        9503
# 2017 0             123818            4225       11092
#      1             105243            4120       11090
#      2             103436            4158       11209
#      3              99905            4390       11087
#      4             104829            3922       10586
#      5              88308            3531        9016
#      6              99155            3779       10448
# 2018 0              67927            2449        8350
#      1              62598            2446        8523
#      2              57212            2569        8702
#      3              63611            2386        8118
#      4              52639            2092        7950
#      5              47442            2095        7098
#      6              54064            2091        8289

マルチインデックスによる集約は内部でGroupByの仕組みを使っている。マルチインデックスを設定せずにgroupby()を直接使ってもいい。おこのみで。

print(df_ts.groupby(df_ts.index.month).sum().sort_index())
#             likes_count  comments_count  post_count
# created_at                                         
# 1                429876           10339       24840
# 2                439354           10348       25629
# 3                445889           11341       27735
# 4                460904           10672       27496
# 5                467723           10927       28059
# 6                443658           10550       28200
# 7                461725           10892       28457
# 8                372658            8840       23274
# 9                400052            8867       20490
# 10               388668            8535       21635
# 11               379596            9101       22975
# 12               843126           15753       43339

これらの結果の可視化は後述。

タグの扱い

文字列をリストに分割

今回の例のタグtags_strのようなカンマ区切りの文字列はリストとして扱うと便利なことが多い。

str.split(',')で文字列をリストに分割する。スペースが入っている場合は', 'などとする必要がある。

print(df_ts['tags_str'].head())
# created_at
# 2011-09-16 03:01:10    Ruby,FizzBuzz
# 2011-09-16 13:38:20            Emacs
# 2011-09-16 13:39:42             Ruby
# 2011-09-16 13:43:07             Ruby
# 2011-09-16 13:44:24      Objective-C
# Name: tags_str, dtype: object

df_ts['tags_list'] = df_ts['tags_str'].str.split(',')

print(df_ts['tags_list'].head())
# created_at
# 2011-09-16 03:01:10    [Ruby, FizzBuzz]
# 2011-09-16 13:38:20             [Emacs]
# 2011-09-16 13:39:42              [Ruby]
# 2011-09-16 13:43:07              [Ruby]
# 2011-09-16 13:44:24       [Objective-C]
# Name: tags_list, dtype: object

なお、文字列もリストも列のデータ型dtypeobject。考慮しなければいけない場面は少ないが、objectは常に文字列というわけではないので注意。

print(df_ts['tags_str'].dtypes)
# object

print(df_ts['tags_list'].dtypes)
# object

print(type(df_ts['tags_str'][0]))
# <class 'str'>

print(type(df_ts['tags_list'][0]))
# <class 'list'>

各タグの個数を算出

標準ライブラリitertoolsモジュールのitertools.chain.from_iterable()を使うとリストが格納されたSeriesを平坦化したイテレータを取得できる。確認のためlist()でリスト化すると以下のような結果となる。

all_tag_list = list(itertools.chain.from_iterable(df_ts['tags_list']))

print(all_tag_list[:10])
# ['Ruby', 'FizzBuzz', 'Emacs', 'Ruby', 'Ruby', 'Objective-C', 'Perl', 'Perl', 'awk', 'Objective-C']

print(len(all_tag_list))
# 793849

平坦化したイテレータを標準ライブラリcollectionsモジュールのcollections.Counter()に渡すと、各タグの出現個数をカウントできる。辞書ライクなオブジェクトを返すのでlen()でユニークなタグの数を取得したり、タグ名を指定して個数を取得したりできる。most_common()メソッドで出現回数順にタグとその個数を取得することも可能。

c = collections.Counter(itertools.chain.from_iterable(df_ts['tags_list']))

print(len(c))
# 37547

print(c['Ruby'])
# 17648

print(c['Python'])
# 19143

print(c.most_common()[:5])
# [('JavaScript', 20302), ('Python', 19143), ('Ruby', 17648), ('PHP', 12775), ('iOS', 11845)]

Seriesに変換することもできる。

tags = pd.Series(c)

print(tags.sort_values(ascending=False)[:50])
# JavaScript      20302
# Python          19143
# Ruby            17648
# PHP             12775
# iOS             11845
# Rails           11833
# Android         10340
# Swift            9654
# Java             9634
# AWS              9407
# Linux            8196
# docker           7305
# Git              6635
# Node.js          6450
# Mac              6151
# C#               5475
# Unity            5462
# MySQL            4901
# C++              4617
# Xcode            4582
# Windows          4448
# CSS              4407
# Go               4387
# CentOS           4056
# #migrated        3912
# python3          3883
# Ubuntu           3854
# Objective-C      3841
# 機械学習             3794
# HTML             3655
# vagrant          3514
# RaspberryPi      3416
# GitHub           3364
# jQuery           3173
# Bash             3109
# Vim              3068
# laravel          2634
# 初心者              2619
# Scala            2473
# DeepLearning     2392
# golang           2387
# WordPress        2322
# MacOSX           2261
# R                2236
# HTML5            2121
# nginx            2088
# ShellScript      2058
# C                1988
# RubyOnRails      1980
# Slack            1866
# dtype: int64

print(tags.sum())
# 793849

リストに対する条件で抽出

上述のように、例えば「Ruby」を抽出したい場合、カンマ区切りの文字列に対して処理をすると「Ruby」単独の記事しか抽出できなかったり「RubyOnRails」まで抽出してしまったりする問題があった。

正規表現を駆使してもいいが、リスト化して処理することも可能。

列の各要素に関数を適用するapply()に無名関数を指定する。Qiitaサイトで「Ruby」タグを検索した結果件数と同じ数の行が抽出できている(データ取得日時とのズレがあるので完全に一致はしない)。

df_ruby = df_ts[df_ts['tags_list'].apply(lambda x: 'Ruby' in x)]

print(df_ruby['title'].head())
# created_at
# 2011-09-16 03:01:10                                        RubyでFizzBuzz
# 2011-09-16 13:39:42                                    句読点を切り替えるのを書きました.
# 2011-09-16 13:43:07                                              感謝しました。
# 2011-09-16 15:45:53                     CRubyでFizzBuzzを書いてみた。JRubyでは動かない
# 2011-09-16 21:30:38    XML/SOAPのAPIでWSDLファイルが無い場合ってどうすればいいんでしょう? Ruby...
# Name: title, dtype: object

print(len(df_ruby))
# 17648

なお、Qiitaのタグに関しては「Python」と「python3」が混在していたりするので厳密には名寄せを行う必要があるが、今回は省略。

タグごとに集約

リストに対する条件でタグごとのデータを抽出できれば、上述のresample()などで集約できる。

print(df_ruby.resample('QS').sum())
#             likes_count  comments_count  post_count
# created_at                                         
# 2011-07-01           27              11          12
# 2011-10-01          546              38          55
# 2012-01-01         6073             211         127
# 2012-04-01         4567             134         275
# 2012-07-01         9199             173         239
# 2012-10-01         7054             167         283
# 2013-01-01        14270             267         458
# 2013-04-01         9263             162         380
# 2013-07-01        10866             193         372
# 2013-10-01        18379             207         519
# 2014-01-01        16437             347         591
# 2014-04-01        43087             519         749
# 2014-07-01        32853             554         837
# 2014-10-01        37885             594         866
# 2015-01-01        23768             659         819
# 2015-04-01        17408             543         631
# 2015-07-01        16508             601         726
# 2015-10-01        24183             611         939
# 2016-01-01        22043             728         797
# 2016-04-01        10219             427         769
# 2016-07-01        11665             506         776
# 2016-10-01        15099             501         985
# 2017-01-01         8949             520         834
# 2017-04-01         5278             474         731
# 2017-07-01         5460             553         755
# 2017-10-01        11092             571         872
# 2018-01-01         4756             333         838
# 2018-04-01        10917             441         902
# 2018-07-01         2340             212         511

タグ別のストック数上位記事を出力するのも簡単。

for t in tags.sort_values(ascending=False).index[:5]:
    print('- __{}__'.format(t))
    df_tag = df_ts[df_ts['tags_list'].apply(lambda x: t in x)].sort_values('likes_count', ascending=False)
    for i, row in df_tag[:3].reset_index().iterrows():
        print('    - No.{0}: [{1[title]}]({1[url]}) ({1[likes_count]} likes)'.format(i + 1, row))

タグ別のストック数ランキング。

タグごとの記事数の推移をまとめたいというような場合は、それぞれの結果のDataFrameをリストに格納しpd.concat()で連結するという方法がある。

df_tag_list = []
top_tag_list = tags.sort_values(ascending=False).index[:10].tolist()

for t in top_tag_list:
    df_tag = df_ts[df_ts['tags_list'].apply(lambda x: t in x)]
    df_tag_list.append(df_tag[['post_count']].resample('QS').sum())

df_tags = pd.concat(df_tag_list, axis=1)
df_tags.columns = top_tag_list

print(df_tags)
#             JavaScript  Python  Ruby  PHP    iOS  Rails  Android  Swift  Java  \
# created_at                                                                      
# 2011-07-01           8       2    12    4    NaN    NaN      NaN    NaN     1   
# 2011-10-01          42      27    55   42    NaN   39.0     34.0    NaN     7   
# 2012-01-01         109      37   127  123   12.0   67.0     37.0    NaN    47   
# 2012-04-01         159      53   275  120   55.0  171.0     81.0    NaN    46   
# 2012-07-01         148      69   239  125   91.0  134.0     42.0    NaN    35   
# 2012-10-01         201      64   283  122   85.0  123.0     41.0    1.0    52   
# 2013-01-01         186     104   458  129  178.0  208.0     80.0    3.0    62   
# 2013-04-01         277     107   380  192  141.0  180.0    121.0    2.0   112   
# 2013-07-01         287     150   372  260  151.0  186.0    111.0    0.0   125   
# 2013-10-01         386     237   519  281  325.0  270.0    202.0    3.0   188   
# 2014-01-01         480     309   591  284  518.0  331.0    253.0    5.0   260   
# 2014-04-01         556     345   749  469  461.0  474.0    344.0  277.0   360   
# 2014-07-01         606     333   837  407  523.0  500.0    453.0  372.0   340   
# 2014-10-01         691     425   866  489  668.0  513.0    571.0  559.0   346   
# 2015-01-01         777     509   819  526  528.0  533.0    492.0  409.0   323   
# 2015-04-01         848     431   631  549  532.0  534.0    438.0  478.0   321   
# 2015-07-01         866     440   726  522  621.0  560.0    516.0  500.0   390   
# 2015-10-01        1125     910   939  671  876.0  732.0    648.0  860.0   587   
# 2016-01-01        1104     782   797  643  632.0  606.0    580.0  702.0   650   
# 2016-04-01         972     849   769  621  528.0  589.0    576.0  584.0   475   
# 2016-07-01         865     893   776  658  699.0  521.0    500.0  629.0   464   
# 2016-10-01        1233    1311   985  811  850.0  561.0    735.0  842.0   648   
# 2017-01-01        1070    1200   834  607  538.0  588.0    474.0  521.0   484   
# 2017-04-01        1021    1186   731  599  473.0  625.0    474.0  500.0   486   
# 2017-07-01        1063    1312   755  609  406.0  537.0    438.0  436.0   490   
# 2017-10-01        1564    2016   872  856  718.0  651.0    696.0  666.0   720   
# 2018-01-01        1413    1959   838  802  466.0  601.0    561.0  528.0   602   
# 2018-04-01        1515    1991   902  809  528.0  653.0    562.0  502.0   722   
# 2018-07-01         728    1092   511  445  239.0  346.0    280.0  275.0   291   
# 
#               AWS  
# created_at         
# 2011-07-01    NaN  
# 2011-10-01    1.0  
# 2012-01-01    6.0  
# 2012-04-01   15.0  
# 2012-07-01   13.0  
# 2012-10-01   12.0  
# 2013-01-01   41.0  
# 2013-04-01   54.0  
# 2013-07-01   49.0  
# 2013-10-01   69.0  
# 2014-01-01  116.0  
# 2014-04-01  126.0  
# 2014-07-01  247.0  
# 2014-10-01  345.0  
# 2015-01-01  343.0  
# 2015-04-01  356.0  
# 2015-07-01  426.0  
# 2015-10-01  549.0  
# 2016-01-01  502.0  
# 2016-04-01  507.0  
# 2016-07-01  488.0  
# 2016-10-01  821.0  
# 2017-01-01  594.0  
# 2017-04-01  570.0  
# 2017-07-01  503.0  
# 2017-10-01  826.0  
# 2018-01-01  740.0  
# 2018-04-01  696.0  
# 2018-07-01  391.0  

この結果の可視化は後述。

plotメソッドによる可視化

Series, DataFrameのメソッドとしてplot()がある。Pythonのグラフ描画ライブラリMatplotlibのラッパーで、簡単にグラフを作成できる。

上述のリサンプリングやマルチインデックスを使った集約結果もDataFrameなので、さらにplot()メソッドを呼ぶだけでグラフを生成可能。

Jupyter Notebookの場合、先に%matplotlib inlineを実行しておくとグラフがインラインで表示される。画像ファイルとして保存する場合はplt.savefig()を使う。

記事数の推移(折れ線グラフ)

plot()はデフォルトで折れ線グラフを出力する。

末尾のデータは期間の途中で終わっているので[:-1]で省いている。

df_ts['post_count'].resample('D').sum()[:-1].plot()

df_ts['post_count'].resample('M').sum()[:-1].plot()

df_ts['post_count'].resample('Y').sum()[:-1].plot()

line_plot_d.png

line_plot_m.png

line_plot_y.png

タグ別の記事数とそのシェアの推移(積み上げ棒グラフ)

上で作成した記事数上位10タグの記事数の推移のデータ(df_tags)を可視化する。

plot()デフォルトは折れ線グラフ。特に指定しなくても列名が凡例として表示される。

df_tags[:-1].plot(figsize=(10, 4))

tags_line_plot.png

  • 2014年末: Ruby → JavaScript
  • 2016年半ば: JavaScript → Python

という記事数トップのタグの変遷が分かる。

折れ線グラフ以外のグラフの種類はplot.bar()またはplot(kind='bar')のように指定する。各種のグラフに対する設定は引数で指定する。例えば棒グラフの場合、stacked=Trueとすると積み上げ棒グラフになる。

df_tags[:-1].plot.bar(stacked=True, figsize=(10, 4))

tags_bar_plot_stacked.png

シェアの推移を見るために総数を1として規格化する。

df_normalized = (df_tags.T / df_tags.sum(axis=1)).T

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df_normalized[:-1].plot.bar(stacked=True, ax=ax, figsize=(10, 4))
handles, labels = ax.get_legend_handles_labels()
plt.legend(reversed(handles), reversed(labels), loc='upper left', bbox_to_anchor=(1, 1))

tags_bar_plot_stacked_normalized.png

RubyからPythonへのシェアの移り変わりや、PHPやJavaの根強さが分かる。

ちなみに上位10タグが含まれていない記事も追加すると以下の結果となる。

df_others = df_ts[df_ts['tags_list'].apply(lambda x: not (set(top_tag_list) & set(x)))]
df_tag_list_others = df_tag_list + [df_others[['post_count']].resample('QS').sum().rename(columns={'post_count': 'Others'})]

df_tags_others = pd.concat(df_tag_list_others, axis=1)
df_tags_others.columns = top_tag_list + ['Others']

df_normalized_others = (df_tags_others.T / df_tags_others.sum(axis=1)).T

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df_normalized_others[:-1].plot.bar(stacked=True, ax=ax, figsize=(10, 4))
handles, labels = ax.get_legend_handles_labels()
plt.legend(reversed(handles), reversed(labels), loc='upper left', bbox_to_anchor=(1, 1))

tags_bar_plot_stacked_normalized_other.png

厳密には上位10タグは重複してカウントされていることを考慮する必要があるが、上位10タグの占める割合は全体のおよそ4割程度で推移している。

曜日別・時間別の記事数の分布(ヒートマップ)

pandasのplot()だけでなく、ビジュアライゼーションライブラリseabornを使うとさらに様々なグラフを簡単に作成できる。

上で作成した曜日などでマルチインデックス化したデータ(df_multi)を例とする。

マルチインデックスをsum()などで処理した結果もDataFrameなので、plot()でそのままプロットできる。曜日別と時間別。weekday0が月曜で6が日曜。

df_multi['post_count'].sum(level='weekday').sort_index().plot.bar(color='navy')

df_multi['post_count'].sum(level='hour').sort_index().plot.bar(color='navy')

weekday_bar_plot.png

hour_bar_plot.png

  • 土日の記事数が少ない
  • 13時、20時に谷がある(食事や帰宅の時間?)

ということが分かる。

さらに曜日別・時間別を合わせて可視化する。2つのインデックスで集約したあとでunstack()で二次元の形にする。

df_w_h = df_multi['post_count'].sum(level=['weekday', 'hour']).sort_index()

print(df_w_h)
# weekday  hour
# 0        0       3204
#          1       1828
#          2       1179
#          3        656
#          4        475
#          5        348
#          6        556
#          7        701
#          8        970
#          9       1394
#          10      1942
#          11      2401
#          12      2272
#          13      2242
#          14      2343
#          15      2626
#          16      2811
#          17      2903
#          18      2899
#          19      2455
#          20      2221
#          21      2468
#          22      2804
#          23      3120
# 1        0       3139
#          1       1848
#          2       1082
#          3        595
#          4        382
#          5        324
#                  ... 
# 5        18      2244
#          19      1932
#          20      1895
#          21      2136
#          22      2485
#          23      2745
# 6        0       2779
#          1       1898
#          2       1379
#          3        839
#          4        572
#          5        414
#          6        476
#          7        575
#          8        690
#          9       1052
#          10      1339
#          11      1794
#          12      1870
#          13      1878
#          14      2196
#          15      2359
#          16      2631
#          17      2682
#          18      2414
#          19      2139
#          20      2258
#          21      2563
#          22      2949
#          23      3193
# Name: post_count, Length: 168, dtype: int64

print(df_w_h.unstack(level='hour'))
# hour       0     1     2    3    4    5    6    7    8     9   ...     14  \
# weekday                                                        ...          
# 0        3204  1828  1179  656  475  348  556  701  970  1394  ...   2343   
# 1        3139  1848  1082  595  382  324  550  692  991  1512  ...   2631   
# 2        3298  1830  1121  600  419  348  546  710  973  1518  ...   2776   
# 3        3334  1876  1152  646  432  324  571  690  991  1472  ...   2684   
# 4        3262  1802  1075  646  396  352  568  685  895  1426  ...   2705   
# 5        2496  1558  1045  694  474  357  435  582  737  1078  ...   1924   
# 6        2779  1898  1379  839  572  414  476  575  690  1052  ...   2196   
# 
# hour       15    16    17    18    19    20    21    22    23  
# weekday                                                        
# 0        2626  2811  2903  2899  2455  2221  2468  2804  3120  
# 1        2693  2866  2995  2845  2545  2334  2364  2715  3092  
# 2        2909  3165  3151  2881  2488  2241  2416  2921  3102  
# 3        2892  2971  3047  2975  2441  2248  2403  2689  2861  
# 4        2925  3034  3101  2883  2474  1973  2019  2056  2166  
# 5        2228  2462  2491  2244  1932  1895  2136  2485  2745  
# 6        2359  2631  2682  2414  2139  2258  2563  2949  3193  
# 
# [7 rows x 24 columns]

これをSeabornのheatmap()に渡す。

plt.figure(figsize=(12, 4))
sns.heatmap(df_w_h.unstack(level='hour'))

heatmap.png

  • 金曜日の夜からおやすみモード
  • 土曜日は夜更かししがち
  • 月曜日(特に午前中)はまだエンジンがかかっていない

というようなQiitaユーザーのみなさんの生態が分かる。日曜日の夜も意外と活発。大きなお世話だと思うが、早めに寝たほうがいいんじゃないだろうか。

折れ線グラフはこちら。曜日間の差分はヒートマップよりこちらのほうが分かりやすい。それぞれに良し悪しがある。

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
df_w_h.unstack(level='weekday').plot(figsize=(10, 4), ax=ax)
plt.legend(loc='upper left', bbox_to_anchor=(1, 1))

weekday_hour_line_plot.png

データを絞り込んで同様の処理を行うのも簡単。

なんとなく時間帯によって差がありそうな「ポエム」タグの時間別の記事数を例にする。

df_poem = df_multi[df_multi['tags_str'].str.split(',').apply(lambda x: 'ポエム' in x)]

print(len(df_poem))
# 910

df_poem['post_count'].sum(level='hour').sort_index().plot.bar(color='navy')

poem_hour_bar_plot.png

「ポエム」タグはこのデータ取得時点で900件強とまだ少ないので参考程度ではあるが、イメージ通り深夜0時頃に投稿された記事数が多い。

「ポエム」タグの記事をいくつか読んでみた感じでは別にそんなにポエティックな内容でもないように思えたが、人は夜中になると「ポエム」タグを付けたくなるのかもしれない。タグはあとからでも追加できるので、夜中に投稿した記事を昼間に読み返して「ポエム」タグを付ける場合もあるかもしれない。

曜日別・時間別のヒートマップがこちら。fillna()で欠損値を埋めている。

plt.figure(figsize=(12, 4))
sns.heatmap(df_poem['post_count'].sum(level=['weekday', 'hour']).sort_index().unstack(level='hour').fillna(0))

poem_heatmap.png

最多投稿数はまさかの日曜深夜(月曜0〜1時)。大きなお世話だと思うが、日曜日の夜はポエムを書くよりも早めに寝たほうがいいんじゃないだろうか。

ちなみに、Seabornのヒートマップは関係性がよくわからないデータに対してとりあえず相関行列を可視化してみて様子を伺うときに便利。

ヒートマップのほかにはペアプロット図(散布図行列)などもある。

まとめ

今回扱った32万件(75MB)程度のデータであればpandasのメソッドをそのまま使うだけでサクサクと処理できる。全件をforループでまわすようなことをしなければストレスを感じるほど時間がかかることもないはず。

今回はただ適当にグラフを作っただけだが、実際の業務においては何らかの答えを出すためにデータをいじくって試行錯誤を繰り返すことになる。pandasを活用して、データを集約したりちょっとグラフ化してみたりというような処理を素早く行うことによって、本来時間をかけるべきところ・悩むべきところに集中できる。

ということで、みなさん、非常に便利なpandasをどんどん使いましょう。

おまけ

NumPy, pandas, Matplotlibをしっかり学びたいなら『Pythonデータサイエンスハンドブック』が最高にオススメ。英語版は全文がオンラインで無料で公開されている。

pandasをより詳しく学びたいなら第二版の日本語版が出た『Pythonによるデータ分析入門』。

なお、『Pythonによるデータ分析入門』の初版(2013年)は最新のバージョンのpandasでサンプルコードを動かすとエラーだらけなので、もし安くなっていても買ってはいけない。