euphonictechnologies’s diary

Haskell超初心者の日記です。OCamlが好きです。

follow us in feedly

Rustで3Dのレンダリングエンジンを書く(1) - ベクトルの実装とマクロ

なぜレンダリングエンジンを?

この記事に感化されて、勉強をしてみたいと思いました。Rustは高速で安全な言語なので、スクラッチでこういうものを作るにはうってつけの言語です。 前から、3Dグラフィックスには興味があって、一時期DirectXでゲームを作っていたりしたので、むくむくと興味が湧き上がってきました。

gam0022.net

レンダリングエンジン?

3Dのレンダリングエンジン。ラスタライザともいいますが、3Dのモデリングソフトウェアなどで作った光景(シーン)データを実際にカメラから見たイメージとしてピクセル画像に変換するソフトウェアのことです。画像だけでなくアニメーション映像を扱うこともあります。

有名なレンダリングエンジンにはPixarのRenderManや、他にもMentalray、VRayなどがあります。3Dソフトに組み込まれていることも多いです(Blenderなど)。

どうやって進めるの?

最終的に欲しいものはシーンを記述した文章をもとに3Dのイメージを画像ファイル形式で出力するソフトウェアです。なので、シーンを記述する方法、シーンから画像を生み出す素になるデータを得る方法、最後に画像を出力する方法の3段階に分かれる予定です。

おおまかには

  1. ベクトルや行列などの3Dの基本的な部品を作ります
  2. それを元にシーンを表すデータ構造を定義します
  3. シーンをレンダリングする部分を実装します
  4. 最後に画像ファイルとして出力できるようにします

こんな流れでやっていこうと思います。最初にあげたブログの方がソースコードを公開されているのでがしがしパクって参考にしていこうと思います。

ベクトルを実装する

ベクトルをどうしても扱う必要があります。なぜかというと、シーンはこんな風に記述されるからです。

hanamaru-renderer/main.rs at 16c4ade782e17893ab6f0d8d646422677c2b210e · gam0022/hanamaru-renderer · GitHub

一部引用すると

fn init_scene_simple() -> (Camera, Scene) {
    let camera = Camera::new(
        Vector3::new(0.0, 2.0, 9.0), // eye
        Vector3::new(0.0, 1.0, 0.0), // target
        Vector3::new(0.0, 1.0, 0.0).normalize(), // y_up
        10.0, // fov

        LensShape::Circle, // lens shape
        0.2 * 0.0,// aperture
        8.8,// focus_distance
    );
...

最初にカメラが配置されているのですが、当然カメラは3D空間上で場所とカメラレンズの向きを指し示すのにベクトルが必要です。

二つ選択肢があります。

  1. 自分でベクトルモジュールを書く
  2. ライブラリを使う

ライブラリにはrust-ndarrayがあります。Pythonのと同じ感じで使えるもののようです。

github.com

ただ、少しオーバーキルっぽいこと、自分で実装して勉強したいこと、欲しいベクトルが3Dレンダリング向けの特殊なものになる気がすることから自分で書くことにしました。

Vector3を作る

基本的な関数を定義する

3次元ベクトルVector3を作ります。ここは参考先のコードを思いっきり参考にします。

基本的には

pub struct Vector3 {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

というデータとモジュール(シグネチャのみ)

impl Vector3 {
    pub fn new(x: f64, y: f64, z: f64) -> Vector3

    pub fn zero() -> Vector3

    pub fn one() -> Vector3

    pub fn norm(&self) -> f64

    pub fn length(&self) -> f64

    pub fn normalized(&self) -> Vector3

    pub fn dot(&self, other: &Vector3) -> f64

    pub fn cross(&self, other: &Vector3) -> Vector3
}

ぐらい。あとはreflectとretractという関数が便利そうなので参考先からもらってくることにします。

内積とかクロス積とか覚えてますか?内積はx同士、y同士、z同士かけて全部足すだけですが、クロス積ってなんでしたっけ。クロス積は二つのベクトルの法線方向に伸びるので、x座標には二つのベクトルのyとz成分をかけたり足したりしたものが、y座標にはxとz成分をかけたり足したりしたものが、z座標にも同じものが入っています。クロス積をそれぞれの基底成分の足し算で表してかけて項を整理してもいいですし、Wikipediaを見てもいいです。答えは

   x: self.y * other.z - self.z * other.y

です。y, zも同じです。

演算子オーバーロードをする

Rustで演算子オーバーロードは、できます!

演算子オーバーロードっていうのは、こんなことを可能にすることです。

let a = new Vector3 {x: 1, y: 0, z: 0};
let b = new Vector3 {x: 0, y: 1, z: 0};

a + b

いちいちJavaみたいにa.mult(b)とかやってられませんよね。読めないし。

Rustの演算子オーバーロードはトレイトで実装されています。具体的にはこんな感じ。

use std::ops::{Add, Sub, Mul, Div, Neg, AddAssign, MulAssign};

impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: self.x + other.x,
            y: self.y + other.y,
            z: self.z + other.z,
        }
    }
}

