F# というプログラミング言語があります..NET Framework で動作する関数型言語で,読みやすく保守性の高いコードを素早く書くことができ,C# などの .NET 言語と簡単に相互運用することができる非常に優れた言語です.
そんな F# ですが,.NET のライブラリ群によってデータサイエンス用途の言語としても活用することができます.
今回は Visual Studio Code の拡張機能である Polyglot Notebooks を使って,F# によるデータ分析・機械学習を行う手順を紹介し,その利点について解説します.
F# について
F# は,OCaML をベースとして開発された,関数型ベースのマルチパラダイム言語です.
言語としては以下のような特徴を備えています:
- インデントを使った構文
- 静的型付け(型推論)
- 関数型言語(ML 系)
- オブジェクト指向
- 代数的データ型,パターンマッチング
- ジェネリクス,パラメトリック多相
- インライン関数
- .NET Framework との相互運用
F# の特徴は,さまざまなスタイルを状況に応じて使い分けることができるという点です.
同じ処理を書くのに,手続き型スタイル
let mutable sum = 0
for x in 1..100 do
if x % 3 = 0 then
sum <- sum + x * x
printfn "%d" sum
と関数型スタイル
let sum =
[1..100]
|> List.filter (fun x -> x % 3 = 0)
|> List.map (fun x -> x * x)
|> List.sum
|> printfn "%d"
のどちらでも書くことができ,型定義でもオブジェクト指向的なクラス
type Foo(name: string) =
member this.Name = name
member this.Bar() = "bar"
とレコード
type Foo =
{ Name: string }
member this.Bar() = "bar"
を使い分けることができます.
そのため F# は関数型言語の経験がなければ書けないということは全くなく,慣れたスタイル,または目的に適したスタイルで書いていくことができる言語となっています1.
また,C# や VB といった .NET 系の言語と容易に相互運用することができるのも利点です.
これは C# や VB のライブラリを F# から呼べるというだけでなく, C# や VB で使うライブラリを F# で書くことができるということも意味しています.
単一のソリューションに C# と F# を混合して使うということも普通に行うことができます2.
C# と F# のどちらからでも同じように使えることを想定したライブラリも多く存在します.
総じて,F# は状況に応じてさまざまな選択肢をとることができる言語であるといえ,非常に柔軟性が高く,実行性能も優れていて使用領域を選びません.
その記述力の高さと汎用性から,言語開発元の Microsoft を中心にデータサイエンス分野での利用が推進されています.
F# にはコンパイラとインタプリタの2つの実行環境があり,今回はインタプリタ環境である .NET Interactive を使用します.
Polyglot Notebooks について
Polyglot Notebooks は Visual Studio Code 上で,.NET 言語を使用した Jupyter Notebook 形式のノートブックを作成・実行するための拡張機能です.
C#, F#, PowerShell, JavaScript, SQL, KQL などの言語をサポートしており,これらの言語で書かれたコードセルを混在させ,同じノートブック内で相互に呼び出し,互いの変数を参照することもできます3.
さらなる機能の情報は Marketplace を参照してください.
今回は F# のみを使用します.
Why F#
手順に入る前に,F# でデータ分析とかなんか逆張りっぽくて不安という方のために F# を使うことで得られる具体的な恩恵を述べておきます.
F# の言語としての特徴である「静的型付け」「関数型言語」といった特徴は,一見するとスクリプト言語としての利用には不向きに思えるかもしれませんが,ノートブックによる開発では F# の言語機能のそれぞれが生産性に直結する強みをもっています.
静的型付けであること
静的型付け言語であることのメリットは,しばしば規模の大きい開発における保守性という視点で認識されがちですが,対話実行環境やノートブックにおける開発でこそ現れる利点として,補完や型ヒントといったエディタサポートをより広範に享受でき,目的の記述を素早く実現することができる点が挙げられます.
Python のような動的型付け言語は,コードを記述している最中に変数や式の型を知ることができず,ある変数が現在どのようなメソッドを持っているのか,ある関数がどのような型の値を返すかなど,コードを書くために必要な情報をその場で確認することができず,実行してみるか,都度ドキュメントなどを参照するなどの手間が発生します.
model = Foo.Bar().model(param1, param2)
# ここで model が any になってしまった!
# 以下のどれが正解?
model.fit(x, y)
model.train(x, y)
model.learn(x, y)
また変数名・メソッド名のタイポなども動的型付け言語を使用する上で気を付けなければならない点となります.
一方で静的型付け言語は,コード上の変数にホバーすることで型とそのドキュメントを確認でき,.
と入力すればメンバの補完を得られたりと,エディタから離れることなく必要な情報を得ることができます.
また,タイポや型の誤りは即座に検知されるため,しょうもないミスをその場で発見・修正することができます.
let model = Foo.Bar().Model(param1, param2)
// この時点で model の型は確定している.
// ので,エディタは model がどんなメンバを持てるか既に知っていて,
// いつでもプログラマに教えることができる.
let learned = model. // この時点でエディタはメンバ候補を表示する.
let learned = model.F // Fit が候補に出てこない,じゃあ Fit じゃないか...
let learned = model.T // Train が候補に出てきたので,tab を押して確定.
let learned = model.Train(x, y) // ホバーするとドキュメントが表示され,使い方を確認できる.
小規模なコードであるからこそ,記述しながら仕様や挙動を把握できるというインタラクタビリティの高さは生産性を高める重要な要素になりえます.
一方,明示的なダウンキャストなど,静的型付け言語特有の煩わしさがあることも事実ですし,言語,フレームワークおよびライブラリに十分に精通している人にとっては,動的型付け言語であっても阻害されることは少なくなると考えられます.
動的型付け言語でのスクリプト開発の IDE・エディタサポートや部分的に型付けを行うツールも充実してきているため,上手く活用すれば動的型付けのデメリットをカバーして開発することも可能でしょう.
関数型言語であること
関数型言語は制御構文や変数の更新などを行わずに処理を複合することができるため,手続き型言語と比べてコードブロックがコンパクトになりやすいというメリットがあります.
また,「どのように計算するか」ではなく「何を計算するか」を記述するコードを書けるため,特にノートブックのようなドキュメントを兼ねたコードにおいては,コメントがなくてもコードの意図を明確にすることができる点も優れています.
ワンライナーのコードを書きやすいという点で REPL での実行にも向いています.
[1..100] // 1 から 100 までの整数から
|> List.filter (fun x -> x % 3 = 0) // 3 の倍数を取り出し,
|> List.map (fun x -> x * x) // 2 乗して,
|> List.sum // 合計を求め,
|> printfn "%d" // 出力する.
// 以上のコードのコメントがなくても,処理の順番や意図,
// 「何を求めるのか」を明確に読み取れるようになっている.
一方,関数型スタイルの記述は下手に短さを追求することで却って読みづらくもなりがちなので,あくまで読みやすさが重要だということを意識しながら書く必要があります.
また,アルゴリズムなどの複雑な手続きの記述は関数型言語の苦手とするところです.
F# では必要に応じて手続き型スタイルを利用したり,場合によってはピンポイントで C# を使うなどの工夫も有効かもしれません4.
不変性
上記と関連しますが,関数型言語ではデータを更新するのではなく,新しいデータを返して元のデータをそのまま保持するというスタイルがよく用いられます.
ことノートブックの環境では,値が勝手に変更されない = データが勝手に消えないというのが思わぬ副作用の影響を防げるメリットになっています.
# このコードセルをもう一度実行してしまうと,model が上書きされてしまう.
model.learn(x, y)
// 値を変更するのではなく変更後の値を返却し,元の値は変更しない.
// このコードセルを実行しても model のデータは変化せず,失われない.
let learned = model.Learn(x, y)
相互運用性
以上に述べたような特徴は,F# でのみ用いられることを想定したライブラリでの話であり,C# で記述されたライブラリでは必ずしも関数型スタイルで使いやすい設計がなされているとは限りません.
しかし,そうであっても,F# は手続き型スタイルやオブジェクト指向設計も自然にサポートしており,ライブラリの設計にあわせた使い方ができます.
また,F# での利便性を考慮して設計されたライブラリも存在するため,そういったライブラリならば C# からでも F# からでも同様に便利に使うことができます.
準備
以降では F# による機械学習の手順を紹介し,実際に上記のメリットが働いていることを確認していきます.
分析データ
今回は,kaggle の "Titanic: Machine Learning from Disaster" コンペティションのデータを用いた生存予測(2値分類)を行います.
コンペティションページからトレーニングデータ(train.csv)とテストデータ(test.csv)をダウンロードします.
環境の準備
Visual Studio Code, .NET 7 SDK がインストールされている必要があります.
Visual Studio Code に Polyglot Notebooks 拡張機能をインストールします.
プロジェクトの作成
以下で作成するプロジェクトのソースコードは以下のリポジトリにあります.
https://github.com/arakur/FSharpMLExample
適当なフォルダで Visual Studio Code を起動します.
F# script の開発では .fsproj
のようなプロジェクトファイルを作成する必要はありません.
今回は以下のようなファイル構成にします.
./
├── data/
│ ├── titanic/
│ │ ├── test.csv
│ │ ├── train.csv
│
├── src/
│
├── submission/
│ ├── titanic/
ここで,train.csv
, test.csv
は先ほどダウンロードしたデータです.
src
フォルダにスクリプトを配置し,submission/titanic
フォルダに kaggle に提出するためのファイルを出力します.
src/
フォルダ内に main.ipynb
ファイルを作成します.
main.ipynb
をエディタで開いたら,右上の Select Kernel
をクリックし .NET Interactive
を選択します.
その後,コードセルの右下の言語名(たとえば csharp - C# Script Code
)が表示されている部分をクリックし,fsharp - F# Script
を選択します.
以下の状態になっていれば OK です.
ノートブック自体の使い方は通常の Jupyter Notebook と変わりません.
分析
データの読み込み
F# のデータフレームライブラリ
Python の Pandas に相当する F# 向けのデータフレームライブラリとして Deedle があります.
これは C# と F# のどちらからでもネイティブに使うことができるよう設計されたライブラリで,F# から使う場合には関数型スタイルに適したインターフェースで使用することができます.
ノートブックの先頭のコードセルに以下のように記述・実行して,インタプリタ環境に Deedle を読み込みます.
#r "nuget: Deedle"
F# script で nuget の外部ライブラリを読み込む際には #r
キーワードを用います.
#r
で nuget のパッケージソースが見つからず読み込めない場合には,#r
の前に
#i "nuget: https://api.nuget.org/v3/index.json"
と記述すれば読み込めるようになります.
データフレームの型 Deedle.Frame
の ReadCsv
メンバを用いてトレーニングデータとテストデータを読み込みます.
open Deedle
let train =
Frame.ReadCsv(__SOURCE_DIRECTORY__ + "/../data/titanic/train.csv") // トレーニングデータを読み込み,
|> Frame.indexRowsInt "PassengerId" // PassengerId を行名に設定する.
let test =
Frame.ReadCsv(__SOURCE_DIRECTORY__ + "/../data/titanic/test.csv") // テストデータを読み込み,
|> Frame.indexRowsInt "PassengerId" // PassengerId を行名に設定する.
(__SOURCE_DIRECTORY__
は現在のスクリプトファイルのディレクトリを表す変数)
このセルの train
をホバーすると,train
の型 Frame<int, string>
を確認することができます.
Frame<int, string>
は,行名が int
型,列名が string
型であるようなデータフレームの型を表します.
実際に値を表示させてみましょう.
train.Print()
Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
1 -> False 3 Braund, Mr. Owen Harris male 22 1 0 A/5 21171 7.25 S
2 -> True 1 Cumings, Mrs. John Bradley (Florence Briggs Thayer) female 38 1 0 PC 17599 71.2833 C85 C
3 -> True 3 Heikkinen, Miss. Laina female 26 0 0 STON/O2. 3101282 7.925 S
4 -> True 1 Futrelle, Mrs. Jacques Heath (Lily May Peel) female 35 1 0 113803 53.1 C123 S
5 -> False 3 Allen, Mr. William Henry male 35 0 0 373450 8.05 S
6 -> False 3 Moran, Mr. James male <missing> 0 0 330877 8.4583 Q
7 -> False 1 McCarthy, Mr. Timothy J male 54 0 0 17463 51.8625 E46 S
8 -> False 3 Palsson, Master. Gosta Leonard male 2 3 1 349909 21.075 S
9 -> True 3 Johnson, Mrs. Oscar W (Elisabeth Vilhelmina Berg) female 27 0 2 347742 11.1333 S
10 -> True 2 Nasser, Mrs. Nicholas (Adele Achem) female 14 1 0 237736 30.0708 C
11 -> True 3 Sandstrom, Miss. Marguerite Rut female 4 1 1 PP 9549 16.7 G6 S
12 -> True 1 Bonnell, Miss. Elizabeth female 58 0 0 113783 26.55 C103 S
13 -> False 3 Saundercock, Mr. William Henry male 20 0 0 A/5. 2151 8.05 S
14 -> False 3 Andersson, Mr. Anders Johan male 39 1 5 347082 31.275 S
15 -> False 3 Vestrom, Miss. Hulda Amanda Adolfina female 14 0 0 350406 7.8542 S
: ... ... ... ... ... ... ... ... ... ... ...
877 -> False 3 Gustafsson, Mr. Alfred Ossian male 20 0 0 7534 9.8458 S
878 -> False 3 Petroff, Mr. Nedelio male 19 0 0 349212 7.8958 S
879 -> False 3 Laleff, Mr. Kristo male <missing> 0 0 349217 7.8958 S
880 -> True 1 Potter, Mrs. Thomas Jr (Lily Alexenia Wilson) female 56 0 1 11767 83.1583 C50 C
881 -> True 2 Shelley, Mrs. William (Imanita Parrish Hall) female 25 0 1 230433 26 S
882 -> False 3 Markun, Mr. Johann male 33 0 0 349257 7.8958 S
883 -> False 3 Dahlberg, Miss. Gerda Ulrika female 22 0 0 7552 10.5167 S
884 -> False 2 Banfield, Mr. Frederick James male 28 0 0 C.A./SOTON 34068 10.5 S
885 -> False 3 Sutehall, Mr. Henry Jr male 25 0 0 SOTON/OQ 392076 7.05 S
886 -> False 3 Rice, Mrs. William (Margaret Norton) female 39 0 5 382652 29.125 Q
887 -> False 2 Montvila, Rev. Juozas male 27 0 0 211536 13 S
888 -> True 1 Graham, Miss. Margaret Edith female 19 0 0 112053 30 B42 S
889 -> False 3 Johnston, Miss. Catherine Helen "Carrie" female <missing> 1 2 W./C. 6607 23.45 S
890 -> True 1 Behr, Mr. Karl Howell male 26 0 0 111369 30 C148 C
891 -> False 3 Dooley, Mr. Patrick male 32 0 0 370376 7.75 Q
行,列の取得・追加・削除など
データフレームの行または列を取得するには以下のように記述します:
// 取得方法1. ObjectSeries として取得する.
// Columns メンバを使用.
// ObjectSeries の各要素は obj 型(Python の any 相当).
// 値を取り出した後にダウンキャスト :?> で型を指定する.
let survived: ObjectSeries<int> = train.Columns.["Survived"]
let survived': ObjectSeries<int> = train.Columns?Survived
let survived1: obj = survived.Get 1
let survived1': bool = survived1 :?> bool
// 取得方法2. Series として取得する.
// GetColumn メンバを使用.
// 要素の型が全て同じと分かっている場合に,あらかじめ型を指定して列を取得できる.
// この場合 Series<int, float> という型の指定は必須.
let age: Series<int, float> = train.GetColumn "Age"
let age': Series<int, float> = train |> Frame.getCol "Age"
let age1: float = age.Get 1
// 行を取得する場合も同じ.
let first: ObjectSeries<string> = train.Rows.[1]
let first': Series<string, obj> = train.GetRow 1
train.GetColumn
というメンバ関数と Frame.getCol
という静的メンバ関数は同じ機能をもちます.
基本的にメンバ関数の方は C# 向けの設計で,静的メンバ関数の方は F# 向けの設計になっています.
列の追加や削除も,それぞれ train.AddColumn
と Frame.addCol
,train.DropColumn
と Frame.dropCol
というペアが用意されています.
ただし,train.AddColumn
や train.DropColumn
はデータフレームを変更する方式,Frame.addCol
や Frame.dropCol
は新しいデータフレームを返す方式であることに注意してください.
// 新しいデータフレームを返す方式.
let isFemale =
train
|> Frame.getCol "Sex"
|> Series.mapValues (fun v -> v = "Female")
let isFemaleAdded = train |> Frame.addCol "IsFemale" isFemale
let cabinDropped = isFemaleAdded |> Frame.dropCol "Cabin"
// まとめて書くとこうなる.
train
|> Frame.addCol "IsFemale" (
train
|> Frame.getCol "Sex"
|> Series.mapValues (fun v -> v = "Female")
)
|> Frame.dropCol "Cabin"
// train を直接更新する方式.
// 以下のコードは train を変更するので注意!
let isFemale = train.GetColumn "Sex" |> Series.mapValues (fun v -> v = "Female")
train.AddColumn("IsFemale", isFemale)
let cabinDropped = train
train.DropColumn "Cabin"
可視化
データの可視化には Plotly.NET ライブラリを用います.
Plotly.NET.Interactive
を読み込むことで,ノートブック上にグラフを表示することができます.
#r "nuget: Plotly.NET.Interactive"
open Plotly.NET
let x: float seq = train.GetColumn "Age" |> Series.values
Chart.Histogram(X = x)
データクレンジング・データの前処理
機械学習の前処理として,データの欠損値の補完とカテゴリカルデータの数値化を行います.
今回は簡単のため,Name
などの前処理の難しい列は削除し,また特徴量エンジニアリングは行いません.
データ上は Embarked
や Cabin
なども欠損値を含みますが,文字列型の列については欠損値は空文字列として取り込まれているので,これらは欠損値として扱わないことにします.
まずは欠損値がどの列にどれくらいあるのかを確認するために関数を定義します.
let printCleansingInfo (frame: Frame<'R, 'C>) =
let missingValueCounts =
frame.Columns
|> Series.observations
|> Seq.map (
fun (key, col) ->
let length = col.ValuesAll |> Seq.length // 欠損値も含めた列の長さ.
let valueCount = col.ValueCount // 欠損値を除いた列の長さ.
let missingCount = length - valueCount // 欠損値の数.
key, missingCount
)
|> Seq.filter (fun (key, missingCount) -> missingCount > 0)
missingValueCounts
|> Seq.iter (fun (key, count) -> printfn "Column %A has %d missing values" key count)
if missingValueCounts |> Seq.isEmpty then
printfn "No missing values"
ここで,ObjectSeries<int>
の Values
メンバは欠損値を除いた要素の列を返すものです.
欠損値を含めた全要素を取得するには ValuesAll
メンバを用います.
train.Columns?Age.Values // : float seq
train.Columns?Age.ValuesAll // : float option seq; 欠損値は None
これを用いて,トレーニングデータとテストデータの欠損値の数を確認します.
printfn "Train data:"
printCleansingInfo train
printfn "Test data:"
printCleansingInfo test
Train data:
Column "Age" has 177 missing values
Test data:
Column "Age" has 86 missing values
Column "Fare" has 1 missing values
Age
と Fare
に欠損値があることが分かりました.
欠損値の補完とカテゴリカルデータの変換を行う関数 preprocess
を定義します.
次のコードセルを作成しましょう:
let preprocess (frame: Frame<int, string>) =
frame
let train' = train |> preprocess
let test' = test |> preprocess
train'.Print()
test'.Print()
いま,preprocess
は frame
を受け取りそのまま返す関数です.
ここから preprocess
の中身を追記して実行を繰り返し,train'
と test'
が所望の形式になるようにしていきます.
まず,Name
, Ticket
, Cabin
を削除します.
let preprocess (frame: Frame<int, string>) =
frame
+ // Name, Ticket, Cabin は削除する.
+ |> Frame.dropCol "Name"
+ |> Frame.dropCol "Ticket"
+ |> Frame.dropCol "Cabin"
次に Sex
を変換します.
Sex
は "male"
か "female"
の値をとるカテゴリカルデータですので,"male"
ならば 0
,female
ならば 1
に置き換えましょう.
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
+ // Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
+ |> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
続いて,Embarked
を変換します.
Embarked
は "S"
, "C"
, "Q"
の3通りの値と,空文字列 ""
をとりうるカテゴリカルデータです.
これは one-hot エンコーディングで数値に変換しましょう.
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
// Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
|> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
+ // Embarked は one-hot エンコーディングを行う.
+ |> Frame.dropCol "Embarked"
+ |> Frame.addCol "Embarked_S" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "S" then 1.0 else 0.0))
+ |> Frame.addCol "Embarked_Q" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "Q" then 1.0 else 0.0))
+ |> Frame.addCol "Embarked_C" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "C" then 1.0 else 0.0))
この処理は煩雑なので,oneHotEncode
関数を定義して分離します.
+ // one-hot エンコーディング.
+ let oneHotEncode (colName: string) (categories: string list) (frame: Frame<'R, string>) =
+ // 元の列を取得する.
+ let originalCol = frame.GetColumn colName
+
+ // カテゴリを表す列を追加する.
+ let addCategoryCol (category: string) (frame: Frame<'R, string>) =
+ let col =
+ originalCol
+ |> Series.mapValues (fun v -> if v = category then 1.0 else 0.0)
+
+ frame
+ |> Frame.addCol (colName + "_" + category) col
+
+ frame
+ // 元の列を削除し,
+ |> Frame.dropCol colName
+ // categories の各要素 に対して,addCategoryCol を適用する.
+ |> Seq.foldBack addCategoryCol categories
+
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
// Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
|> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
// Embarked は one-hot エンコーディングを行う.
- |> Frame.dropCol "Embarked"
- |> Frame.addCol "Embarked_S" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "S" then 1.0 else 0.0))
- |> Frame.addCol "Embarked_Q" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "Q" then 1.0 else 0.0))
- |> Frame.addCol "Embarked_C" (frame.GetColumn "Embarked" |> Series.mapValues (fun s -> if s = "C" then 1.0 else 0.0))
+ |> oneHotEncode "Embarked" [ "S"; "Q"; "C" ]
そして,Age
と Fare
の欠損値を補完します.
Fare
は欠損値が test
内に一つだけですので,単に平均値で補完してしまいます.
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
// Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
|> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
// Embarked は one-hot エンコーディングを行う.
|> oneHotEncode "Embarked" [ "S"; "Q"; "C" ]
+ // Fare は欠損値を平均値で置き換える.
+ |> Frame.replaceCol "Fare" (
+ frame.GetColumn "Fare"
+ |> Series.fillMissingWith (frame?Fare |> Stats.mean)
+ )
Age
は欠損値を平均値で置き換えた上で,欠損値にラベルを付けて対処します.
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
// Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
|> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
// Embarked は one-hot エンコーディングを行う.
|> oneHotEncode "Embarked" [ "S"; "Q"; "C" ]
// Fare は欠損値を平均値で置き換える.
|> Frame.replaceCol "Fare" (
frame.GetColumn "Fare"
|> Series.fillMissingWith (frame?Fare |> Stats.mean)
)
+ // Age は欠損値を平均値で置き換え,そのうえで欠損値であるか示す列を追加する.
+ |> Frame.replaceCol "Age" (
+ frame.GetColumn "Age"
+ |> Series.fillMissingWith (frame?Age |> Stats.mean)
+ )
+ |> Frame.addCol "Age_missing" (
+ frame.GetColumn "Age"
+ |> Series.mapValues (fun _ -> 0)
+ |> Series.fillMissingWith 1
+ )
最後に,train'
の Survived
列を数値に変換して,前処理は完了です.
- let train' = train |> preprocess
+ let train' =
+ train
+ |> Frame.replaceCol "Survived" (train?Survived |> Series.mapValues float)
+ |> preprocess
最終的な前処理のコードは以下の通りです:
// one-hot エンコーディング.
let oneHotEncode (colName: string) (categories: string list) (frame: Frame<'R, string>) =
// 元の列を取得する.
let originalCol = frame.GetColumn colName
// カテゴリを表す列を追加する.
let addCategoryCol (category: string) (frame: Frame<'R, string>) =
let col =
originalCol
|> Series.mapValues (fun v -> if v = category then 1.0 else 0.0)
frame
|> Frame.addCol (colName + "_" + category) col
frame
// 元の列を削除し,
|> Frame.dropCol colName
// categories の各要素 に対して,addCategoryCol を適用する.
|> Seq.foldBack addCategoryCol categories
let preprocess (frame: Frame<int, string>) =
frame
// Name, Ticket, Cabin は削除する.
|> Frame.dropCol "Name"
|> Frame.dropCol "Ticket"
|> Frame.dropCol "Cabin"
// Sex は `male` ならば `0.0`,`female` ならば `1.0` に置き換える.
|> Frame.replaceCol "Sex" (frame.GetColumn "Sex" |> Series.mapValues (fun s -> if s = "male" then 0 else 1))
// Embarked は one-hot エンコーディングを行う.
|> oneHotEncode "Embarked" [ "S"; "Q"; "C" ]
// Fare は欠損値を平均値で置き換える.
|> Frame.replaceCol "Fare" (
frame.GetColumn "Fare"
|> Series.fillMissingWith (frame?Fare |> Stats.mean)
)
// Age は欠損値を平均値で置き換え,そのうえで欠損値であるか示す列を追加する.
|> Frame.replaceCol "Age" (
frame.GetColumn "Age"
|> Series.fillMissingWith (frame?Age |> Stats.mean)
)
|> Frame.addCol "Age_missing" (
frame.GetColumn "Age"
|> Series.mapValues (fun _ -> 0)
|> Series.fillMissingWith 1
)
let train' =
train
|> Frame.replaceCol "Survived" (train?Survived |> Series.mapValues float)
|> preprocess
let test' = test |> preprocess
train'.Print()
test'.Print()
Pclass SibSp Parch Survived Sex Embarked_C Embarked_Q Embarked_S Fare Age Age_missing
1 -> 3 1 0 0 0 0 0 1 7.25 22 0
2 -> 1 1 0 1 1 1 0 0 71.2833 38 0
3 -> 3 0 0 1 1 0 0 1 7.925 26 0
4 -> 1 1 0 1 1 0 0 1 53.1 35 0
5 -> 3 0 0 0 0 0 0 1 8.05 35 0
6 -> 3 0 0 0 0 0 1 0 8.4583 29.69911764705882 1
7 -> 1 0 0 0 0 0 0 1 51.8625 54 0
8 -> 3 3 1 0 0 0 0 1 21.075 2 0
9 -> 3 0 2 1 1 0 0 1 11.1333 27 0
10 -> 2 1 0 1 1 1 0 0 30.0708 14 0
11 -> 3 1 1 1 1 0 0 1 16.7 4 0
12 -> 1 0 0 1 1 0 0 1 26.55 58 0
13 -> 3 0 0 0 0 0 0 1 8.05 20 0
14 -> 3 1 5 0 0 0 0 1 31.275 39 0
15 -> 3 0 0 0 1 0 0 1 7.8542 14 0
: ... ... ... ... ... ... ... ... ... ... ...
877 -> 3 0 0 0 0 0 0 1 9.8458 20 0
878 -> 3 0 0 0 0 0 0 1 7.8958 19 0
879 -> 3 0 0 0 0 0 0 1 7.8958 29.69911764705882 1
880 -> 1 0 1 1 1 1 0 0 83.1583 56 0
881 -> 2 0 1 1 1 0 0 1 26 25 0
882 -> 3 0 0 0 0 0 0 1 7.8958 33 0
883 -> 3 0 0 0 1 0 0 1 10.5167 22 0
884 -> 2 0 0 0 0 0 0 1 10.5 28 0
885 -> 3 0 0 0 0 0 0 1 7.05 25 0
886 -> 3 0 5 0 1 0 1 0 29.125 39 0
887 -> 2 0 0 0 0 0 0 1 13 27 0
888 -> 1 0 0 1 1 0 0 1 30 19 0
889 -> 3 1 2 0 1 0 0 1 23.45 29.69911764705882 1
890 -> 1 0 0 1 0 1 0 0 30 26 0
891 -> 3 0 0 0 0 0 1 0 7.75 32 0
Pclass SibSp Parch Sex Embarked_C Embarked_Q Embarked_S Fare Age Age_missing
892 -> 3 0 0 0 0 1 0 7.8292 34.5 0
893 -> 3 1 0 1 0 0 1 7 47 0
894 -> 2 0 0 0 0 1 0 9.6875 62 0
895 -> 3 0 0 0 0 0 1 8.6625 27 0
896 -> 3 1 1 1 0 0 1 12.2875 22 0
897 -> 3 0 0 0 0 0 1 9.225 14 0
898 -> 3 0 0 1 0 1 0 7.6292 30 0
899 -> 2 1 1 0 0 0 1 29 26 0
900 -> 3 0 0 1 1 0 0 7.2292 18 0
901 -> 3 2 0 0 0 0 1 24.15 21 0
902 -> 3 0 0 0 0 0 1 7.8958 30.272590361445783 1
903 -> 1 0 0 0 0 0 1 26 46 0
904 -> 1 1 0 1 0 0 1 82.2667 23 0
905 -> 2 1 0 0 0 0 1 26 63 0
906 -> 1 1 0 1 0 0 1 61.175 47 0
: ... ... ... ... ... ... ... ... ... ...
1295 -> 1 0 0 0 0 0 1 47.1 17 0
1296 -> 1 1 0 0 1 0 0 27.7208 43 0
1297 -> 2 0 0 0 1 0 0 13.8625 20 0
1298 -> 2 1 0 0 0 0 1 10.5 23 0
1299 -> 1 1 1 0 1 0 0 211.5 50 0
1300 -> 3 0 0 1 0 1 0 7.7208 30.272590361445783 1
1301 -> 3 1 1 1 0 0 1 13.775 3 0
1302 -> 3 0 0 1 0 1 0 7.75 30.272590361445783 1
1303 -> 1 1 0 1 0 1 0 90 37 0
1304 -> 3 0 0 1 0 0 1 7.775 28 0
1305 -> 3 0 0 0 0 0 1 8.05 30.272590361445783 1
1306 -> 1 0 0 1 1 0 0 108.9 39 0
1307 -> 3 0 0 0 0 0 1 7.25 38.5 0
1308 -> 3 0 0 0 0 0 1 8.05 30.272590361445783 1
1309 -> 3 1 1 0 1 0 0 22.3583 30.272590361445783 1
改めて欠損値の確認もしておきます.
printfn "Train data:"
printCleansingInfo train'
printfn "Test data:"
printCleansingInfo test'
Train data:
No missing values
Test data:
No missing values
欠損値を全て補完できました.
データの学習
続いてデータの学習を行います.
今回は以下の 2 つのモデルを使います:
- 多変量ロジスティック回帰,
- ランダムフォレスト.
.NET で利用できる機械学習フレームワークにはいくつか選択肢がありますが,今回は Accord.NET を使います.
こちらは C# で書かれたライブラリです.
#r "nuget: Accord.MachineLearning"
#r "nuget: Accord.Statistics"
まずデータフレームから予測変数と目的変数の配列を取り出します.
let inputs: float array array =
train'
|> Frame.dropCol "Survived"
|> Frame.toJaggedArray
let survived: int array =
train'
|> Frame.getCol "Survived"
|> Series.values
|> Seq.toArray
次に,ロジスティック回帰とランダムフォレストのモデルを作成し,学習を行います.
open Accord
open Accord.Statistics.Analysis
open Accord.MachineLearning.DecisionTrees.Learning
// Multinomial logistic regression.
let MLR = MultinomialLogisticRegressionAnalysis().Learn(inputs, survived)
// Random forest.
let RF = C45Learning().Learn(inputs, survived)
// 入力から生存者を予測する関数を取得する.
let MLRDecide: float array -> int = MLR.Decide
let RFDecide: float array -> int = RF.Decide
これで MLRDecide
,RFDecide
という予測関数が得られました.
これを使ってテストデータの生存者を予測し,提出用の CSV ファイルを作成します.
// テストデータの予測結果を計算する.
let predictions (decide: float array -> int) =
test'
|> Frame.rows
|> Series.observations
|> Seq.map (fun (id, row) ->
id,
row.Values
|> Seq.map Convert.ToDouble
|> Seq.toArray
|> decide
)
|> Seq.map (fun (id, pred) -> {|
PassengerId = id
Survived = pred
|})
// 予測結果を出力する.
let export (fileName: string) (predictions: {| PassengerId: int; Survived: int |} seq) =
let header = "PassengerId,Survived\n"
let body =
predictions
|> Seq.map (fun p -> sprintf "%d,%d" p.PassengerId p.Survived)
|> String.concat "\n"
let out = header + body
File.WriteAllText (__SOURCE_DIRECTORY__ + "/../submission/titanic/" + fileName + ".csv", out)
[
"MLR", MLRDecide
"RF", RFDecide
]
|> Seq.iter (fun (name, decide) ->
printfn "Decider: %s" name
let predictions = predictions decide
predictions
|> Seq.take 10
|> Seq.iter (fun p -> printfn "PassengerId: %d, Survived: %d" p.PassengerId p.Survived)
printfn "..."
predictions |> export (name + "-submission")
)
予測結果の CSV ファイルが submission/titanic
ディレクトリに出力されます.
PassengerId,Survived
892,0
893,0
894,0
895,0
896,1
...
PassengerId,Survived
892,0
893,0
894,0
895,0
896,1
...
これを Kaggle に提出します.
多変量ロジスティック回帰のスコアが 0.76794,ランダムフォレストのスコアが 0.77511 という結果になりました.
まとめ
データフレーム,可視化,学習といったデータサイエンスの基本的な処理を F# で行う手順を紹介しました.
.NET のライブラリやフレームワークを使うことができるのはもちろん,RProvider や Python.NET などのライブラリを介して R や Python のパッケージ群にもアクセスできるため,比較的マイナーな選択肢であるにもかかわらず利用可能なコード資産は十分に多いといえます.
ぜひデータサイエンスの第二言語として F# を試してみてください.
作成したプロジェクトは以下のリポジトリに置いてあります.
補遺: F# の構文について
以上のコードを読む上で,関数型言語特有の記法や流儀に慣れていないと意味がとりづらいと感じるであろう部分について説明しておきます.
関数の引数
F# (やその他の関数型言語)では,一引数関数の引数は括弧を省略して単に並べて書くことができます.
f(x) // OK
f x // OK
ただし,二引数以上の関数の引数は括弧を省略することはできません.
g(x, y) // OK
g x, y // NG
そのため,F# 向けの関数には,複数の引数をタプルでいっぺんに受け取るのではなく,一引数ずつ順番に受け取るように定義されていることが多いです.
g' x y z // = ((g' x) y) z と読む.
このように定義することで,引数を途中までしか与えないという書き方が可能になります(部分適用).
let h = g' x // 先に x のみ与え,
h y z // 後から y と z を与える.
なお,関数や引数が式になる場合は括弧を省略できません.
f x + y // = (f x) + y
f(x + y)
パイプライン演算子
f(g(h x))
のように複数の関数を順番に適用する式は,パイプライン演算子によって関数を繋げることで書くことができます.
f(g(h x))
x |> h |> g |> f
x
|> h
|> g
|> f
パイプライン演算子を用いることで,「h
を適用」→「g
を適用」→「f
を適用」という処理の順序がより明確になります.
また括弧を省くことができるため,インデントも少なくなり読みやすくなります.
パイプライン演算子はしばしば部分適用と組み合わせられます.
f x (g y z (h w))
w
|> h
|> g y z
|> f x
そのため,F# ではパイプラインで順々に受け渡していくことが想定される引数は最後に配置されることが多いです.
-
とはいえ,後に述べるように,関数型言語のスタイルを使いこなすことで F# の強みを最大限に活かすことができます. ↩
-
ただし,C# で使いやすい書き方と F# で使いやすい書き方というのは少し違うので,特に F# で書くときには相手の関数で使いやすいかどうかを考慮する必要はあります. ↩
-
変数の参照については,明示的にこの変数をこっちの言語で使うという宣言が必要です.詳しくは拡張機能の説明を参照してください. ↩
-
モナドを使ってもよいでしょう.F# は Haskell の do 記法に相当するコンピューテーション式という構文を持っていて,関数型言語の枠組みの中でモナディックな文脈をもった手続き型処理を記述することができます. ↩