先週、groupby.aggに複数の集約関数を与えてできた、列がMultiIndexのDataFrameを、元の列毎に処理する方法がわからず悩んだが、良い方法が見つかったのでメモしておく。

In [1]:
import numpy as np
import pandas as pd

def get_data(date):
    n = np.random.randint(5, 10)
    return pd.DataFrame({
        'person': list("ABCDEFGHIJ"[:n]), 
        'date': date,
        'hour': map(lambda x: ['AM', 'PM'][x], sorted(np.random.randint(2, size=n))),
        'Left': np.random.randint(5, size=n),
        'Right': np.random.randint(10, size=n)
    }).set_index(['person', 'date', 'hour'])

# test
np.random.seed(3)
get_data('2020-03-01')
Out[1]:
Left Right
person date hour
A 2020-03-01 AM 3 4
B 2020-03-01 AM 2 7
C 2020-03-01 AM 3 8
D 2020-03-01 AM 1 1
E 2020-03-01 PM 1 6
F 2020-03-01 PM 2 2
G 2020-03-01 PM 0 2

このような形式で、来た人が午前か午後のどちらに左の通路と右の通路をそれぞれ何回通ったかというデータがあり、時間帯毎の1人当たりのそれぞれの通路を通った平均回数を計算したいとする。

実際のデータはサイズが大きく、1日分のデータはDRAMに載るが全期間のデータは載らずMemory Errorになったので、平均は合計÷人数ということで、次のように1日分ずつデータを読み込んで時間帯毎、通路毎に通った回数の合計と人数を加算していくようにした。

In [2]:
np.random.seed(3)

sumdf = None
for date in pd.date_range('2020-03-01', '2020-03-07'):
    df = get_data(date.date())

    df = df.groupby('hour').agg(['sum', 'size'])
    if sumdf is None:
        sumdf = df
    else:
        sumdf = sumdf.add(df, fill_value=0)

sumdf
Out [2]:
Left Right
sum size sum size
hour
AM 40 27 114 27
PM 49 23 72 23

groupby.aggに'sum', 'size'という複数の集約関数を渡しているので、結果の列がMultiIndexになっている。
後はこれの 'Left' と 'Right' をそれぞれの 'sum' / 'size' に置換すれば良いのだが、その方法がわからなかった。
結局、調べながら試行錯誤して筆者が最もシンプルだと思ったコードは次のようになった。

In [3]:
meandf = sumdf.groupby(level=0, axis=1).apply(lambda x: x[x.name]['sum'] / x[x.name]['size'])
meandf
Out [3]:
Left Right
hour
AM 1.481481 4.222222
PM 2.130435 3.130435
In [4]:
meandf.plot(kind='bar', title='The average per person')
Out [4]:

Movable Typeで記事を作成する時にフォーマットとして「改行を変換」を指定して、<blockquote>タグを使うと、blockquoteの引用部の前後に改行が入ったり入らなかったり、引用部の先頭や末尾に余計な改行が入ったりして見た目のバランスが悪くなることがしょっちゅう起こる。 再現条件もよくわからないし、CSSやブラウザによっても症状が変わったりする。

例えば、

XXXに
<blockquote>
YYY
</blockquote>
と書かれている。
と書くと、Movable Typeが出力するHTMLは
<p>XXXに<br>
</p><blockquote>
YYY<br>
</blockquote>
と書かれている。<p></p>
このようになる(Movable Type 7.5.0(r.4703)で確認)。blockquoteの前に余計な</p>があるし、blockquoteの後の<p>の位置がおかしい。Webブラウザで表示すると次のようになる(これがアンバランスな見た目になるかどうかは環境による)。
--------------------------------

XXXに

YYY
と書かれている。

--------------------------------

これを闇雲に改行を足したり減らしたりして修正しようとしても、滅多にうまくいかない。例えば、改行を全て取っ払って

XXXに<blockquote>YYY</blockquote>と書かれている。
とすると、
<p>XXXに</p><blockquote>YYY</blockquote>と書かれている。<p></p>
という結果になる。