impl Add<f64> for Vector3 {
    type Output = Vector3;

    fn add(self, other: f64) -> Vector3 {
        Vector3 {
            x: self.x + other,
            y: self.y + other,
            z: self.z + other,
        }
    }
}

...

こんな感じでopsモジュールの中にあるAddトレイトで+を、以下同文で四則演算をオーバーロードできます。二種類必要で、

let a = new Vector3 {x: 1, y: 0, z: 0};
let b = new Vector3 {x: 0, y: 1, z: 0};

a + b;        // Vector3{ x: 1, y: 1, z: 0 }
a + 2.0;     // Vector3{ x: 3, y: 2, z: 0 }

どっちもできるようにしたいです。このa + bというのは実際にはAdd::add(a, b)のシンタックスシュガーになっています。なので、プラスの演算はAdd::add、マイナスの演算はSub::subが呼ばれているので、それのメソッドをオーバーロードすれば良いです。

なので早速Add, Sub, Mul, Divで2通りずつ、全部で8通りのimplを書きましょう!

嫌です!!!!!!

どう考えても4回コピペしてAddの部分をSub, Mul, Divへそれぞれ、+を-, *, /へそれぞれ置換する単純作業のお出ましです。人間はそういうことをするために生きているわけではありません。

当然怠惰を美徳とする模範的プログラマである我々はCtrl-Cをする前に関数にして繰り返しを防げないか考えます。

今回はできそうにありませんね、なんせtraitの型とかインターフェースにまつわるところなので。ただ、Rustにはマクロがあります。

マクロ?

もしあなたが老害C++プログラマの場合は「マクロはやめろ!!八つ墓村の祟りじゃあ!!!」といって聞かないかもしれませんが是非聞いて欲しいです。Rustには本物のマクロがります。

www.atmarkit.co.jp

本物のマクロといえばLispですね。Lispのマクロは本物です。Cのプリプロセサマクロは単なる文字置換機でクソみたいなものです。関係ないですが、最近C++のコードでマクロ関数をnamespaceで囲って名前空間を汚さないように気をつけている人を見かけました。驚愕です。

Rustのマクロも結構ホンモノです。今回の目的はそれぞれ4回ずつ計8回の繰り返しですコピペコードを抹殺することです。もう一度コピペ前のコードを引用しますと

use std::ops::{Add, Sub, Mul, Div, Neg, AddAssign, MulAssign};

impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: self.x + other.x,
            y: self.y + other.y,
            z: self.z + other.z,
        }
    }
}

impl Add<f64> for Vector3 {
    type Output = Vector3;

    fn add(self, other: f64) -> Vector3 {
        Vector3 {
            x: self.x + other,
            y: self.y + other,
            z: self.z + other,
        }
    }
}

...

です。これをマクロを使うと

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}

macro_rules! impl_op_v2f_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $templ_type_) -> $for_ {
                $for_ {
                    $(
                        $member_: $trait_::$op_(self.$member_, other),
                    )*
                }
            }
        }
    }
}

macro_rules! impl_ops_for_xyz {
    ($macro_name_: ident, $to_type_: ident, $with_type: ident, $(($trait_: ident, $op_fn_: ident)),*) => {
        $(
            $macro_name_!($trait_, $with_type, $to_type_, $op_fn_, x, y, z);
        )*
    }
}

//trace_macros!(true);

impl_ops_for_xyz!(impl_op_v2v_for, Vector3, Vector3, (Add, add), (Sub, sub), (Mul, mul), (Div, div));
impl_ops_for_xyz!(impl_op_v2f_for, Vector3, f64, (Add, add), (Sub, sub), (Mul, mul), (Div, div));

これで、4種類ずつ8個のimplが書けています。順番に見ていきます。

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}
// corresponds to
impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),
        }
    }
}

マクロはmacro_rules!から始まる行で定義できます。一般的な形は

macro_rules! <マクロ名>  {
    (マクロの引数の定義をつらつらと...) => {.   // この波括弧はマクロの定義内容の始まりを示す波括弧
        // ここにマクロの定義。つまりマクロ名!(...)という呼び出しがどういうコードに展開されるかを書く。
    }
}

こんな感じです。

macro_rules! impl_op_v2v_for {
    ($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {
        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }
    }
}
// corresponds to
impl Add for Vector3 {
    type Output = Vector3;

    fn add(self, other: Vector3) -> Vector3 {
        Vector3 {
            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),
        }
    }
}

まずimpl_op_v2v_forというのがマクロ名です。普通のRustの識別子ルールに従いアンダーバーで小文字名詞をつないでいきましょう。

次に引数です。$<マクロ変数名>: マクロの変数の型というのがマクロ変数のルールです。今回の場合、具体的には

impl_op_v2v_for ! ( Add, Vector3, Vector3 , add, x, y, z );

こういう呼び出し方ができるようにしたいです。4つの決まった数(Add(トレイト名), Vector3(トレイトを実装する型、a + bのaの方), Vector3(a + bのbの方の型), add(実装するトレイトのメンバ関数))の識別子と、いくつかの識別子(x, y, z, ...)を渡します。

