C# on Macによるsparseなデータに対する機械学習

こんにちは、AIチームの杉山です。
前回の記事に引き続き、C#(ML.NET)による機械学習に取り組みます。前回の導入記事では、Pythonと比べて型周りの扱いなどで辛い点が目立ったため、今回は優れていると謳われている大規模データの扱いにフォーカスして検証を行います。

大規模なデータ

ML.NETは特徴量、レコード数共に数百万のオーダーを持つような大規模データを扱うことができ、1TBまでのデータを処理することができるとされています。そこで、レコード数230万・特徴量数320万のこちらのデータセットに対して二値分類を行ってみます。
タスクに取り掛かる前にデータセットの説明を簡単にします。こちらのデータセットはURLをレコードとして持ち、匿名化された特徴量が付与されています。各URLが有害であるか無害であるかをラベルとして持ち、それを予測するタスクのためのデータセットです。特徴としては、特徴量全体では320万種類あるものの、各レコードはそれぞれ200前後の項目にのみ値を持つ、いわゆる疎(sparse)なデータ構造をしています。このようなsparseなデータ構造は、効率的にデータを表現するために一般的にsparse matrix formatと呼ばれるデータ形式で保持され、有名なところではlibsvm data formatや、Pythonのsklearnではsvmlight data formatといったところで使われています。以下にデータ例を示します。

-1 4:0.0788382 5:0.124138 6:0.117647 11:0.428571 16:0.1 17:0.749633 18:0.843029 19:0.197344 21:0.142856 22:0.142857 23:0.142857 28:1 33:0.0555556 41:0.1 54:1 56:1 64:1 70:1 72:1 74:1 76:1 82:1 84:1 86:1 88:1 90:1 92:1 94:1 96:1 102:1 104:1 106:1 108:1 110:1 112:1 155:1 190:1 204:1 359:1 360:1 361:1 1306:1 1309:1 1310:1 1311:1 2408:1 2921:1 2923:1 7000:1 7001:1 7002:1 7005:1 7006:1 7007:1 7009:1 7010:1 7759:1 7762:1 155153:1 155154:1 155155:1 155156:1 155157:1 155158:1 155159:1 155160:1 155161:1 155163:1 155164:1 155165:1 155166:1 155168:1 155169:1 155170:1 155172:1 155173:1 155174:1 155175:1 155176:1 155177:1 155178:1 155179:1 155180:1 155181:1 155182:1 155183:1 155194:1 155195:1 155196:1 155197:1 155198:1 155199:1 155200:1 155201:1 155202:1 155203:1 155204:1 155205:1 155206:1 155207:1 155208:1 155209:1 155210:1 155211:1 155212:1 155213:1 945789:1 1988571:1 2139257:1 2987739:1 3224681:1

先頭がラベルを表し、以降は値を持つ列のみを列名:値 の形式で表現しています。

ML.NETでは型の取り回しの関係上上記のフォーマットに加え、2番目に特徴量数を付与する必要があります。今回のデータは3231961列あるため、全てのレコードの2番目に3231961を付与します。

-1 3231961 4:0.0788382 5:0.124138 6:0.117647 11:0.428571 16:0.1 17:0.749633 18:0.843029 19:0.197344 21:0.142856 22:0.142857 23:0.142857 28:1 33:0.0555556 41:0.1 54:1 56:1 64:1 70:1 72:1 74:1 76:1 82:1 84:1 86:1 88:1 90:1 92:1 94:1 96:1 102:1 104:1 106:1 108:1 110:1 112:1 155:1 190:1 204:1 359:1 360:1 361:1 1306:1 1309:1 1310:1 1311:1 2408:1 2921:1 2923:1 7000:1 7001:1 7002:1 7005:1 7006:1 7007:1 7009:1 7010:1 7759:1 7762:1 155153:1 155154:1 155155:1 155156:1 155157:1 155158:1 155159:1 155160:1 155161:1 155163:1 155164:1 155165:1 155166:1 155168:1 155169:1 155170:1 155172:1 155173:1 155174:1 155175:1 155176:1 155177:1 155178:1 155179:1 155180:1 155181:1 155182:1 155183:1 155194:1 155195:1 155196:1 155197:1 155198:1 155199:1 155200:1 155201:1 155202:1 155203:1 155204:1 155205:1 155206:1 155207:1 155208:1 155209:1 155210:1 155211:1 155212:1 155213:1 945789:1 1988571:1 2139257:1 2987739:1 3224681:1

今回のデータセットは1日分ごとに別のファイルになっている(全121ファイル)ため、各ファイルに対して上記の付与を行い、任意の場所に保存しておきます。データの合計サイズは約2.2GBでした。

実験