本当に今更だが、突然思い出して気になったので、対策が無いか探してみた。

Webで探した結果、
MT6で<blockquote>を使う場合の書き方 - 継続は力なり!なのか?
に、MT6では<blockquote>タグの後ろと</blockquote>タグの前に空白行を入れれば良いという対策が書かれているのを見つけたが、MT7.5.0の筆者の環境では解決しなかった。

入力
XXXに
<blockquote>

YYY

</blockquote>
と書かれている。
出力
<p>XXXに<br>
</p><blockquote><p></p>

<p>YYY</p>

</blockquote>
と書かれている。
(入力の<blockquote>の前の改行や</blockquote>の後の改行を外しても出力はほぼ同じ)

Movable Typeのドキュメント

Movable Type r.4607 / 6.6.0 から、「改行を変換」による br タグ、p タグへの変換規則が変更になりました。
とあるので、この関係かも知れない。
Webでは他には対策を見つけられなかった。

とりあえず、Movable Typeのソースコードを少し覗いてみた。
おそらく、lib/MT/Util.pmのhtml_text_transformが問題の関数である。これとMT 6.6.0より前の仕様の実装であろうhtml_text_transform_traditionalとをざっと読んでみたが、対策は見つけられなかった。

わかったことはこれくらいであった。

  • <blockquote>〜</blockquote>の対応を考慮していることは無く、改行2つ(空行)で区切った段落の単位で処理して、段落の中に<blockquote>や</blockquote>があれば何か処理を分けているだけ
  • 段落の先頭が<blockquote>や</blockquote>であれば、その段落を<p>〜</p>で囲まない
  • 段落の最後の行以外で、行の最後が</blockquote>なら<br>を付けない
  • 段落が<blockquote>で始まり</blockquote>で終わらないなら、最後に<br><br>を付ける

大体、改行が<blockquote>や</blockquote>の前後に0個か1個か2個かで結果が変わる可能性がありそうなので、<blockquote>の前後、</blockquote>の前後計4ヶ所にそれぞれ改行文字が0個、1個、2個入れる全81パターンを試してみた。
その結果、出力されるHTMLがアンバランスでない(<blockquote>の前と</blockquote>の後、<blockquote>の後と</blockquote>の前が共に対称)のは以下の2パターンしか無かった。

  • (1) <blockquote>の前に改行2つ、</blockquote>の前に改行2つ
    入力
    XXXに
    
    <blockquote>YYY</blockquote>
    
    と書かれている。
    
    出力
    <p>XXXに</p>
    
    <blockquote>YYY</blockquote>
    
    <p>と書かれている。</p>
    
  • (2) <blockquote>の前後、</blockquote>の前後全てに改行2つ
    入力
    XXXに
    
    <blockquote>
    
    YYY
    
    </blockquote>
    
    と書かれている。
    
    出力
    <p>XXXに</p>
    
    <blockquote>
    
    <p>YYY</p>
    
    </blockquote>
    
    <p>と書かれている。</p>
    

(2)の出力は<blockquote>の後の<p>〜</p>が余計である。(1)の出力はblockquote部分とその前後が別の段落になるのが気になるが、上記の81通りの入力の中ではこれが一番ましである。(Movable Type 7.5.0(r.4703)での確認結果)

つまり、対策としては以下のように書くのが良さそうである。

  • <blockquote>の前と</blockquote>の後に空行を入れる
  • <blockquote>の後と</blockquote>の前は改行しない

Pandasのプログラムで、あるDataFrameから、関連する別のDataFrameに存在する値を含む条件に合う行を選択するコードの書き方を見つけるのに、結構時間が掛かった。

In [1]:
df1 = pd.DataFrame({
    'name': list('AAABBBCCC'),
    'val': range(1, 10)
})
df1
Out [1]:
name val
0 A 1
1 A 2
2 A 3
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9

こういうDataFrameと

