Tipsと注意点#

in English or the language of your choice.

# 警告メッセージを非表示
import warnings
warnings.filterwarnings("ignore")

書式について#

コードを書く際に以下を注意すること。

  1. 分かりやすい変数名を使う。

    • 具体的な内容を示す変数名は可読性を高める。例えば、インフレ率をinflation、インフレ率の変化をinf_change。また単位を含めるとより分かりやすくなる。例えば、kgpercentvar1var2と書くと、短くて書きやすいが、他の人や数ヶ月後の自分自身が読み返す時に非常に読み難いコードとなる。

  2. 変数や関数の命名規則

    • 名は小文字とアンダースコア_を使う(スネーク・スケールと呼ばれる)。

    • 例えば、inflation_rateと書き、InflationInflationRateは避ける。大文字で始まる変数は、慣例としてクラスと呼ばれるオブジェクトに使う。

  3. エディット・モードのセルでコメントを入れる。

    • 例えば、# これはコメントです。

    • 目的:可読性を高める。何を書いているかを確認しながらコードを書くことができる。

    • 行頭に#を挿入することをコメント・アウト(comment out)という。コメント・アウトされたコードは実行されない。

    • (Tip)行頭に#を挿入または削除するトグルのショートカット。

      • Macの場合、トグルしたい行にカーソルを置きCommandを押したまま/を押下する(Cmd+/)。複数行をトグルしたい場合は、それらをハイライトしCmd+/

      • Windowsの場合、トグルしたい行にカーソルを置きControlを押したまま/を押下する(Ctrl+/)。複数行をトグルしたい場合は、それらをハイライトしCtrl+/

  4. インデントを駆使して可読性を高める。(以下の例で改行は必要ないかもしれないが、1行が長い場合に役立つ)

    • ()の間は改行してもエラーにならない。

      my_func(a = 10,
              b = 5,
              c = 0)
      
    • 改行したい箇所に\を入れる。(Jupyter Notebookでは{}の間に改行を入れてもエラーにならない)

      my_dict = {a:10, \
                 b:5,  \
                 c:[1,2]}
      
  5. スペースや行間を開け読みやすくする。

    # 悪い例
    def func(x,y):
        return x*100+y
    
    # 良い例
    def meter_and_centimeter_to_centimeter(meter, centimeter):
        return meter * 100 + centimeter
    

スコープ#

スコープとは、変数が所属し直接アクセスできるコードの中の「領域」を示す。類似する概念に名前空間(Namespace)もあるが、スコープの情報を記す「表」のことであり、スコープ(Scope)と同義と理解すれば良い。

ここでは基本的に以下のように理解すれば良いであろう。

  1. Jupyter Notebookを開始した時点からglobalスコープが始まる。

  2. 関数を定義すると、その関数の範囲内でlocalスコープが生成される。

  3. 関数の中で入れ子の関数を定義すると、その入れ子関数の範囲内でより狭いlocalスコープが生成される。

  4. globalスコープで定義された変数は、localスコープ(入れ子関数のより狭いlocalスコープも含む)からアクセスできるが、globalスコープからlocalスコープ(入れ子関数のより狭いlocalスコープも含む)の変数にはアクセスできない。

  5. 関数内のlocalスコープで定義された変数は、入れ子関数のより狭いlocalスコープからアクセスできるが、親関数のスコープから入れ子関数の変数にはアクセスできない。

次の例を考えよう。

例1#

s = "Kobe University"  # globalスコープ

def func_1():
    s = "神戸大学"  # localスコープ
    print(s)
print(s)
Kobe University
func_1()
神戸大学

例2#

def func_2():
    a_local = 3
    print(a_local)

func_2()
3

globalスコープからa_localをアクセスすると次のエラーが発生する。

a_local
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 a_local

NameError: name 'a_local' is not defined

教訓#

関数内で使う変数は関数内で定義する方が意図しない結果につながるリスクを軽減できる。この点を説明するために次の例を考えよう。

a = 10

def func_3(x):
    return a + x

func_3(2)
12

知らずに以下を設定していたとしよう。

a = 1000
func_3(2)
1002

このような間違いは関数内で変数を定義することで回避できる。

def func_4(x):
    a = 10
    return a + x

func_4(2)
12

浮動小数点型について#

表記#

Pythonでは指数表現がよく使われる。

5.123e2\(= 5.123\times 10^{2}\)

5.123e-2\(= 5.123\times 10^{-2}\)

