静かなる名辞

pythonとプログラミングのこと

2019/03/22:TechAcademyがteratailの質問・回答を盗用していた件
2019/03/26:TechAcademy盗用事件 公式発表と深まる疑念



Ubuntu 18.04 LTSにvenvでミニマムなPython3.7仮想環境を構築

概要

 まっさらなパソコンを開発環境として立ち上げることになり、表題の通りのことをやる必要があったのでまとめておきます。

 venvを使うつもりなので、作業量としては少ないはずです。

Python3.7の導入

 Ubuntu 18.04はデフォルトでpython2が導入されています。また、python3もありますが、バージョンは3.6です。

 3.7くらいを使いたいので、必要そうなものをすべて突っ込んでおきます。

 とりあえず以下をターミナルから実行(ぶっちゃけ必要なものの抜けとかはあるかもしれません。気づいたら反映します)。

$ sudo apt install tk-dev python3.7 python3.7-dev python3.7-tk python3.7-distutils python3.7-venv

 pipはget-pip.pyを落としてきて入れます。

$ curl -kL https://bootstrap.pypa.io/get-pip.py | sudo python3.7

 あと、wheelくらいは入れておくか。

$ sudo python3.7 -m pip install wheel

venvで仮想環境を構築

 なにも考えずにやるとトラブりやすいので、注意が必要です。とりあえずドキュメントの上半分くらいを全部見ておいてください。

venv --- 仮想環境の作成 — Python 3.7.3 ドキュメント

 コマンドとしては、これでやってみます。

$ mkdir ~/.venvs
$ python3.7 -m venv --copies  ~/.venvs/e371

 --copiesは強く推奨します(詳しく書きたくないけど、昔--symlinksにしてひどい目に遭いました)。お名前とかはお好みで。この記事ではこれで説明しますが、自分が決めた名前に合わせて書き換えてください。

 できていることを確認します。

$ source ~/.venvs/e371/bin/activate # (e371)のような文字列がプロンプトの先頭に付けばOK
$ python # python3.7が立ち上がればOK
$ pip show pip # さっきのインストール先のpipが見つかればOK
$ deactivate # 抜けられればOK

 そしたら.bashrcとかに以下を書きます。

alias ve37="source ~/.venvs/e371/bin/activate"

 保存した後にsource ~/.bashrcなどで読み込み、ve37コマンドで仮想環境が有効になれば問題ありません。

パッケージの導入

 各自行ってください。

まとめ

 venvで仮想環境を組んでみました。とても簡単で余計なツールなどを入れる必要もなく、だいたい無難に作れるのがいいところです。

【python】pandasのto_sqlを試してみる

はじめに

 気軽にDataFrameをデータベーステーブルに変換できそうなto_sqlなるものがあるので、試してみます。

pandas.DataFrame.to_sql — pandas 0.23.4 documentation

sqliteを使いたかった

 ドキュメントではSQLAlchemyを使ってSQLiteを叩いているようですが、SQLAlchemy*1は素人なので、ここでのファイルの指定方法がいまいち謎です。

 とりあえず、

データベースエンジン — SQLAlchemy 0.6.5 ドキュメント (和訳)

 をざっと眺めて適当にやったら、エラーが出ました。

sqlalchemy.exc.ArgumentError: Invalid SQLite URL: sqlite://test.db
Valid SQLite URL forms are:
 sqlite:///:memory: (or, sqlite://)
 sqlite:///relative/path/to/file.db
 sqlite:////absolute/path/to/file.db

 あ、なるほど、相対パスならスラッシュ3つ、絶対パスならスラッシュ4つにするんですね。すごくどうでも良いところでハマりました。

実行して確認してみる

 何はともあれ、こんなコードを実行します。

import pandas as pd
from sqlalchemy import create_engine

df = pd.DataFrame({"A":["hoge", "fuga"],
                   "B":[1, 2],
                   "C":[1.0, 2.0]})

