Scalaでデータを読み込んでデータ加工が出来ないか諸々模索した簡単なまとめです。
データの加工などはScalaではなく、別の言語(例えばpythonなど)の方がお手軽かつ、簡単に書ける場合も多々あると思います。ですが、Scalaの関数型の特性をうまく使えば、よりわかりやすく、高度な処理が出来るデータ処理が可能になるはずと思い記事(至らない部分は多々あると思いますが)かきました。(一応こちらの記事の続き物という位置づけです)
なお、この記事には機械学習的なアルゴリズムは一切でてきません。コードなどはこちらの本を参考にしました。
データの前処理でしたい事
データ前処理は大まかにはファイルの読み込み=>読み込んだデータを変換&計算処理=>処理したデータを出力
の順に処理が進みますが、少しデータの処理の観点で困るのは、ファイルのカラム毎に行いたい処理が異なる場合が多々あるという事です。
例えば名前のカラムならば、そのままString型でもいいかもしれませんが、年齢や身長のような数値の情報が入ったカラムならばIntないしはDoubleにキャストする必要があるかもしれませんし、さらにそれらの数値から平均値を出す処理も必要になるかもしれません。 そのようなオーダーメイドなデータの処理に対応するため、カラム毎に関数を渡し、データを処理する方法がうまく出来ないか模索していきたいと思います。
使用するデータ
例として以下のような名前、身長、年齢が入ったcsvファイルを処理したいとします。
名前,身長,体重,性別
丸雄,180,50,男
陽子,150,50,女
一郎,170,60,男
次郎,160,50,男
三郎,150,40,男
四郎,140,30,男
さて、ここで、上のデータを
- 身長をDouble型に変換
- 身長と体重からBMI値を算出
- 男なら
1
女なら0
にラベル化
にして出力したいと思います。カラム毎にキャストやら別カラム同士の計算やらなんやらが入り組んでて、すこし面倒ですね。
ファイルを読み込み、処理するclass
を定義する
下記のようにファイルを読み込み、加工するclass
を定義します。
import scala.io.Source
import scala.util.Try
class DataSource(path: String, delm: String) {
// ファイルをロードし、ヘッダーとデータに分ける
private def load: Try[(Seq[Seq[String]])] = Try {
val src = Source.fromFile(path)
val (head, rawFields) = src.getLines().map(_.split(delm).toSeq).toArray
match {
// ヘッダーとヘッダー以外を分ける
case x => (x.head, x.tail)
}
src.close()
// ヘッダーを除いた中身を返す
rawFields.toSeq
}
// カラムごとに引数として、関数の値のListを渡すことで、その関数に応じた計算処理を行う
def |> : PartialFunction[Seq[(Seq[String]) => Double], Try[Seq[Seq[Double]]]] = {
case fields: Seq[(Seq[String]) => Double] if fields.nonEmpty => load.map(data => {
// 行毎にfにそった変換処理を行う
val convert: ((Seq[String]) => Double) => Seq[Double] = (f: Seq[String] => Double) => data.map(f)
fields.map(convert)
})
}
}
def load
はファイルを読み込み。形式(ここではCSV)にそってデータをパースします。
|>
は部分関数を返り値として返すメソッドです。返ってきた部分関数にカラム毎にどういう処理をするかを定義した関数のList
を与えてやることで、データの処理が行えます。(ここだけだと、返り値を部分関数にする必要はあまりありませんが、こちらの記事と同じようにパイプラインのように繋げやすくなるようにすることを考えて部分関数にしました。)
実行してみます
object main extends App {
final val CSV_DELM = ","
// ヘッダーの位置を定義
val LENGTH = 1
val WEIGHT = 2
val GENDER = 3
// Doubleに変換する
def toDouble(v: Int): Seq[String] => Double = (s: Seq[String]) => s(v).toDouble
// v1(身長)とv2(体重)からBMIを計算する
def toBMI(v1: Int, v2: Int): Seq[String] => Double = (s: Seq[String]) =>
s(v1).toDouble / Math.pow(s(v2).toDouble * 0.01, 2)
// markerに一致するならば1を、そうでないならば0を返す
def toLabel(marker: String, v: Int): Seq[String] => Double = (s: Seq[String]) => if (s(v) == marker) 1 else 0
// |>に渡すための関数のSeqを作成する.この関数に沿って、カラムごとに計算処理が行われる。
val extractor: Seq[(Seq[String]) => Double] = toDouble(LENGTH) :: toBMI(WEIGHT, LENGTH) :: toLabel("男", GENDER) :: Nil
// csvファイルがおいてあるpath
val path = "/hoge/csvData.csv"
// データのロードと extractorの関数に沿ってデータを整形
val data = new DataSource(path = path, delm = CSV_DELM) |> extractor
}
toDouble
,toBMI
,toLabel
により、どのカラムに対し、どういう変換(計算)処理を行うかを定義し、それをList
に詰めて、|>
に渡すことで、csv
のデータ処理を行えます。
実行結果
見やすいように上のdata
の中身をcsvにパースし、ヘッダーを付けると以下のようになります。
身長,BMI,性別
180.0,15.43,1.0
150.0,22.22,0.0
170.0,20.76,1.0
160.0,19.53,1.0
150.0,17.77,1.0
140.0,15.30,1.0
まとめ
コード的に至らないところは多々あるかもしれませんが、やりたい事は出来ました。仮に適性体重などの新しい計算処理を追加したいときは|>
に渡す関数を増やすだけでよいのでとても楽です。