($trait_: ident, $templ_type_: ident, $for_: ident, $op_: ident, $($member_: ident),*) => {

ここで、最初の4つはわかりやすいです。全部ident(識別子型)にバインドされています。残りは$($member_: ident),*に当てはまります。これは可変長のパターンです。ここにx, yとかx, y, zとかを割り当てて最終的に

            x: Add::add( self.x, other.x ),
            y: Add::add( self.y, other.y ),
            z: Add::add( self.z, other.z ),

この繰り返しを作ります。$(...)*と書いてある部分が繰り返しパターンです。正規表現みたいですね。

        impl $trait_<$templ_type_> for $for_  {
            type Output = $for_;

            fn $op_(self, other: $for_) -> $for_ {
                $for_ {
                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*
                }
            }
        }

この部分はルールさえわかれば読めるはずです。implの後に引数で与えられるトレイト名(Addとか)、でジェネリックスの型(<Vector3とかf64>)、で中身を定義します。

                    $($member_: $trait_::$op_(self.$member_, other.$member_),)*

ここが引数の繰り返しパターンの展開場所です。お尻に*が付いている部分です。

implement_op_v2f_forも同じです。違いはx: self.x + other.x,のところがx: self.x + other,になるだけです。

このimpl_op_v2v_forマクロがあれば、退屈な繰り返しを

impl_op_v2v_for! ( Add, Vector3, Vector3 , add, x, y, z );
impl_op_v2v_for! ( Sub, Vector3, Vector3 , sub, x, y, z );
impl_op_v2v_for! ( Mul, Vector3, Vector3 , mul, x, y, z );
impl_op_v2v_for! ( Div, Vector3, Vector3 , div, x, y, z );

このように別の退屈な繰り返しに置き換えられます。アホか!

というわけで、まとめて定義できるようにしましょう。つまり

impl_ops_for_xyz!(impl_op_v2v_for, Vector3, Vector3, (Add, add), (Sub, sub), (Mul, mul), (Div, div));

これです。最初に呼び出したいマクロの名前、演算子の左の方、右の方、あとはimplしたいトレイト名とその関数名のタプルを渡していきます。勘のいい方はここで、繰り返しパターンが使えることに気づいたと思います。こんな感じです。

macro_rules! impl_ops_for_xyz {
    ($macro_name_: ident, $to_type_: ident, $with_type: ident, $(($trait_: ident, $op_fn_: ident)),*) => {
        $(
            $macro_name_!($trait_, $with_type, $to_type_, $op_fn_, x, y, z);
        )*
    }
}

$(($trait_: ident, $op_fn_: ident)),*の部分がタプルの繰り返しを受け取ります。マクロが展開された結果マクロが生まれます。なのでそのマクロもちゃんと展開されます。これはコンパイル時に解決されて、実際にRustのコンパイラがコンパイルする段階ではコード上からマクロは消え去っています。例えばprint!なんかもマクロですが、コンパイラはprint!マクロの展開された後のコードをコンパイルします。

なので、再帰的定義のマクロもかけます。自然数をマクロで表現して四則演算をマクロコンパイラにやらせることも当然できます。すごいですね。チューリング完全らしいです。

Vector2も作る

Vector3と同じように2次元ベクトルのVector2もつくります。でもってAdd/Sub/Mul/Divも同様にimplします。演算子の右と左の型や、演算子を適用するx, y, zの部分が可変なので、さっきのマクロがそのまま使えます。全部実装するとこんな感じです。

gist.github.com

まとめ

Rustで3Dのレンダリングエンジンを作り始めました。早速Rustのパワーを使ってみました。繰り返し根絶です。ただ、Lispの頃から言われていますが、マクロは毒にも薬にもなります。当然関数で同様のことができないかをまず初めに考えて、トレイトでもなんでもできないとき、最後の最後にマクロを検討すべきです。マクロは展開後の様子が基本的には見えないので、理解しづらくバグの元です。ただ、今回のように字句上の繰り返しパターンを抽象化することによって保守性を高めることもできます。

次は行列を作ると思います。

Could not build Objective-C module 'GoogleMobileAds' とXcodeに怒られたら

久しぶりにiOSアプリをビルドしようとして失敗しました。

やっぱりか、と思いつつエラーが一つだけなことに気づきました。GoogleMobileAdsのモジュールが無いという怒られ方です。これなら楽ちんに直せそうです。

とりあえず、gitをきれいにしていつでも戻せるようにしてからはじめましょう。コミットしてないファイルがあればどこかにとっておくか、コミットしちまいましょう。

まずは原因の切り分け

まっとうなエンジニアリング的アプローチをとってみるとここは原因の切り分け。つまり、GoogleMobileAds以外は大丈夫なことを確認するために、Adを外してビルドしてみます。

おそらく編集するのは

  • ViewControllerからimport GoogleMobileAdsを削除する

  • さらに、同じファイルからGADBannerViewとかGoogleMobileAdsを使用しているViewをViewに変換する

  • そのViewの初期化処理をコメントアウトする

私のプロジェクトの場合は以下の感じです。

Preparation for upgrade, running ok on iPhone X real · ysnrkdm/FlatReversi@07cedc7 · GitHub

さらに

  • プロジェクト設定からGoogleMobileAdsフレームワークがあれば、それも削除する

f:id:euphonictechnologies:20181001223349p:plain

これでとりあえずビルドが走るはず。

GoogleMobileAdsをPodから入れ直す

というわけで、ぶっ壊れてるのはCocoaPodsで昔いれたライブラリのようです。

こういうとき一番に試すのは、入れ直しです。よっしゃ。Lockファイルに全部残っているのでpod installしても同じバージョンが入るか、そのバージョンの存在を確認してするっと抜けられてしまいます。

Analyzing dependencies
Downloading dependencies
Using Firebase (3.6.0)
Using FirebaseAnalytics (3.4.2)
Using FirebaseInstanceID (1.0.8)
Using Google-Mobile-Ads-SDK (7.11.0)
Using GoogleInterchangeUtilities (1.2.1)
Using GoogleSymbolUtilities (1.1.1)
Using GoogleUtilities (1.3.1)
Generating Pods project
Integrating client project
Sending stats
Pod installation complete! There are 2 dependencies from the Podfile and 7 total pods installed.

バージョン古いですね。入れ直ししましょう。まずはインストールされているライブラリを全部消します。Podfileの中身は、現在こんな感じ。

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '7.0'

target 'FlatReversi' do
  pod 'Firebase/Core'
  pod 'Firebase/AdMob'
end

こんな感じになっているファイルを、まず、消すために、全部消して別の関係ないライブラリをいれます。

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '7.0'

target 'FlatReversi' do
  pod 'Async'
end
$ pod install
source 'https://github.com/CocoaPods/Specs.git'
Analyzing dependencies
Removing Firebase
Removing FirebaseAnalytics
Removing FirebaseInstanceID
Removing Google-Mobile-Ads-SDK
Removing GoogleInterchangeUtilities
Removing GoogleSymbolUtilities
Removing GoogleUtilities
Downloading dependencies
Installing Async (0.2.0)
Generating Pods project
Integrating client project
Sending stats
Pod installation complete! There is 1 dependency from the Podfile and 1 total pod installed.

消えました。やりますねぇ。それで、ファイルを元に戻して入れ直します。Podfileはこんな感じ。

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '7.0'

target 'FlatReversi' do
  pod 'Firebase/Core'
  pod 'Firebase/AdMob'
end

で、pod installを実行してみると。

$ pod install
Analyzing dependencies
Removing Async
Downloading dependencies
Installing Firebase (4.13.0)
Installing FirebaseAnalytics (4.2.0)
Installing FirebaseCore (4.0.20)
Installing FirebaseInstanceID (2.0.10)
Installing Google-Mobile-Ads-SDK (7.30.0)
Installing GoogleToolboxForMac (2.1.4)
Installing nanopb (0.3.8)
Generating Pods project
Integrating client project
Sending stats
Pod installation complete! There are 2 dependencies from the Podfile and 7 total pods installed.

バージョンが全然違いますね。やりました。これでビルドしてみると、

f:id:euphonictechnologies:20181001224702p:plain

Test Adが表示されるようになりました。

まとめ

ホビープログラミングやっててCocoaPodsで問題が起きたら、ざっくり全部消してもう一回入れ直すといいことがあるかもです。

もちろん、業務用の場合は、まあ、ね。

Google Cloud Datalabを使って未経験からKaggleのTitanicサブミットまで - その2 サブミットにこぎ着ける

前回までで

Google Cloud Datalabのセットアップができました。楽ちんでしたね。

サブミットまで

今回は、サブミットまでたどり着きたいのですが、サブミットするだけならテストデータの要素数分ランダムにゼロイチ並べて出せばいいのですが、それだとあれなので普通に取り組んで答えを出してみます。

その前に

Datalab落ちちゃっている場合はVMのコンソール開いて

datalab connect --zone us-west2-a --port 8081 datascience-01a

名前やゾーンは適宜変えてくださいね。

Pandasを使う

テーブルっぽいデータ構造pandasをつかいます。この手のデータ構造はどんなプログラミング言語にもありますよね。それを使って前回ダウンロードした問題ファイルを読み込みます。

import pandas as pd

train_data = pd.read_csv('train.csv')
train_data.head(4)

f:id:euphonictechnologies:20180923111918p:plain

こんな感じ。中身が見えています。中身は問題ページに書いてありますし、大体から無名からわかりますよね。わかりにくいところだと

  • Pclass: チケットのクラスです
  • SibSp, Parch: 乗船してる兄弟とか配偶者(SibSp)と、両親か子供(Parch)の数。兄弟か、配偶者か、両親か子供かはこの数字からはわからない。
  • Embarked: 乗船した場所。C = Cherbourg, Q = Queenstown, S = Southamptonです。タイタニックはイギリスからアメリカに行く途中で沈没したのでこの地名は全部イギリスの地名ですね。

残りの説明は: Titanic: Machine Learning from Disaster | Kaggle

こんな感じ。

データのゴミ取り

データにはゴミがたくさん含まれておりまして、

train_data.info()

すると

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 891 entries, 0 to 890
Data columns (total 12 columns):
PassengerId    891 non-null int64
Survived       891 non-null int64
Pclass         891 non-null int64
Name           891 non-null object
Sex            891 non-null object
Age            714 non-null float64
SibSp          891 non-null int64
Parch          891 non-null int64
Ticket         891 non-null object
Fare           891 non-null float64
Cabin          204 non-null object
Embarked       889 non-null object
dtypes: float64(2), int64(5), object(5)
memory usage: 83.6+ KB

こんな感じで見えます。これを見るとAgeはNaNが入っている可能性があります。Ageは整数であるべきですよね。

train_data.isnull().any()

これをやってみます。isnull()はそれぞれのテーブルセル一つ一つをNaNかどうかのブールに置き換えて、anyは列ごとにTrueが一つでもあればTrueになります。

PassengerId    False
Survived       False
Pclass         False
Name           False
Sex            False
Age             True
SibSp          False
Parch          False
Ticket         False
Fare           False
Cabin           True
Embarked        True
dtype: bool

というわけなので、あってたっぽいです。Age, CabinとEmbarkedがNaNがあります。Nameとかは大丈夫でした。ゴミ取りしましょう。

Embarkedのゴミ取り

a = train_data.copy()
a["Embarked"].fillna(value='', inplace=True)
a.Embarked.value_counts()

中身をとりあえず見てみましょう。train_dataはオリジナルデータをとっておきたいので、copy()しましょう。変数名はひどいです。aはやめましょう。NaNはfillnaで空白に置き換えます。参照透明性大好き人間としてはinplaceは嫌いです。

S    644
C    168
Q     77
       2
Name: Embarked, dtype: int64

すっとNaNが2個あることがわかりました。2個ならどっちでもええやろ、ってことで

a = train_data.copy()
# seems like Embaked NaN are only 2, so map it to S (maximum)
a["Embarked"].fillna(value='', inplace=True)
a = dummify(a, 'Embarked', {'S': 0, 'C': 1, 'Q': 2, '': 0}, 'int64')

として空白をSに割り当ててダミー変数化してしまいます。港をそれぞれ{0, 1, 2}に割り当てることにしました。dummify関数は私のオリジナルのやつで

def dummify(table, col_name, mapping, astype=None):
  ret = table.copy()
  ret[col_name] = ret[col_name].map(mapping)
  if astype:
    ret[col_name] = ret[col_name].astype(astype)
  return ret

こんな感じ!単にマッピング掛けるだけかーい。

Cabinのゴミ取り

CabinはCXXXとかAXXXみたいな形のようです。なので頭だけ取り出すことにします。多分キャビン番号使うと生存率予想に使えるはずですが、難しいので無視しましょう。

a = train_data.copy()
a["Cabin"].fillna(value='', inplace=True)
a.Cabin.value_counts()

とすると

               687
B96 B98          4
G6               4
C23 C25 C27      4
D                3
              ... 
B78              1
D56              1
C62 C64          1
B41              1
Name: Cabin, Length: 148, dtype: int64

こんな感じ。ほとんど空白やないか!と言うわけで、あるやつは頭のアルファベット、無いやつはUnknownのUと言うことにしましょう、とりあえず。

a["Cabin"].fillna(value='', inplace=True)  
a.Cabin = a.Cabin.map(lambda x : x[0] if len(x) > 0 else 'U')
a = dummify(a, 'Cabin', {'A': 8, 'B': 7, 'C': 6, 'D': 5, 'E': 4, 'F': 3, 'G': 2, 'T': 1, 'U': 0}, 'int64')

頭文字取り出して整数に割り当てます。一応連続性を考慮してAから数字が減っていって空白はゼロに割り当てました。どうなんでしょうねこれ。

Ageのゴミ取り

Ageはちょっと難しいです。適当に全体の平均値なり中央値で埋めてもいいんですが、それもちょっとまずそう。てことで何か使えないかというと、名前が使えそう。名前についている敬称は年齢をある程度反映してるかもしれないです。実際に見てみましょう。

まずは、敬称をとるために全部名前を小文字に変換して、その中身で"Sir"というカラム名をつけます。

a = train_data.copy()
a.Name = a.Name.map(lambda name : name.lower())
a.loc[:,'Sir'] = a.Name.map(lambda name : 1 if 'mrs' in name else (2 if 'mr' in name else (3 if 'miss' in name else 4 if 'master' in name else 0)))
a.head(10)

としてみると、Sirに敬称、"Mrs", "Mr", "Miss"そして"Master"に応じた数字が割り振られます。無いやつは0にマップしました。 a.loc[:,'Sir']は、あたらしカラムを作るためにそうしてます。他にやり方がわかりませんでした…。 んでもって中身を見てみると、

import matplotlib.pyplot as plt

plt.style.use('ggplot')
plt.scatter(a.Age,a.Sir,color="#cc6699",alpha=0.5)
plt.show()

f:id:euphonictechnologies:20180923120112p:plain

こんな感じで分布しています。横軸が年齢、縦軸はそれぞれの敬称。敬称によって年齢の分布が変わっていることはわかります。本当だったらその要素ごとに敬称と他のフィールド要素から最もらしい年齢を類推するのがよいのでしょうが面倒なので、適当にそれぞれの敬称グループごとの平均とって、それで。

# Check age and sir
a[['Age', 'Sir']].groupby('Sir').mean()

要素を並べたリストでaを引くと、そのテーブルだけselectできます。でもってgroupbyして、meanをとります。ここら辺SQLの勘が働く人は強いですね。

Age
Sir 
0   42.666667
1   35.689922
2   32.301158
3   21.805556
4   4.525000

いい感じに平均ばらけてるので、これでいきましょう。本当はMasterとか眉唾物ですね、

 = train_data.copy()
a.Name = a.Name.map(lambda name : name.lower())
a.loc[:,'Sir'] = a.Name.map(lambda name : 1 if 'mrs' in name else (2 if 'mr' in name else (3 if 'miss' in name else 4 if 'master' in name else 0)))
a.Age = a.Age.where(a.Age == a.Age, a.Sir.map({0: 43, 1: 36, 2: 32, 3: 22, 4: 5})).astype('int64')  
a.head(10)

このwhereがNaNでは失敗するので(NaN!=NaN)、そのマッチングが失敗してFalseになる部分ではマップを使ってSirによって当てはめる数字を決めて、入れ込みます。

あとはダミー変数化

あとは適当にダミー変数化していらないコラムをそぎ落とします。

まずは性別。

a = dummify(a, 'Sex', {'male': 1, 'female': 0}, 'int64')

次に、落とすカラム

a = a.drop('Ticket', axis=1).drop('Name', axis=1)

2つ落としちまいましょう。

SibSpとParch何かに使えない?

この二つは全部足すと家族の人数になります。詳しい分析は置いておいて、家族の人数ってことにしましょう。

a.loc[:, 'FamilySize'] = a.SibSp + a.Parch + 1

自分を含めた家族の人数です。多分ですが、家族が多いほど死にやすいはず。兄弟と両親のカラムはFamilySizeに置き換えたいので、FamilySizeを追加して、他二つは落としましょう。

a = a.drop('SibSp', axis=1).drop('Parch', axis=1)

テーブルのお掃除をまとめます

というわけで、ここまでの作業を全部一つの関数にまとめます。こんな感じ。

def dummify(table, col_name, mapping, astype=None):
  ret = table.copy()
  ret[col_name] = ret[col_name].map(mapping)
  if astype:
    ret[col_name] = ret[col_name].astype(astype)
  return ret

def get_normalized_dataset(train_data):
  a = train_data.copy()

  # seems like Embaked NaN are only 2, so map it to S (maximum)
  a["Embarked"].fillna(value='', inplace=True)
  a = dummify(a, 'Embarked', {'S': 0, 'C': 1, 'Q': 2, '': 0}, 'int64')

  a["Cabin"].fillna(value='', inplace=True)    
  a.Cabin = a.Cabin.map(lambda x : x[0] if len(x) > 0 else 'U')
  a = dummify(a, 'Cabin', {'A': 8, 'B': 7, 'C': 6, 'D': 5, 'E': 4, 'F': 3, 'G': 2, 'T': 1, 'U': 0}, 'int64')

  a.Name = a.Name.map(lambda name : name.lower())
  a.loc[:,'Sir'] = a.Name.map(lambda name : 1 if 'mrs' in name else (2 if 'mr' in name else (3 if 'miss' in name else 4 if 'master' in name else 0)))
  a.Age = a.Age.where(a.Age == a.Age, a.Sir.map({0: 43, 1: 36, 2: 32, 3: 22, 4: 5})).astype('int64')
    
  a = dummify(a, 'Sex', {'male': 1, 'female': 0}, 'int64')    

  a.loc[:, 'FamilySize'] = a.SibSp + a.Parch + 1
  
  a = a.drop('Ticket', axis=1).drop('Name', axis=1)
  a = a.drop('SibSp', axis=1).drop('Parch', axis=1)

  return a

この関数にpandasテーブルを渡すと結果が返ってきます。

train_data = pd.read_csv('train.csv')
train_data.head(4)
a = get_normalized_dataset(train_data)
a.head(10)

こんな感じ。

f:id:euphonictechnologies:20180923132429p:plain

大分減っちゃいましたね。ここで全部数値データになっていることを確認しておきます。

学習するんや

学習しましょう。こっから楽しいところですね。LightGBMを使います。決定木をたくさん集めるアンサンブル学習を勾配ブースティングを使ってうまいことやってくれるらしいです。中身はまあ、あれです。使ってみましょう。

とりあえず使う

from sklearn.model_selection import train_test_split

aa = pd.get_dummies(a)
x_train, x_test, y_train, y_test = train_test_split(
    aa.values[:, 2:], aa.values[:, 1], test_size=0.33, random_state=201612
)

とりあえずpandasのget_dummies関数をかましています。多分ちゃんときれいなテーブルになっていたらいらないやつです。ここでtrain_test_split関数を使ってtrainデータを訓練用と検証用のデータに分けます。レコード数を2:1に分割します。aa.values[:, 2:]は生存結果の右側、使うデータ。values[:, 1]は生存結果、つまり答え。

そうするとx/y_trainは訓練用のフィールドがたくさん並んだリスト、x/y_testは答えの入った単なるバイナリ値のリストです。名前はもうちょっと考えた方がいいですね。

import lightgbm as lgb

gbm = lgb.LGBMClassifier(objective='binary',
                        num_leaves=22,
                        learning_rate=0.1,
                        min_child_samples=10,
                        n_estimators=100)

gbm.fit(x_train, y_train,
        eval_set=[(x_test, y_test)],
        eval_metric='multi_logloss',
        early_stopping_rounds=10)

y_pred = gbm.predict(x_test, num_iteration=gbm.best_iteration_)

とりあえずこれでLightGBMが動きます。うれしいですね。

最後の行は検証用訓練データを使って検証用データへの答えを出力します。検証用データの実際の答えと比べてみます。

# eval
print('Accuracy is:', 1 - abs(y_test - y_pred).sum() / len(y_test))

これで、正解率を計算すると

Accuracy is: 0.840677966101695

84点ですね。まあまあでしょうか。とにもかくにも与えられた訓練用データ全体を訓練用と検証用に勝手に分けて点数をつけているだけの話です。早速サブミットしてkaggleに点数をつけてもらいましょう。

テストデータを評価する

test_data = pd.read_csv('test.csv')
test_data.head(4)
b = get_normalized_dataset(test_data)
b.head(10)

テストデータを読み込んでさっきのデータ調整関数に通して予測できる形にします。

pred_for_submit = gbm.predict(b.values[:, 1:], num_iteration=gbm.best_iteration_)

このgbmは訓練データを読み込ませてfitしたやつです。これで予測して、結果を出します。

array([0., 0., 0., 0., 1., 0., 0., 0., 1., 0., 0., 0., 1., 0., 1., 1., 0.,
       0., 1., 1., 0., 1., 1., 0., 1., 0., 1., 0., 0., 0., 0., 0., 1., 1.,
       0., 0., 0., 0., 0., 0., 0., 1., 0., 1., 1., 0., 1., 0., 1., 1., 1.,
       0., 1., 1., 0., 0., 0., 0., 0., 1., 0., 0., 0., 1., 0., 1., 1., 0.,
       0., 1., 1., 0., 0., 1., 1., 0., 0., 1., 0., 1., 1., 0., 0., 0., 0.,
       0., 1., 0., 0., 1., 1., 0., 1., 0., 0., 0., 1., 0., 1., 0., 1., 0.,
       0., 0., 1., 0., 0., 0., 0., 1., 0., 1., 1., 1., 1., 0., 0., 1., 0.,
       1., 1., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 1., 0., 0., 1., 0., 0., 0., 1., 0., 1., 0., 0.,
       1., 0., 0., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 1., 0., 1., 0.,
       0., 1., 0., 0., 0., 1., 1., 0., 1., 1., 0., 1., 1., 0., 1., 0., 1.,
       0., 0., 0., 0., 0., 1., 0., 1., 0., 1., 1., 0., 1., 1., 1., 0., 1.,
       0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 1., 0., 1., 0., 1.,
       0., 1., 0., 1., 0., 0., 1., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0.,
       1., 1., 1., 1., 0., 0., 1., 0., 1., 0., 1., 0., 1., 0., 1., 0., 0.,
       0., 0., 0., 1., 0., 0., 0., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0.,
       1., 1., 0., 1., 0., 0., 0., 0., 0., 1., 1., 1., 1., 0., 0., 1., 0.,
       0., 0., 0., 0., 1., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 1., 1.,
       0., 1., 1., 0., 0., 0., 0., 0., 1., 1., 1., 0., 0., 0., 0., 1., 1.,
       1., 1., 0., 1., 0., 0., 0., 1., 1., 0., 1., 0., 0., 0., 0., 0., 1.,
       0., 0., 0., 1., 1., 1., 0., 1., 0., 1., 1., 0., 0., 0., 1., 0., 1.,
       0., 0., 1., 0., 1., 1., 0., 1., 0., 0., 0., 1., 0., 0., 1., 0., 0.,
       1., 1., 0., 0., 0., 0., 0., 0., 1., 1., 0., 1., 0., 0., 0., 0., 1.,
       1., 1., 0., 0., 1., 0., 1., 0., 0., 1., 0., 1., 0., 0., 1., 0., 0.,
       1., 1., 1., 1., 0., 0., 1., 0., 0., 1.])

こんなのが得られます。確かに1か0に分類されていますね。

サブミットしましょう

答えがpred_for_submitに格納されています。というわけで、サクッとサブミットしてしまいましょう。まず提出用のcsvに書き出します。

import csv
with open("predict_result_data.csv", "w") as f:
    writer = csv.writer(f, lineterminator='\n')
    writer.writerow(["PassengerId", "Survived"])
    for pid, survived in zip(test_data.values[:,0].astype(int), pred_for_submit.astype(int)):
        writer.writerow([pid, survived])
    print('Written.\n')

でもって、これをkaggleのCIツールで提出します。

%%bash
kaggle competitions submit -f predict_result_data.csv -m 'ここに提出データの説明を割と詳しくつけましょう' -c titanic

f:id:euphonictechnologies:20180923140742p:plain

という感じで、サブミットした結果。どうなったかというと…

f:id:euphonictechnologies:20180923140952p:plain

うーん。あんまり点数高くないですね。

というわけで、サブミットまでできました。

リーダーボードのランキングを見てみると上の方は全部1.00です。つまり、全問正解というわけですね。ちょっと萎えますね。

これでひとまず全部一回通せたので他のコンペティションにも取り組んでみたいと思いました。おしまい。

全部のコード

%%bash
pip install kaggle lightgbm
%%bash
echo '{"username":"my_user_name","key":"key_key_key"}' > ~/.kaggle/kaggle.json
%%bash
chmod 600 ~/.kaggle/kaggle.json
%%bash
kaggle competitions download -c titanic
from google.datalab import Context
import google.datalab.bigquery as bq
import google.datalab.storage as storage
import pandas as pd
from io import BytesIO as StringIO
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import GridSearchCV
import sklearn.preprocessing as sp

train_data = pd.read_csv('train.csv')
train_data.head(4)

def dummify(table, col_name, mapping, astype=None):
  ret = table.copy()
  ret[col_name] = ret[col_name].map(mapping)
  if astype:
    ret[col_name] = ret[col_name].astype(astype)
  return ret

def get_normalized_dataset(train_data):
  a = train_data.copy()

  # seems like Embaked NaN are only 2, so map it to S (maximum)
  a["Embarked"].fillna(value='', inplace=True)
  a = dummify(a, 'Embarked', {'S': 0, 'C': 1, 'Q': 2, '': 0}, 'int64')

  a["Cabin"].fillna(value='', inplace=True)    
  a.Cabin = a.Cabin.map(lambda x : x[0] if len(x) > 0 else 'U')
  a = dummify(a, 'Cabin', {'A': 8, 'B': 7, 'C': 6, 'D': 5, 'E': 4, 'F': 3, 'G': 2, 'T': 1, 'U': 0}, 'int64')

  a.Name = a.Name.map(lambda name : name.lower())
  a.loc[:,'Sir'] = a.Name.map(lambda name : 1 if 'mrs' in name else (2 if 'mr' in name else (3 if 'miss' in name else 4 if 'master' in name else 0)))
  a.Age = a.Age.where(a.Age == a.Age, a.Sir.map({0: 43, 1: 36, 2: 32, 3: 22, 4: 5})).astype('int64')
    
  a = dummify(a, 'Sex', {'male': 1, 'female': 0}, 'int64')    

  a.loc[:, 'FamilySize'] = a.SibSp + a.Parch + 1
  
  a = a.drop('Ticket', axis=1).drop('Name', axis=1)
  a = a.drop('SibSp', axis=1).drop('Parch', axis=1)

  return a

a = get_normalized_dataset(train_data)
a.head(10)

# Check age and sir
a[['Age', 'Sir']].groupby('Sir').mean()

plt.style.use('ggplot')
plt.scatter(a.Age,a.Sir,color="#cc6699",alpha=0.5)
plt.show()

a.Cabin.value_counts()

aa = pd.get_dummies(a)
x_train, x_test, y_train, y_test = train_test_split(
    aa.values[:, 2:], aa.values[:, 1], test_size=0.33, random_state=201612
)

gbm = lgb.LGBMClassifier(objective='binary',
                        num_leaves=22,
                        learning_rate=0.1,
                        min_child_samples=10,
                        n_estimators=30)

gbm.fit(x_train, y_train,
        eval_set=[(x_test, y_test)],
        eval_metric='multi_logloss',
        early_stopping_rounds=10)

y_pred = gbm.predict(x_test, num_iteration=gbm.best_iteration_)

# eval
print('Accuracy is:', 1 - abs(y_test - y_pred).sum() / len(y_test))

# feature importances
print('Feature importances:', list(gbm.feature_importances_))

test_data = pd.read_csv('test.csv')
test_data.head(4)
b = get_normalized_dataset(test_data)
b.head(10)

pred_for_submit = gbm.predict(b.values[:, 1:], num_iteration=gbm.best_iteration_)

import csv
with open("predict_result_data.csv", "w") as f:
    writer = csv.writer(f, lineterminator='\n')
    writer.writerow(["PassengerId", "Survived"])
    for pid, survived in zip(test_data.values[:,0].astype(int), pred_for_submit.astype(int)):
        writer.writerow([pid, survived])
    print('Written.\n')
%%bash
kaggle competitions submit -f predict_result_data.csv -m 'LightBGM, adjusted3' -c titanic