engine = create_engine('sqlite:///test.db', echo=False)
df.to_sql("df1", engine)

 lsしてtest.dbが生成されたことを確認したら、コンソールからsqliteでつなぎます。

$ sqlite3 test.db
sqlite> .tables
df1
sqlite> .schema df1
CREATE TABLE df1 (
	"index" BIGINT, 
	"A" TEXT, 
	"B" BIGINT, 
	"C" FLOAT
);
CREATE INDEX ix_df1_index ON df1 ("index");
sqlite> select A, B, C from df1;
hoge|1|1.0
fuga|2|2.0

 まずはできてるっぽくて安心。型はよくわからないけど、こんなものだと思います。

複数回実行

 問題になりそうなのは複数回呼んだときにどうなるかです。DataFrameを更新してテーブルにも反映する・・・といった処理を想定しています。

 デフォルトの挙動は「エラーになる」です。

ValueError: Table 'df1' already exists.

 ただしまったく使えないという訳ではなく、ドキュメントに書いてあることですが、

if_exists : {‘fail’, ‘replace’, ‘append’}, default ‘fail’

 というパラメータがあって、これで調整できます。

 dfの定義を書き換えて、if_exists="replace"にして実行してみます。

import pandas as pd
from sqlalchemy import create_engine

df = pd.DataFrame({"A":["aa", "hoge", "fuga"],
                   "B":[-100, 1, 2],
                   "C":[-100.0, 1.0, 2.0]})

engine = create_engine('sqlite:///test.db', echo=False)
df.to_sql("df1", engine, if_exists="replace")

 実行してから中身を見ます。

$ sqlite3 test.db
sqlite> select A, B, C from df1;
aa|-100|-100.0
hoge|1|1.0
fuga|2|2.0

 テーブルの形が変わっても同様のことができます。

import pandas as pd
from sqlalchemy import create_engine

df = pd.DataFrame({"A":["aa", "hoge", "fuga"],
                   "B":[-100, 1, 2],
                   "C":[-100.0, 1.0, 2.0],
                   "D":[1, 2, 3]})

engine = create_engine('sqlite:///test.db', echo=False)
df.to_sql("df1", engine, if_exists="replace")
sqlite> select A, B, C, D from df1;
aa|-100|-100.0|1
hoge|1|1.0|2
fuga|2|2.0|3

 一回消して作り直しているのと同じようなものと考えるべき・・・でしょうか。

 続けてappendを試します。

append: Insert new values to the existing table.

 いまいちよくわからない説明なので、念の為に新しいテーブルを作って試します。

import pandas as pd
from sqlalchemy import create_engine

df = pd.DataFrame({"A":["aa", "hoge", "fuga"],
                   "B":[-100, 1, 2],
                   "C":[-100.0, 1.0, 2.0],
                   "D":[1, 2, 3]})

engine = create_engine('sqlite:///test.db', echo=False)
df.to_sql("df1", engine, if_exists="append")
sqlite> select * from df2;
0|aa|-100
1|hoge|1

 一回目は普通。

 もう一回実行すると、下みたいになります。

sqlite> select * from df2;
0|aa|-100
1|hoge|1
0|aa|-100
1|hoge|1

 なんとなく納得しました。

 列を追加すると、どうなるんでしょうね。

import pandas as pd
from sqlalchemy import create_engine

df = pd.DataFrame({"A":["aa", "hoge"],
                   "B":[-100, 1],
                   "C":[0.1, 0.2]})

engine = create_engine('sqlite:///test.db', echo=False)
df.to_sql("df2", engine, if_exists="append")

 なんとなく予想していたことですが、エラーを吐かれました。

sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) table df2 has no column named C
[SQL: INSERT INTO df2 ("index", "A", "B", "C") VALUES (?, ?, ?, ?)]
[parameters: ((0, 'aa', -100, 0.1), (1, 'hoge', 1, 0.2))]
(Background on this error at: http://sqlalche.me/e/e3q8)

 便利に変更部分だけ反映してくれたりはしないので、使いみちは限られると言えます。それがしたければif_exists="replace"の方が良いのですが、頻繁にやると遅くなるのでなにか考えた方が良いでしょう。

まとめ

 ちょっと微妙・・・

 使い方次第でしょうね。pandasデータフレームを何も考えずにデータベースに突っ込めるので、そういうことをしたいときに重宝するでしょう。だけど、積極的にデータベースと連携させるために使うかというと、それは違う気がします。

*1:ところで、こういうタイピングに苦痛を伴う命名はやめてほしい

numpy配列に文字列を格納した場合の型と挙動

 numpy配列に文字列を格納した場合、どう扱われるのか知らなかったので、調査してみました。

 まず基本。

>>> import numpy as np
>>> a = np.array(["a", "b"])
>>> a
array(['a', 'b'], dtype='<U1')
>>> type(a[0])
<class 'numpy.str_'>

 配列そのものは「<U1」なる型になります。要素を取り出すとnumpy.str_という型です。要素の型は別にどうでも良いのですが、中身の実体は何なのでしょうか。

 ドキュメントにはイマイチ親切な説明がありませんが、こちらなどに一応書いてあります。

The basic string format consists of 3 parts: a character describing the byteorder of the data (<: little-endian, >: big-endian, |: not-relevant), a character code giving the basic type of the array, and an integer providing the number of bytes the type uses.
(中略)
U Unicode (fixed-length sequence of Py_UNICODE)

The Array Interface — NumPy v1.16 Manual

 リトルエンディアンの長さ1(固定長)のユニコード文字列のようです。

 固定長ということなので、いろいろやってみます。

>>> import sys
>>> a = np.array(["a"]*1000)
>>> sys.getsizeof(a)
4096
>>> a = np.array(["a"]*10000)
>>> sys.getsizeof(a)
40096

 96バイトのおまけが付いてきますが、基本的には1要素あたり4byte=32bitで表現するようです。内部的にUCS4で表現されているから、ということだと思います。

 つまり、ASCIIもマルチバイトも同じ大きさで表現できます。

>>> a = np.array(["あ"]*10000)
>>> sys.getsizeof(a)
40096

 固定長フォーマットなので、文字数を増やすとお察しの通りの現象が発生します。更にお気付きの通り、一番長さが大きい要素に引っ張られます。

>>> a = np.array(["aあ"]*10000)
>>> sys.getsizeof(a)
80096
>>> a = np.array(["a"]*9999 + ["aあ"]*1)
>>> sys.getsizeof(a)
80096
>>> a = np.array(["a"]*9999 + ["aiueoあいうえお"]*1)
>>> sys.getsizeof(a)
400096

 メモリ効率は悪いです。まあ、numpyというのはそもそも固定データサイズ前提の設計になっているので、仕方ありません。

 型のことはわかったので、いろいろな操作をしてみます。

 まず、indexingを試してみましょう。

>>> a = np.array(["ab", "cd", "ef"])
>>> a[0, 1]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: too many indices for array
>>> a[0][1]
'b'

 C言語のcharの2次元配列みたいな実装になっていることは容易に想像できるのですが、numpy配列としてはndim==1なので、都合よく要素を取り出せたりはしません。また、代入してmutableな文字列として使うというのも不可能なようです。

 通常の演算などが行えるかを確認します。

 復習しておくと、素のpythonの文字列では

  • 文字列同士の加算
  • 文字列とintの掛け算

 のみが行えるはずです。

>>> a = np.array(["a", "b", "c"])
>>> b = np.array(["あ", "い", "う"])
>>> a + b
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: ufunc 'add' did not contain a loop with signature matching types dtype('<U1') dtype('<U1') dtype('<U1')
>>> a * 3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: ufunc 'multiply' did not contain a loop with signature matching types dtype('<U3') dtype('<U3') dtype('<U3')

 numpyの内部表現そのままの型だと演算は受け付けてくれないようです。残念。

 object型(要するにただのstrということだと思う)に変換すれば、いけるようです。

2つの文字列配列の要素同士を連結する(文字列配列の足し算) - Pythonのメモ帳

>>> a.astype(object) + b.astype(object)
array(['aあ', 'bい', 'cう'], dtype=object)
>>> a.astype(object) * 3
array(['aaa', 'bbb', 'ccc'], dtype=object)

 ということなので、全体を通して最初からobject型で配列を作っておけば良いと思いました。逆に、numpyの内部表現で持っておくメリットはあるのでしょうか・・・。

【python】threadingでsleep中に即座にスレッドを止める

 pythonのスレッド活用というと、こんなコードがすぐに思い浮かびます。

# エンターされるまでは数字を更新して、
# エンターされたら終了する(つもり)

import time
import threading

flag = True

def th():
    i = 0
    while flag:
        print("\r{}:".format(i), end="")
        i += 1
        time.sleep(3)

th = threading.Thread(target=th)
th.start()
input()
flag = False

 これは期待と違う動作になります(エンター押してから最大3秒後にならないと終了しない)。sleep中に条件の判定をしてくれる訳はないからです。

 どうやって意図通りの動作にするかというと、threading.Eventを使うと楽です。

docs.python.org

import threading

def th():
    i = 0
    while True:
        print("\r{}:".format(i), end="")
        i += 1
        if event.wait(timeout=3):
            # ここにイベントが発生したときの処理を書く
            break

event = threading.Event()
th = threading.Thread(target=th)
th.start()
input()
event.set()

 Event.waitにtimeoutを指定するとsleep代わりに使えます。要諦としては、

  • Eventの内部に持つフラグをclearメソッドでFalseに、setメソッドでTrueに切り替えられる。デフォルトはFalse
  • 内部フラグがTrueならwaitメソッドは即座に返る。Falseの場合はTrueになるまで(他スレッドがsetするまで)永遠に待つのがデフォルトの動作だが、timeoutを指定するとその期間待ってから返る
  • waitメソッドはtimeoutしたとき(だけ)はFalseを返す。それ以外はTrue

 というあたりで、活用すると面白いコードが書けます。

もう参照の値渡しとは(無条件では)言わせない

 注意:この記事では「参照の値渡し」がどういうものか、という点については説明しません。あくまで「参照の値渡し」を理解している方が対象読者です。

概要

 「参照の値渡し」という言葉がありますが、この言葉に関してはずっとモヤモヤ感を抱いていました。

 某所での議論を通じて、自分の考えがある程度まとまったので、記録しておきます。

 結論を先に要約すると、

  • 「参照の値渡し」は無条件で使える言葉ではない。むしろこの言葉が独り歩きするのであれば、かなり問題があると考える
  • 個人的には「共有渡し(call by sharing)」というネーミングを推す

 です。

 それぞれの理由について以下で述べます。

 目次

「参照の値渡し」の問題点

 これはもともと「参照渡し」と「参照の値渡し」を混同して使うな! という文脈で流行りだした言葉なのだと思うのですが、呼び方として「参照の値渡し」が良いとは思えない理由がたくさんあります。

 幾つか挙げます。

「参照の値」が定義されていない言語では使うべきではない

 「参照の値渡し」という言葉を好んで使う方は、おそらく「参照値」というものがあり、それを値渡ししている……というイメージを明確に持っています。というか、ぶっちゃけ「参照値=メモリ番地」というイメージまではっきり持っているでしょう。

 ただし、メモリ番地どうこうというのは(相対的に見て)低水準の話です。よって、高級言語、特にLLのようなものでは、メモリ番地をプログラマから直接見る手段がないようなものが幾らでもあります。

 ワーストケースでは、言語仕様上で「参照の値」の定義が存在しておらず、実装に委ねられている、ということがあり得ます。そういうケースでは当然「参照の値渡し」とは書けない。そもそも内部的に「参照の値渡し」である保証すらない(同様の動作の実装方法は「参照の値渡し」に限らないかもしれない)。

 逆に、「参照の値」が定義されている言語ではこの問題はありません。たとえば、この話題に関して検索すると割と上の方に出てくるJavaに関する記事*1では、この点が明確です。

Java で「参照 (references)」といったら「参照値 (reference values)」という「値」のことです。

もう参照渡しとは言わせない - Qiita

 「参照の値渡し」に関して言えば、Javaはかなり有利な立ち位置にいると言えます。・・・というか、Javaの人たちは「参照の値渡し→参照値の値渡し→そもそも値渡しである」という話の方に向かってしまう場合があります(上の記事もそうです)。こうみなすと、プリミティブ型にしろ参照型にしろ値渡しという一つの方法で扱われると言えるのですっきりするのです。

 問題は、このように「値渡し」とみなしても、特に良いことのないばかりか悪いことまで出てくる言語もたくさんある、ということです。

 私の好きなpythonはその代表格で、

  • すべての変数は参照型、というかオブジェクト

 →「参照の値渡し」の部分は裏側に引っ込んでしまうので、あえて値渡しと呼称してもメリットはない

  • 当たり前だが「参照値」なんて定義されていないし、プログラムから触る手段もない

 →実際は(少なくともCPythonでは)変数表のdictのvalueのポインタがそれに相当する。ただし、pythonのレイヤでは完全に見えない

 という事情があります。なので、pythonで「参照の値渡し」という言葉を使うインセンティブはありません。

「参照」と「値渡し」を理解していないとわからない

 「参照の値渡し」という言葉の残念な点は、それが「参照」と「値渡し」という概念に依存していることです。どちらも重要な概念で、しかも初心者のつまづきポイントです。

 つまり、「ヤツらに参照渡しと参照の値渡しを区別してほしい!」と思っている対象の「ヤツら」はそもそも「参照」も「値渡し」もちゃんと理解していない可能性が相応にある、ということです。ということは、「参照の値渡し」も理解してくれませんよね。

 また、一例として、これまでまったくプログラミングをやったことがない人とか、小学生とかにpythonを教えることを考えてみましょう。

 「参照」は理解してもらうしかないでしょう。すべての変数が参照型である以上、やむを得ません。でも、「値渡し」は上述した通りpythonのレイヤでは意識する必要がないので、教える必要はありません*2。しかし、「値渡し」を教えないと問題が発生します。「参照の値渡し」は引数の渡り方という超重要なトピックなので必ず教える必要があり、そのためには「値渡し」を教えないといけない・・・なにかが破綻しています。純粋に「参照の値渡し」という言葉が悪いのです。

そもそも言葉が抽象化されていない

 もっとも根本的な問題です。「参照の値渡し」は何も抽象化していない言葉です。「参照値が値渡しされる」と言っているのと同じですから。

 これは、「値渡し」と言う代わりに「実引数の値がコールスタックにpushされて・・・」と言うのと同じことです。単に動作を説明しているだけです。

 モジュール化して適切な名前を付け、ブラックボックスにして抽象化し、使いやすくする、というのはプログラミングにおいては極めて重要なことです。これを否定する人はいないでしょう。なのに、そういう大切な原則が守られていない言葉なのです。

 この言葉に対して私が個人的に抱いているモヤモヤ感も、けっきょくその辺りに起因する気がします。「名前」が「動作の説明」になってはいけません。

共有渡しを推してみる

 上で述べた通り、「参照の値渡し」はまずい点の多い言葉です。ただし、この言葉を使わないのであれば、代わる候補を探す必要があります。

 ということで、共有渡しを推します。

  • オブジェクトが(あるいはメモリ領域が)関数の間で共有される、という現象を捉える上で自然な言葉。
  • 上述した「参照の値渡し」の問題がない。
  • 英語圏ではもともと「call by sharing」が「参照の値渡し」とほぼ同じ意味で使われており、ならばこれの訳語を使うのが自然*3

 特に異論はないと思います。「共有渡し」唯一の難点は日本語圏ではまったく流行っていないことですが、どうせ「参照の値渡し」もリアルでは通じないことの方が多いでしょうから、大きな問題ではないでしょう。

「参照の値渡し」を使っていいとき

 この記事でこれまで述べてきた通り、「参照の値渡し」という言葉にはかなり問題があります。なので、使用場面は本来は極限されるべきであると考えます。

  • 参照値が定義されている言語で、
  • 共有渡しより通じやすい可能性があり、
  • 共有渡しより文脈上適当と考えられ、
  • その他の他の候補と比べて適切であると認められる場合*4

 上で挙げたJavaの例などはこれに当てはまる可能性があります。ただし、この場合も、「(一般名詞の)『参照の値渡し』と呼ばれる動作です」と言うよりは、「参照値が値渡しされます」と説明的に書いた方がはるかに親切でわかりやすいことに留意してください。

 つまり、理想論を言えば使途はかなり限定されるべきであろう(=ほぼ使われるべきではない)、ということです。

 ただし純粋に呼び方の問題でしかないので、理想論を押し通したところで、得られるメリットはほとんどありません。それが最大の難点です。「まあ細かい齟齬はあるかもしれないけど、『参照の値渡し』でも別にいいんじゃない?」と言われたら何も言い返せません。正しくはないと思うけど、自分の思う正しさを押し付ける蛮勇を振るうのは大変です。ぶっちゃけいまいち積極的になれません(という気分がタイトルにもにじみ出ているのを感じ取っていただければ)。

 この記事を読んだ上で、いろいろな理由で「それでも私は『参照の値渡し』と呼ぶ」と決意する方がいれば、私から言うことは特にありません。あくまでもこれは消極的な提言であるとご理解ください。できれば共有渡し派が増えてくれると嬉しいのですが・・・

 けっきょく、この記事にしてもこういう思いを共有してくれる人に向けて書いているだけなのです。(なんつーオチだ)

まとめ

 「参照の値渡し」という言葉は考えれば考えるほどケチがつけられるので、やっぱり問題含みだと思う。

関連記事

 (昔書いたものなので、今見ると拙い部分もあります。この記事のアップに伴って多少修正しましたが、私自身100%内容に満足している訳ではないことはご理解ください。)
www.haya-programming.com

*1:タイトルのパクり元・・・

*2:もちろんプログラミングを続けるのであれば、いずれ理解する必要が生じるでしょうけど

*3:callとpassは英語でもどちらでも良いものらしいので、問題にはなりません

*4:たとえば「ポインタ渡し」や「アドレス渡し」という言葉もあり、Cであればこちらを使った方が良いと思われます

AdaBoostとRandomForestの比較

はじめに

 個人的にAdaBoostの性質がまだよくわかっていないので、比較を行ってみようと思います。

参考文献

 大元はsklearnのこの記事です。

scikit-learn.org

 また、コードを1から書き上げるほどの情熱が今回沸かなかったので、自分の過去記事からコピペして書いています。

www.haya-programming.com

www.haya-programming.com

 AdaBoostのパラメータの決め方については、こちらの記事を御覧ください。

www.haya-programming.com

実験

 適当な2次元データに対して、分類境界の可視化を試みる。

 AdaBoostはベース分類器に決定木を使い、木の最大深さ5と10で試しています。

 コード

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap

from sklearn.datasets import make_moons, make_circles, make_classification
from sklearn.ensemble import AdaBoostClassifier, RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier

def main():
    ada5 = AdaBoostClassifier(
        base_estimator=DecisionTreeClassifier(max_depth=5),
        n_estimators=100)
    ada10 = AdaBoostClassifier(
        base_estimator=DecisionTreeClassifier(max_depth=10),
        n_estimators=100)

    rfc = RandomForestClassifier(n_estimators=100)

    X, y = make_classification(
        n_features=2, n_redundant=0, n_informative=2,
        random_state=1, n_clusters_per_class=1)
    rng = np.random.RandomState(2)
    X += 2 * rng.uniform(size=X.shape)
    linearly_separable = (X, y)

    datasets = [make_moons(noise=0.3, random_state=0),
                make_circles(noise=0.2, factor=0.5, random_state=1),
                linearly_separable
            ]

    fig, axes = plt.subplots(nrows=3, ncols=3, figsize=(10,10))

    cm = plt.cm.RdBu
    cm_bright = ListedColormap(['#FF0000', '#0000FF'])
    for i, (X, y) in enumerate(datasets):
        x_min, x_max = X[:, 0].min()-0.5, X[:, 0].max()+0.5
        y_min, y_max = X[:, 1].min()-0.5, X[:, 1].max()+0.5

        xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
                             np.arange(y_min, y_max, 0.1))

        for j, (cname, clf) in enumerate([("ADA d=5", ada5),
                                          ("ADA d=10", ada10),
                                          ("RFC", rfc)]):
            clf.fit(X, y)
            if hasattr(clf, "decision_function"):
                Z = clf.decision_function(np.c_[xx.ravel(), yy.ravel()])
            else:
                Z = clf.predict_proba(np.c_[xx.ravel(), yy.ravel()])[:, 1]
            Z = Z.reshape(xx.shape)
            axes[j,i].contourf(xx, yy, Z, 20, cmap=cm, alpha=.8)
            axes[j,i].scatter(X[:,0], X[:,1], c=y, s=20,
                              cmap=cm_bright, edgecolors="black")
            
            axes[j,i].set_title(cname)
    plt.savefig("result.png", bbox_inches="tight")