今回は、ML.NETでsparseなデータ構造に対応している二値分類の手法のうち、FieldAwareFactorizationMachine[Juan+, RecSys2016]をチュートリアルを参考に試したいと思います。FieldAwareFactorizationMachineに関してはこちらの記事が詳しいです。

それでは実装に入ります。大まかな流れは前回と同様です。
まずはML Contextを作成します。

MLContext mlContext = new MLContext();

次にデータの読み込みです。今回もテキストファイルを扱うため、LoadFromTextFileメソッドで読み込みます。今回はsparseなデータ構造の読み込みであるため、allowSparse=trueとします。読み込み時に対象データの型を指定する必要があるため、以下のようにラベルと特徴量を定義したクラスを作成しておき、ジェネリクスに与えます。LoadFromTextFileはLazy Loadであるため、この時点では評価は行われず実際にはデータは読み込まれていない状態です。

public class UrlData
{
    [LoadColumn(0)]
    public string LabelColumn;

    [LoadColumn(1, 3231961)]
    [VectorType(3231961)]
    public float[] FeatureVector;
}

var fullDataView = mlContext.Data.LoadFromTextFile<UrlData>(path: <YOUR DATA PATH>,
                                          hasHeader: false,
                                          allowSparse: true);

データを学習データとテストデータに分割します。今回も簡単のためdevデータは用意していません。

TrainTestData trainTestData = mlContext.Data.TrainTestSplit(fullDataView, testFraction: 0.2, seed: 1);
IDataView trainDataView = trainTestData.TrainSet;
IDataView testDataView = trainTestData.TestSet;

二値分類のためにラベルをマッピングします。今回は有害URL判定タスクなので、有害URLをtrue(positive)、そうでない方をfalse(negative)とします。出力カラム名、マッピングのKeyValuePair、入力カラム名をそれぞれ指定してMapValueメソッドに渡すことでマッピングが行われます。

var UrlLabelMap = new Dictionary<string, bool>();
UrlLabelMap["+1"] = true; // 有害URLをtrueとする
UrlLabelMap["-1"] = false; //無害URLはfalse
var dataProcessingPipeLine = mlContext.Transforms.Conversion.MapValue("LabelKey", UrlLabelMap, "LabelColumn");

あとはパイプラインに学習器としてFieldAwareFactorizationMachineを指定し、ラベル名、特徴量名を与えて学習を行います。

var trainingPipeLine = dataProcessingPipeLine.Append(
    mlContext.BinaryClassification.Trainers.FieldAwareFactorizationMachine(labelColumnName: "LabelKey", featureColumnName: "FeatureVector"));    

var mlModel = trainingPipeLine.Fit(trainDataView)

手元の環境(OS:Mac, CPU:Corei7 2.6GHz, Memory:16GB)では今回のデータ量が111秒で学習が終わりました。学習が終わると検証用データに対して評価を行います。

var predictions = mlModel.Transform(testDataView);
var metrics = mlContext.BinaryClassification.Evaluate(data: predictions, labelColumnName: "LabelKey", scoreColumnName: "Score");            

Evaluateメソッドで評価を行った結果の戻り値に二値分類で使用される一通りの指標が入っています。metrics.Accuracyやmetrics.AreaUnderRocCurveといった形でAccuracyやAUCが得られるので、一通りの結果を出力してみます。

Accuracy: 98.18%
Area Under Curve:      99.88%
Area under Precision recall Curve:  99.77%
F1Score:  97.22%
LogLoss:  0.06

問題として簡単だったかもしれませんが、かなりの精度で予測するモデルとなりました。
新規のデータに対して予測を行いたい場合は、モデルをPredictEngineに変換してかPredictメソッドで予測を行います。

var predEngine = mlContext.Model.CreatePredictionEngine<UrlData, UrlPrediction>(mlModel);

UrlPrediction predictionResult = predEngine.Predict(sampleData);

predictionResult.Predictionで二値分類の結果(今回はLabelKeyに指定した+1/-1)とpredictionResult.Scoreで予測スコアを取得できます。

終わりに

今回は、ML.NETを用いてsparseなデータを扱ってみました。感想としては、sparseであることや大容量なデータであることを意識せずに扱うことができてよかったと思いました。しかし、前回同様型の取り回し(今回で言えば事前に特徴量の数を調べて元データにもデータ定義クラスにも付与する必要がある)に難があるなという印象です。慣れ親しんだC#とVisual Studioで実装できる以外に明確なメリットが得られにくいため、今後の開発に期待しつつ業務ではPythonを使っていこうと思います。

参考

Justin Ma, Lawrence K. Saul, Stefan Savage, and Geoffrey M. Voelker,
Identifying Suspicious URLs: An Application of Large-Scale Online Learning
Proceedings of the International Conference on Machine Learning (ICML), pages 681-688,
Montreal, Quebec, June 2009.