目次

DataFrameの値の更新 - pandas

pandasは、DataFrameの値の取得方法がいろいろあるため、値の代入更新もつい「この書き方でいいんだっけ」と混乱する。

基本的には(一般的な代入と同じく)左辺で更新するデータ範囲を、右辺で値を指定するのだが、左辺のデータ範囲の指定方法が様々あるのに加え、右辺での値の指定にも複数方法がある。

df.loc[df['col1']==3, ['col2', 'col3']] = df['col4']

col1 が 3 である行の col2,col3 列を、ともに同行の col4 の値にする

大別すると以下の感じ。 左辺のアクセス関数に例えば配列を渡しても、関数の種類や配列の中身によって、名前か、添字か、どのように解釈されるか異なってくるのがややこしさの元となる。

※ここで、「名前」とは行や列に付けられた名称を差し、「添字」とは0から始まる連番(通常の配列の要素取得に使うもの)を指すものとする。

※配列は numpy.ndarray でもよい。

範囲の指定方法

データ範囲の指定方法は、以下のサイトがまとまっている。

loc, ilocは法則性がありわかりやすい。原則 loc[行, 列] の順で指定する。(bool配列以外は)locが名前、ilocが添字として解釈される。 意味を明確にコーディングしたい場合はこれらを使うのが良い。

対してgetitemは、なんとなくよく使う方で解釈されるため、便利な反面、行なのか列なのか時と場合で変わり、やや紛らわしい。

getitem loc iloc
単独の値
[●●]
または
カンマ区切りで1番目の値
[●●, ○○]
単一の値 ①列の名前として解釈
列をSeriesとして取得
②行の名前として解釈
1行のみのDataFrameとして取得
③行の添字として解釈
1行のみのDataFrameとして取得
bool配列 ④Trueの行のみのDataFrameとして取得
* dfの行数と同じ長さでないとエラー
その他配列 ⑤列の名前として解釈
DataFrameとして取得
* 列名が1つでも存在しないとエラー
⑥行の名前として解釈
DataFrameとして取得
* 行名が1つでも存在しないとエラー
⑦行の添字として解釈
DataFrameとして取得
* 1つでも範囲外だとエラー
slice
[s:t]
⑧行の名前として解釈
DataFrameとして取得
* s,tがともに列名に存在しないとエラー
* tを含む
⑨行の添字として解釈
DataFrameとして取得
* tを含まない
DataFrame ⑩下例参照、DataFrameとして取得
* 全要素がboolでないとエラー
* エラー
カンマ区切りで2番目の値
[○○, ●●]
単一の値 ①「(1番目の値, 2番目の値)というtuple」という単一の値として解釈
* ともにHashableな値で無いとエラー
⑪列の名前として解釈
Seriesまたは値として取得
⑫列の添字として解釈
Seriesまたは値として取得
bool配列 * エラー ⑬Trueの列のみのDataFrameとして取得
* dfの列と同じ長さでないとエラー
その他配列 ⑭列の名前として解釈
DataFrameとして取得
⑮列の添字として解釈
DataFrameとして取得
slice
[s:t]
⑯列の名前として解釈
* tを含む
⑰列の添字として解釈
* tを含まない

df
#     col1   col2   col3
# 10     1      2      3
# 20     4      5      6
# 30     7      8      9

df['col1']  # 1
#     col1
# 10     1
# 20     4
# 30     7

df.loc[20]  # 2
df.iloc[1]  # 3
#     col1   col2   col3
# 20     4      5      6

df[[True, False, True]]  # 4
df.loc[[True, False, True]]
df.iloc[[True, False, True]]
#     col1   col2   col3
# 10     1      2      3
# 30     7      8      9

df[['col1', 'col3']]  # 5
#     col1   col3
# 10     1      3
# 20     4      6
# 30     7      9

df.loc[[10, 30]]  # 6
df.iloc[[0, 2]]   # 7
#     col1   col2   col3
# 10     1      2      3
# 30     7      8      9

df[10:30]  # 8
df.loc[10:30]
#     col1   col2   col3
# 10     1      2      3
# 20     4      5      6
# 30     7      8      9

df.iloc[1:2]  # 9
#     col1   col2   col3
# 20     4      5      6

# 10
idf = pd.DataFrame([[True, False, True], [False, True, True]], index=[10, 20], columns=['col1', 'col2', 'dummy'])
#     col1   col2  dummy
# 10  True  False   True
# 20 False   True   True
df[idf]
#     col1   col2   col3
# 10     1    NaN    NaN
# 20   NaN      5    NaN
# 30   NaN    NaN    NaN

df.loc[10, 'col1']  # 11
df.iloc[0, 0]       # 12
# 1

df.loc[[10, 30], 'col1']  # 11
df.iloc[[0, 2], 0]        # 12
#     col1
# 10     1
# 30     7

代入値の指定方法

FIXME

SettingWithCopyWarning

pandasの値の更新は、'copy'と'view'の概念を知らないと、更新したはずなのにできていないなんて現象に悩まされる。これはNumPyに由来する概念で、

特に、DataFrameの一部のみを更新する際に混乱しやすい。