if __name__ == "__main__":
    main()

結果

 こんなものになります。

result.png
result.png

 AdaBoostそのものには過学習を抑制するような仕組みはないので、ベース分類器の汎化性能がなければほぼ決定木になることがわかります。バギング系の手法とは違います。

 最大深さ5では概ねまともですが、これでもよく見るとランダムフォレストより過学習の傾向を示しています。変なところにあるデータに引きずられる訳です。そうすると、決定木をもっと浅くするとどうなる? という疑問もありますが、見るに堪えない結果になるので、ある程度の識別性能のある分類器を使ったほうが良いです(見たい方は各自書き換えて実行してください)。

結論

 気軽に使うというわけにはいかないけど、アルゴリズムの原理的にはパラメータチューニングするとランダムフォレストより良くなる可能性を秘めているようです。

 それって要するに玄人向けなんじゃ・・・かといって絶対性能で最強でもないんですが。

 とはいえ、ベース分類器の過学習さえ抑制しておけばそこそこランダムフォレストと同等に使えそうなので、軽めのアンサンブル手法として便利ではあります。

【python】sklearnのAdaBoostをデフォルトパラメータで使ってはいけない

はじめに

 sklearnのAdaBoostを使う機会がありましたが、デフォルトパラメータのまま使ってみたら性能が悪すぎて驚きました。

 対策を書きます。