この例で5.123の部分は「仮数」(mantissa もしくは significand)と呼ばれる。

精度#

\(1/3\)を考えよう。

\(\dfrac{1}{3}=0.33333333333333333\cdots\cdots\cdots\)

となり永遠に3が続く。コンピュータのメモリの容量は有限であるため、無限に続く3をメモリに保存しすることは不可能である。従って、どこかで切断して\(1/3\)の近似値を保存する必要がある。これと同じようなことをPythonは行っている。10進法の数字を2進法に変換し、計算結果を10進法に変換し表示する。その際、多くの数字は\(1/3\)と同じように近似値となる。その結果、誰でもおかしいと思う結果が発生することになる。

0.3 == 0.1 + 0.1 + 0.1
False

0.30.1と表示されているが、実際には近似値で計算している。その近似値を表示すると

format(0.3, '.55f')
'0.2999999999999999888977697537484345957636833190917968750'

ここで'.55f'は小数点以下55桁を表示させる引数。同様に、

format(0.1, '.55f')
'0.1000000000000000055511151231257827021181583404541015625'

また、この問題は値が小さな場合だけではなく、大きい値の場合にも類似する問題がある。

2.0**52
4503599627370496.0
2.0**52 == 2.0**52 + 1
False

この結果は期待どおりであるが、次の例はそうではない。

2.0**53
9007199254740992.0
2.0**53 == 2.0**53 + 1
True

これは浮動小数点型で正確に表示できる最大の10進数桁が約15であるために発生する。従って、浮動小数点型に==を使い真偽を確かめるのは危険である。

解決策としてnumpyisclose()関数を使うことができる。この関数は2つの値が十分に近い場合はTrueを返す。では、どれだけ近い場合にTrueを返すのか?詳細はマニュアルを参照。

import numpy as np
np.isclose(0.3, 0.1+0.1+0.1)
True

一方、2.0**53の例は桁の制限に達しているため対応不可能である。ちなみに、整数だとこのような問題は発生しない。

2**53 == 2**53 + 1
False

変数とオブジェクト#

変数とオブジェクトの関係#

Pythonでは「全てがオブジェクト」と言われるが、ここでのオブジェクトとは属性データ(値や性質など)とメソッド(変数特有の関数))から構成される集合体(「塊り」)を指す。例えば、数値0.1のオブジェクトを考えよう。dir()を使うとどのような属性から構成されているか表示される。

dir(0.1)
['__abs__',
 '__add__',
 '__bool__',
 '__ceil__',
 '__class__',
 '__delattr__',
 '__dir__',
 '__divmod__',
 '__doc__',
 '__eq__',
 '__float__',
 '__floor__',
 '__floordiv__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getformat__',
 '__getnewargs__',
 '__getstate__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__int__',
 '__le__',
 '__lt__',
 '__mod__',
 '__mul__',
 '__ne__',
 '__neg__',
 '__new__',
 '__pos__',
 '__pow__',
 '__radd__',
 '__rdivmod__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__rfloordiv__',
 '__rmod__',
 '__rmul__',
 '__round__',
 '__rpow__',
 '__rsub__',
 '__rtruediv__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__sub__',
 '__subclasshook__',
 '__truediv__',
 '__trunc__',
 'as_integer_ratio',
 'conjugate',
 'fromhex',
 'hex',
 'imag',
 'is_integer',
 'real']

例えば、

  • realは数値の実数部を示す属性であり、0.1の実数数値データである。

  • as_integer_ratioは有理数として表すメソッドであり、(分子、分母)のタプルとして返す。