どのような処理がviewを返し、copyを返すのか、というのは複雑だが、なんとなくふわっと解釈するなら、更新は「唯一の抽出結果に直接代入しないといけない」。

#    col1  col2
# 0     1     3
# 1     2     5

df['col1'][df['col1'] == 2] = 100  # OK

df[df['col1'] == 2]['col1'] = 100  # SettingWithCopyWarning

df['col1'][df['col1'] > 1][df['col1'] < 3] = 100  # NG(特に警告も出ない)

df.loc[df['col1'] == 2, 'col1'] = 100  # OK

1番目の書き方、df['col1'] は、df中の1カラムのSeriesを返す。dfはこのSeriesを参照として持っているので、このSeriesに対する更新はdfにも反映される。Seriesに対して“df['col1'] == 2“に合致する行を抽出し、結果に直接代入しているので、OK。

2番目の書き方、df[df['col1'] == 2] は、まず”df['col1'] == 2“に合致する行を抽出している。この時点で元のdfとは別物となっていて、その後['col1']で取り出されるSeriesも、元のSeriesとは別物となる。それに対して更新しても、反映されない。

3番目も同様。df['col1'][df['col1'] > 1] までで別物となり、さらなる抽出結果に代入しても反映されない。

4番目のdf.loc[] は行列の条件を一度に指定して抽出する方法で、これは1回の抽出結果に直接代入しているのでOK。

pandasでは df[df['col1'] == 2]['col1'] = 100 のように、2回以上に分けて抽出した結果に何かを代入しても、元のdfの値は置き換わらない。

こういうコードを書くと警告が出る。

SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
self._setitem_with_indexer(indexer, value)

このことについては、詳しい海外記事と、それを日本語訳してくれている方がいるので、それを読むとよい。

上記を読めば下記の回避方法の説明は不要なのだが、まぁ、せっかくだし残しておこう。

回避方法

最初から行も列も1発で指定したオブジェクトに対する代入は、きちんと反映される。

#      col1  col2  col3
# 100     1     2     3
# 200     4     5     6
# 300     7     8     9

df.loc[df['col1'] > 5, ['col2', 'col3']] = 20
print(df)
# =>
#      col1  col2  col3
# 100     1     2     3
# 200     4     5     6
# 300     7    20    20

行と列を一度に指定してDataFrame内の要素を抽出する方法は、loc, iloc がある。 他にもatとかixとかあるが、柔軟に解釈してくれるおかげでさらに紛れが発生しやすいので、基本はこの2つだけ覚えておけばよい。

列は名前、行は添字で指定

locは「行も列も名前で指定する」、ilocは「行も列も添字で指定する」のどちらかしか無く、「列は名前で指定したいが、行は添字で指定したい」時に困る。

劇的な解決とはいかないが、以下の解決策がある。

#   名前で['col2', 'col3'] 列、添字で[1, 2]行目のデータを更新したい

# 【あらかじめ目的行の名前を調べてからlocを使う】
df.index                # df.indexで、行の名前の配列を取得
# => [100  200  300]
df.index[[1, 2]]        # df.indexへの添字アクセスにより2,3行目の名前を得る
# => [200  300]
df.loc[df.index[[1, 2]], ['col2', 'col3']] = 30  # これをlocに用いることで、目的が達成される
df
# =>
#      col1  col2  col3
# 100     1     2     3
# 200     4    30    30
# 300     7    30    30


# 【あらかじめ目的列のindexを調べてからilocを使う】
df.columns
# => ['col1', 'col2', 'col3']            # df.columnsで、列の名前の配列を取得
col2_idx = df.columns.get_loc('col2')    # 'col2', 'col3'のindexを得る
col3_idx = df.columns.get_loc('col3')

df.iloc[[1, 2], [col2_idx, col3_idx]] = 40  # これをilocに用いることで、目的が達成される
df
# =>
#      col1  col2  col3
# 100     1     2     3
# 200     4    40    40
# 300     7    40    40

DataFrameの複数columnを更新

更新元のDataFrameに更新先のDataFrameを代入する。Indexの一致した行が置換される。

import pandas as pd

# 更新元
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=['col1', 'col2', 'col3'])
print(df)
# =>
#    col1  col2  col3
# 0     1     2     3
# 1     4     5     6

# 更新先
upd_df = pd.DataFrame([[7, 8], [9, 10]])
print(upd_df)
# =>
#    0   1
# 0  7   8
# 1  9  10

# 代入
df[['col2', 'col3']] = upd_df
print(df)
# =>
#    col1  col2  col3
# 0     1     7     8
# 1     4     9    10

関数を適用して更新

(例)
  id col1 col2    -- col1, col2を -->     id col1 col2
0  0   10   10       2値の和と差で      0  0   20    0
1  1   20   15    --      更新    -->   1  1   35    5

import pandas as pd

# rowを受け取り、col1 と col2 の和と差をタプルで返すサンプル関数
apply_func = lambda row: (row['col1'] + row['col2'], row['col1'] - row['col2'])

# サンプルDataFrame
df = pd.DataFrame([[1, 20, 10], [2, 10, 9], [3, 11111, 9999]])
df.columns = ['id', 'col1', 'col2']
print(df)
# =>
#    id   col1  col2
# 0   1     20    10
# 1   2     10     9
# 2   3  11111  9999