症状

 何も考えずに使うとこんな感じです。

from sklearn.datasets import load_digits
from sklearn.ensemble import AdaBoostClassifier
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split

def main():
    digits = load_digits()
    ada = AdaBoostClassifier()
    
    X_train, X_test, y_train, y_test = train_test_split(
        digits.data, digits.target, stratify=digits.target)
    ada.fit(X_train, y_train)
    pred = ada.predict(X_test)
    print(classification_report(y_test, pred))

if __name__ == "__main__":
    main()

""" => 
UndefinedMetricWarning: Precision and F-score are ill-defined and being set to 0.0 in labels with no predicted samples.
  'precision', 'predicted', average, warn_for)
              precision    recall  f1-score   support

           0       0.92      0.98      0.95        45
           1       0.13      1.00      0.23        46
           2       0.00      0.00      0.00        44
           3       0.00      0.00      0.00        46
           4       0.00      0.00      0.00        45
           5       0.00      0.00      0.00        46
           6       0.59      0.44      0.51        45
           7       0.00      0.00      0.00        45
           8       0.00      0.00      0.00        43
           9       0.50      0.24      0.33        45

   micro avg       0.27      0.27      0.27       450
   macro avg       0.21      0.27      0.20       450
weighted avg       0.21      0.27      0.20       450
"""

 さすがにひどい。