(0.1).real
0.1
(0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

上の例では、=を使いaxなどの変数に「代入」していないことに注意しよう。これは変数がなくても0.1はオブジェクトとして存在していることを示している。では、変数とオブジェクトにはどのような関係があるのだろう。変数aを導入するために次のように書いてみよう。

a = 0.1
a.real, a.as_integer_ratio()
(0.1, (3602879701896397, 36028797018963968))

以前、次の説明をした。

多くの品物が保管されている大きな倉庫を考えてみよう。管理者はどの品物がどこに保管されているかを記録するための在庫リスト(記録帳やコンピューター・ファイル)を作成し、そこに品物が保管されている棚を示す記号(や番号)を記入しているとしよう。この例では、

  • オブジェクト → 倉庫の棚に保管されている品物

  • 変数 → 在庫リストに記載されている棚の記号

となる。即ち、変数は品物の実態とは異なる単なる「参照記号」なのである。

上の例の0.1がPCのメモリに保存された「品物」であり,aは参照記号である。その参照記号を使って,0.1の属性realにアクセスしたり,メソッドas_integer_ratio()を使って有理数として表示している。

実は、この考え方(品物と参照記号の関係)は関数の名前とオブジェクトとしての関数の関係にも当てはまる。変数とオブジェクトのこのような関係により、関数がメソッドや関数の引数になることが可能となり、関数の返り値にもなり得るのである。

オブジェクトとメモリー・アドレス#

Pythonのオブジェクトをもう少し考えてみよう。オブジェクトはコンピューター内のメモリに保存され、直ぐに計算などに使えるように準備されている。例えば、0.1というオブジェクトを生成してみる。

a = 0.1

コンピューター・メモリーには住所のようにロケーションを示すメモリー・アドレス(以下では「アドレス」と呼ぶ)があり、id()関数を使うとそれを表示することができる。

id(a)
4430344176

2つのオブジェクトが同一のものかどうかを確認するには、このアドレスを比べれば良い。例えば、aと同じオブジェクトを示す別の参照記号bを生成しよう。

b = a
id(b)
4430344176

アドレスが同じなので同じオブジェクトであることが確認できる。また、アドレスが等しいかどうかはisを使って確認することもできる。

a is b
True

==ではないことに注意しよう。==10という値が同じかどうかを確認するだけで、アドレスとは無関係である。

c = 0.1
id(c)
4425056944
c is a, c == a
(False, True)

整数では\([-5,256]\)以外この結果が成り立つ。\([-5,256]\)は処理速度を最適化するために、Pythonのセッションが始まると同時に自動でメモリーにキャッシュ(保存)されるためアドレスは同じになる。例えば、次のdeは値もアドレスも同じである。

d = 1
e = 1
d is e, d == e
(True, True)

次に関数を考えてみよう。

def func(x):
    return x**2

func(2)
4

この関数のアドレスもid()を使い確認できる。

id(func)
4446095136

<注意>

  • id(func())とするとエラーが発生する。()を付けると「関数を評価する」ためである。

  • id(func(2))とするとエラーは発生しないが、func(2)を評価した結果(即ち、4)のアドレスを表示することになる。

  • funcは関数の「参照記号」であり、それを引数とすることにより関数のアドレスが表示される。

「浅いコピー」と「深いコピー」#

オブジェクトと変数の関係は、倉庫に保管されている商品と参照番号の関係と同じだと説明した。このような関係により、次のような問題が発生する。

f = [1,2,3]
f
[1, 2, 3]

リストfのコピーであるgを作成する。

g = f

gfは同じオブジェクトであり、isで確認できる。

g is f, id(g), id(f)
(True, 4445895872, 4445895872)

即ち、gfは別の参照記号ではあるが同じ品物を指している。ここで、fにメソッドappend()を使って100を追加しよう。

f.append(100)
f
[1, 2, 3, 100]

gはどうなっているかというと

g
[1, 2, 3, 100]

fgは同じ品物を指している参照番号なので、このような結果となる。

100が追加された後のアドレスを表示してみよう。

f is g, id(f), id(g)
(True, 4445895872, 4445895872)

100が追加される前のアドレスと同じである。即ち、append()は元のオブジェクトを変化させている。このようなことが発生することを念頭に置いてf=gのようなコードを書くべきであり、このことを忘れると意図しない結果につながる。このようなリスクを回避するためには、同じ数値のコピーを異なるオブジェクトとして生成する必要がある。そのためにはメソッド.copy()を使う。

f_copy = f.copy()
f_copy
[1, 2, 3, 100]
f_copy is f
False

.copy()で生成したコピーは 「浅いコピー」 (shallow copy)と呼ばれる。しかし、浅いコピーの場合でも、リストが二重配列(入れ子)になっている場合は上で説明した同じ問題が発生する。その場合には、標準ライブラリcopydeepcopy関数を使い独立したコピーを作ることが可能となる。これを 「深いコピー」 (deep copy)呼ぶ。

from copy import deepcopy
f_deep = deepcopy(f)
f_deep
[1, 2, 3, 100]

<以下の場合にリストの浅いコピーが生成される>

  1. メソッド.copy()によるコピー

  2. list()関数によるコピー

  3. リストのインデクシング(indexing)およびスライシング(slicing)によるコピー

# list()による浅いコピー
ff1 = list(f)
ff1, ff1 is f
([1, 2, 3, 100], False)
# インデクシング
ff2 = f[1]
ff2, ff2 is f
(2, False)
# スライシング
ff3 = f[:]
ff3, ff3 is f
([1, 2, 3, 100], False)

NumPy:ビューとコピー#

リストの「浅いコピー」と「深いコピー」の問題はNumPyにも存在する。しかし、これもnp.copy()関数やarray型のメソッド.copy()で回避することができる。また類似する問題としてコピー(copy)とビュー(view)の違いがある。以下ではこの問題を説明する。

import numpy as np
arr = np.array([1,2,3,4,5])
arr
array([1, 2, 3, 4, 5])

これをスライスして異なる変数を参照記号として使う。

arr_slice = arr[1:3]
arr_slice
array([2, 3])

arr_slice2200に入れ替える。

arr_slice[0] = 200
arr_slice
array([200,   3])

arrを表示しよう。

arr
array([  1, 200,   3,   4,   5])

arr_sliceを変更したにも関わらず、arrも影響を受けている。これはarr_slicearrが参照している元のデータが同じであるために発生する。即ち、arrは元のデータ全てを表示し、arr_sliceは同じデータの一部を表示しているのである。arr_slicearrのビュー(view)と呼ぶ。

Warning

arr[0] is arr_slice[1]としてもFalseとなる。リストと異なり、np.arrayではそれぞれの要素がメモリーに配置されていないため、indexingでアクセスする度に結果をメモリーに配置することになる。したがってarr[0]でアクセスするとその結果があるアドレスに配置され、続くarr_slice[1]は別のアドレスに保存されることになりFalseが返される。


ではどのような場合にviewsになるのか。残念ながら、複雑な場合分けが必要になり、それを覚え使いこなすにはある程度の慣れが必要だる。従って、viewをどうしても使わなくてはならない状況を除いては、以下のようにメソッド.copy()を使い、コピーを生成して作業することを強く推奨する。.copy()は「深いコピー」を生成する。

arr_copy = arr[1:3].copy()
arr_copy
array([200,   3])

arr_copy3300に入れ替える。

arr_copy[1] = 300
arr_copy
array([200, 300])
arr
array([  1, 200,   3,   4,   5])

arrは影響を受けていない。

<コメント>

viewの役目はなんなのか、という疑問が生じる。.copy()は新たにオブジェクトを生成するが、viewであれば新たなオブジェクトを生成する必要がない。その分、処理速度が早くなるのである。しかし、実証分析で行う「通常」のデータ処理であれば.copy()を使って問題ないだろう。

Pandas:ビューとコピー#

NumPyと同じようにPandasもビュー(view)とコピー(copy)の問題が存在する。確認するために、次のコードを考えよう。

import pandas as pd
df = pd.DataFrame({'a':np.arange(4), 'b':np.arange(4)})
df
a b
0 0 0
1 1 1
2 2 2
3 3 3
df_slice = df.iloc[1:3,:]
df_slice
a b
1 1 1
2 2 2

.iloc[]によりviewが生成されている。

それを確認するために元のDataFrameであるdfの要素を変更する。

df.iloc[1,1] = 100
df
a b
0 0 0
1 1 100
2 2 2
3 3 3
df_slice
a b
1 1 100
2 2 2

ここではスライシングで生成した場合を考えたが、他の場合でもviewになったりcopyになったりする。実は、NumPyと同様に場合わけが複雑だが、NumPyの場合よりもviewなのかcopyなのかの判別が非常に難しい。しかし、PandasviewかもしれないDataFrameSeriesに変更を加えると警告を出す仕組みになっている。df_sliceを使って説明する。

df_slice.iloc[1,0] = 200

とすると次のような警告が出る。

/Users/my_name/anaconda3/envs/py4etrics/lib/python3.7/site-packages/ipykernel_launcher.py:1: 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/user_guide/indexing.html#returning-a-view-versus-a-copy
  """Entry point for launching an IPython kernel.

この警告が出る場合は、viewの疑いが高い(確定ではない)ので、dfのメソッド.copy()を使って「深いコピー」を生成することを薦める。以下の例を考えよう。

df_copy = df.copy().iloc[1:3,:]
df_copy
a b
1 1 100
2 2 2
df.iloc[2,1] = 2000
df
a b
0 0 0
1 1 100
2 2 2000
3 3 3
df_copy
a b
1 1 100
2 2 2
df_slice
a b
1 1 100
2 2 2000

df_copyは影響を受けていないが、df_sliceの値は元のデータの変化を反映している。