# DataFrameの各rowにapply_func関数を適用
trans = df.apply(apply_func, axis=1, reduce=False)
print(type(trans), trans.shape)
print(trans)
# =>
# <class 'pandas.core.frame.DataFrame'> (3, 2)
#        0     1
# 0     30    10
# 1     20     1
# 2  21110  1112

# 元のDataFrameに代入
df[['col1', 'col2']] = trans
print(df)
# =>
#    id   col1  col2
# 0   1     30    10
# 1   2     20     1
# 2   3  21110  1112

# 1行にまとめると、
df[['col1', 'col2']] = df.apply(apply_func, axis=1, reduce=False)

IDごとにはじめて○○する時の値で更新

      a    b   id
1    10    6    1
2     6   -3    1
3    -3   12    1 # id:1 ではじめてbが10以上になる
4     4   23    2 # id:2 ではじめてbが10以上になる
5    12   11    2  
6     3   -5    2
          ↓
      a    b   id   c
1    10    6    1  -3  # 各idではじめてbが10以上になったレコードの
2     6   -3    1  -3  # aの値を持つ、カラムcを追加する
3    -3   12    1  -3
4     4   23    2   4
5    12   11    2   4
6     3   -5    2   4

# bが10以上のレコードのみを抽出後、groupby()して、各id最初のレコードを得る
fdf = df[df['b'] >= 10].groupby('id').first()

# カラム名リネーム
fdf.rename(columns={'a': 'c'}, inplace=True)

# idをキーにマージする
df = pd.merge(df, fdf['c'], how='left', left_on='id', right_index=True)

但し、あるidに条件を満たすレコードが1つも無かった場合、そのidのカラムcはnanになる。

その他小ネタ

inplaceは効かない

DataFrameやSeriesには、replace, fillna, interpolate など値を埋めたり変換したりする便利な関数が備えられている。

これらは基本的には新しいDataFrameやSeriesを返すが、引数にinplace=Trueを与えると、直接変更するようになる。

だが、locで抽出したものには、たとえ唯一の抽出結果であっても、inplace=Trueは効かない。

# × 更新されない
df.loc[[1,2,3], 'b'].fillna(0, inplace=True)

# ○ 更新したければ、inplaceを使わず代入を用いる
df.loc[[1,2,3], 'b'] = df.loc[[1,2,3], 'b'].fillna(0)

# ○ 単独列を丸ごと取り出したものは効く
df['a'].fillna(0, inplace=True)

# × 複数列を取り出したものは効かない
df[['a', 'b']].fillna(0, inplace=True)

巨大なDFに対する更新はなるべく一度に

数百~数千万行のDataFrameに対して更新をかけるのは、かなりコストの重い操作となる。たとえ更新する範囲が一部であっても、その場所の特定に時間がかかる。

もし、groupby() などの分割結果毎に何らかの集計処理し、元のDFに結果を反映させたい場合でも、毎回、元のDFを更新していては相当時間がかかる。
(そもそもgroupbyごとにforループ回すのも速度的によろしいことではないが。。。関数的に書けない処理が必要になることもあるので仕方ない)

そんな時、即時更新はせずリストに溜めて、更新は最後に(メモリが厳しいならある程度溜まった後に)行えば、高速化に繋がる。

下記は一例だが、もっと速い方法もあるかも知れない。(例なので処理内容には特に意味は無い)

# 'AAA' ごとに 'BBB' カラムを 'CCC' の2倍の値で更新
for i, grouped_df in df.groupby('AAA'):
    # なんか処理する
    # 毎回、元のDFを更新する → 遅い
    df.loc[grouped_df.index, 'BBB'] = grouped_df['CCC'] * 2

# 'AAA' ごとに 'BBB' カラムを 'CCC' の2倍の値で更新
buf = []
for i, grouped_df in df.groupby('AAA'):
    # なんか処理する
    # とりあえずバッファに溜める
    buf.append(grouped_df['CCC'] * 2)

# 最後に更新する
update_sr = pd.concat(buf)  # DataFrameを全部つなげて
update_sr.sort_index(inplace=True)  # indexは整列されてた方が速い
df.loc[update_sr.index, 'BBB'] = update_sr

# 'AAA' ごとに 'BBB1' と 'BBB2' を、何らかの値で更新

update_data = []
update_indices = []
for i, grouped_df in df.groupby('AAA'):
    # なんか処理する
    
    # 更新用numpy配列を作る
    n = len(grouped_df)
    si = grouped_df.index[0]
    ti = grouped_df.index[-1]
    
    update_table = np.zeros((n, 2))
    update_table[:, 0] = 長さnのBBB1を更新したい値
    update_table[:, 1] = 長さnのBBB2を更新したい値
    
    # 蓄積する
    update_data.append(update_table)
    update_indices.extend(range(si, ti + 1))

# 最後に更新する
update_table = np.concatenate(update_data, axis=0)  # 全部縦に繋げる
df.loc[update_indices, ['BBB1', 'BBB2']] = update_table