原因と対処

 ドキュメントをしっかり読んでみます。するとここが気になります。

base_estimator : object, optional (default=None)
The base estimator from which the boosted ensemble is built. Support for sample weighting is required, as well as proper classes_ and n_classes_ attributes. If None, then the base estimator is DecisionTreeClassifier(max_depth=1)

sklearn.ensemble.AdaBoostClassifier — scikit-learn 0.20.3 documentation

 深さ1の決定木はさすがに駄目なことが予想されるので、ベース分類器をもう少しまともなものにしてあげます。とりあえず、max_depth=Noneにした(伸び放題の)決定木。

from sklearn.datasets import load_digits
from sklearn.ensemble import AdaBoostClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split

def main():
    digits = load_digits()
    ada = AdaBoostClassifier(DecisionTreeClassifier(max_depth=None))
    
    X_train, X_test, y_train, y_test = train_test_split(
        digits.data, digits.target, stratify=digits.target)
    ada.fit(X_train, y_train)
    pred = ada.predict(X_test)
    print(classification_report(y_test, pred))

if __name__ == "__main__":
    main()

""" => 
              precision    recall  f1-score   support

           0       1.00      0.96      0.98        45
           1       0.73      0.89      0.80        46
           2       0.92      0.82      0.87        44
           3       0.89      0.85      0.87        46
           4       0.85      0.91      0.88        45
           5       1.00      0.85      0.92        46
           6       0.98      0.93      0.95        45
           7       0.87      0.89      0.88        45
           8       0.72      0.79      0.76        43
           9       0.82      0.82      0.82        45

   micro avg       0.87      0.87      0.87       450
   macro avg       0.88      0.87      0.87       450
weighted avg       0.88      0.87      0.87       450
"""

 大幅に改善。

 わかったら、あとはパラメータチューニングして追い込みます。