In [2]:
df2 = df1.groupby('name').sum() + 1
df2.columns = ['val2']
df2
Out [2]:
val2
name
A 7
B 16
C 25

こういうDataFrameがあり、df1から"val2"の値が10より大きな行を選択したい、但しdf1とdf2とのmergeはワークメモリの都合でやりたくないとする。

df1とdf2をmergeして良いなら、やりたいことは次のことである。

In [3]:
df = df1.merge(df2, on='name')
df[df['val2'] > 10]
Out [3]:
name val val2
3 B 4 16
4 B 5 16
5 B 6 16
6 C 7 25
7 C 8 25
8 C 9 25

次のように書ければ筆者としては直観的なのだが、これはエラーになる。

In [4]:
df1[df2.loc[df1['name'], 'val2'] > 10]
Out [4]:
ValueError: cannot reindex from a duplicate axis

括弧内の"df2.loc[df1['name'], 'val2'] > 10"はdf1と同じ行数のboolean値を返すので、このままboolean indexingとして通してくれても良さそうなものである。

In [5]:
df2.loc[df1['name'], 'val2'] > 10
Out [5]:
name
A    False
A    False
A    False
B     True
B     True
B     True
C     True
C     True
C     True
Name: val2, dtype: bool

indexが問題とのことなので、次のようにすれば成功するが、無駄に複雑というか、そんな問題をいちいち意識したくない。

In [6]:
# 成功例1
df1[(df2.loc[df1['name'], 'val2'] > 10).reset_index(drop=True)]
Out [6]:
name val
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9

結局、Webで調べて試行錯誤しながら辿り着いたコードは、次のようにmapやapplyを使う形になった。

In [7]:
# 成功例2
df1[df1['name'].map(lambda x: df2.loc[x, 'val2'] > 10)]
Out [7]:
name val
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9
In [8]:
# 成功例3
df1[df1.apply(lambda df: df2.loc[df['name'], 'val2'] > 10, axis=1)]
Out [8]:
(上と同じなので省略)

処理時間は、筆者の環境では成功例1と成功例3が大差なく、成功例2が半分ほどだった。

筆者が実際に困ったケースでは、2つのDataFrameを結合するキー(上の例の"name")が複数の列からなっていた。

In [9]:
df3 = pd.DataFrame({
    'name1': list('AAAABBBB'),
    'name2': list('xxyyxxyy'),
    'val': range(1, 9),
})
df3
Out [9]:
name1 name2 val
0 A x 1
1 A x 2
2 A y 3
3 A y 4
4 B x 5
5 B x 6
6 B y 7
7 B y 8
In [10]:
df4 = df3.groupby(['name1', 'name2']).agg(sum) + 1
df4.columns = ['val2']
df4
Out [10]:
val2
name1 name2
A x 4
y 8
B x 12
y 16

こういうDataFrameがあり、df3から"val2"の値が5より大きな行を選択したい、但しdf3とdf4とのmergeはワークメモリの都合でやりたくない、というような問題である。
DataFrameにはmapメソッドが無いので、成功例2の延長では書けず、成功例3の延長で、MultiIndexのindexingに苦戦しながら、次のようなコードに行き着いた。

In [11]:
# 成功例3'
df3[df3.apply(lambda df: df4.loc[tuple(df[['name1', 'name2']]), 'val2'] > 5, axis=1)]
Out [11]:
name1 name2 val
2 A y 3
3 A y 4
4 B x 5
5 B x 6
6 B y 7
7 B y 8

しかし、ここまで複雑になるのなら、成功列1のような方法でも良いかなとも思えてきた。

In [12]:
# 成功列1'
df3[(df4.loc[df3[['name1', 'name2']].values.tolist(), 'val2'] > 5).reset_index(drop=True)]
Out [12]:
(上と同じなので省略)

筆者にはやはり成功例3'より美しくない上に一回りややこしく感じるが、筆者の環境では、成功例1'の処理時間は成功例3'の6割ほどだった。