from sklearn.datasets import load_digits
from sklearn.ensemble import AdaBoostClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import classification_report
from sklearn.model_selection import GridSearchCV, train_test_split

def main():
    digits = load_digits()
    ada = AdaBoostClassifier(n_estimators=100) # ついでに増やす
    params = {"base_estimator" : [DecisionTreeClassifier(max_depth=x) 
                                  for x in range(5, 10)],
              "learning_rate" : [0.5, 1.0, 1.5]
    }
    cv = GridSearchCV(ada, params, cv=3, n_jobs=-1, verbose=2)

    X_train, X_test, y_train, y_test = train_test_split(
        digits.data, digits.target, stratify=digits.target)
    cv.fit(X_train, y_train)
    print(cv.best_params_)
    pred = cv.predict(X_test)
    print(classification_report(y_test, pred))

if __name__ == "__main__":
    main()
""" =>
{'learning_rate': 1.0, 'base_estimator': DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=8,
            max_features=None, max_leaf_nodes=None,
            min_impurity_decrease=0.0, min_impurity_split=None,
            min_samples_leaf=1, min_samples_split=2,
            min_weight_fraction_leaf=0.0, presort=False, random_state=None,
            splitter='best')}
              precision    recall  f1-score   support

           0       1.00      1.00      1.00        45
           1       0.98      1.00      0.99        46
           2       1.00      0.98      0.99        44
           3       1.00      0.96      0.98        46
           4       1.00      1.00      1.00        45
           5       0.98      0.91      0.94        46
           6       1.00      0.98      0.99        45
           7       0.98      0.98      0.98        45
           8       0.91      1.00      0.96        43
           9       0.91      0.96      0.93        45

   micro avg       0.98      0.98      0.98       450
   macro avg       0.98      0.98      0.98       450
weighted avg       0.98      0.98      0.98       450

"""

 データの限界に近いであろう数字が出ました。

まとめ

 base_estimatorをいじって多少パラメータチューニングすれば十分性能が出るので、デフォルトのまま使って使えない子だと判断しないように。