デザインパターンとリファクタリング

16 December 2018 by PPAP

この記事はEnigmo Advent Calendar 2018の16日目です

デザインパターンとリファクタリング

こんにちは。iOSチームでエンジニアをやっています

今チームでは、プロジェクトの進行と並行してリファクタリングを行なっています

対象プロジェクトは、MVVMのデザインパターンを多用しています

そのプロジェクトをどうリファクタリングしていったかをツラツラと書いていこうかと思います

TL;DR

  • デザインパターンって、設計パターン。うまく対処するためにどう設計していけばいいかをまとめたものです
  • デザインパターンを各画面で分けよう
  • 簡単な実装なのに、MVVMを利用したら複雑になってしまった、であれば、設計を間違えている

リファクタリング

リファクタリングについては、短期間で見ると、ビジネスとしてぶっちゃけ一円にもなりません。
しかし、リファクタリングをする、しないでは、未来のプロジェクトの進行速度に影響していきます。

なぜリファクタリングをしたのか

  • メンテ、新規開発がしづらい
  • 数行いじると全然違う画面でエラーがでてしまう
  • どの画面がどのViewControllerなのかわからない
  • Swiftらしいコードに
  • MVVMなのに、UIViewControllerがふとっている

着手前

[アプリ設計]

  • RxSwift / RxSwift Community の様々なライブラリを使って MVVM を 試してみた実装
  • iOS5の時代のライブラリをそのまま使い続け、iOSの新しい機能が実装されていない

[構成]

  • Managers
    • サーバAPIへアクセスする
    • レスポンスをModelへパースする
    • パースしたModelの一部を、インスタンス変数で管理
    • シングルトン
  • ViewController
    • 別ViewControllerに遷移
    • Userのアクションへのリアクション
  • View
    • SnapKitによるレイアウト実装
  • ViewModel
    • Managerにデータを要求、受け取ったデータを管理
    • UIViewControllerにアクセスしてUIの更新等を行う
  • Model
    • データ

[アプリ設計]

[構成]

  • Components
    • Model
    • データ
    • ViewController
    • 別ViewControllerに遷移
    • Userのアクションへのリアクション
    • Delegate / DataSourceの実装
    • Storyboard / Xib
    • レイアウト実装
    • View
    • iPhoneのサイズによるFontサイズの調整など
  • Network
    • APIClient
    • サーバに要求する
    • Responseを生成する
    • Response
    • Codableによって APIをモデル化

比較

Before

After

やったこと

  1. Componentsというディレクトリを作ってその中に各画面毎のfileをいれるようにしました
  • その画面に関係するfileが明確になり、関係ないものは使わないようにチームで心がけるようになりました
  • 各Componentでデザインパターンを変更できるようにしたので、あった設計をできるようにしました
  • 簡単な画面については、コード量が少ない設計に変更
  1. APIClient / JSONデコーダー ともに複数あったので、新しいAPIClientを作成し、Codableを使うように変更

    -> 古いものはまとめて削除。Objective-Cの時のような、json["key"] as? Intのような実装をなくしました

  2. ソースコードに対するコメント / BTS なぜその実装になったのか等記載がない

    -> 現状を知り着手しやすいように、複雑な処理になる部分は、シーケンス図 / コメントで動作を記載

  3. 一行直すだけで、関係ないと思ったところでエラーが起こる

    -> Component間で扱うデータを減らし、非結合にしてComponent間の影響を減らしました

  4. Manager / Utility クラスは、シングルトンで実装

    -> シングルトン実装のクラスを極力減らす。シングルトンにするとpropertyをつけたくなる人がいるので避けます

  5. UIはSnapKitのみで実装

    -> Storyboard / Xib で実装、IBInspectable, IBDesignableを使い、GUIで状況を把握しつつ実装
     デザイナーが作ってくれているレイアウトに沿った物を作れる ( SnapKitだけの時、cornerRadiusなど漏れが発生していた )

まとめ

よかったこと

  • コードの削除をかなりできた

    着手前と今の差分:

    2467 files changed, 142326 insertions(+), 271177 deletions(-)
    
  • すべての画面に、MVVMをあてようとして無理している部分が多々あり結果、読みにくいコードになっていたのを直せた

  • レビューする際に、コードよりも、Storyboard / Xib で見た方がわかりやすかった

    Before:

    override func viewDidLoad() {
    super.viewDidLoad()
    .... 40行ほど
    }
    

    After:

    override func viewDidLoad() {
    super.viewDidLoad()
    imageView.image = UIImage(named: ... ) // 1行のみ
    }
    

  • ドキュメント作成した事で効率改善が行えた

    • デザイナーとの画像受け渡しは、Xcodeから直接やってもらえることになった
    • ドキュメント自体もレビューされるので、その際に共有できた
    • レビューする側も、どういう事をしたいロジックなのか理解できた

リファクタリングを行う時、考慮すること

  • チーム全員なので、非エンジニアにも理解してもらわないといけない
  • リファクタしたいところを共有しておく
  • 企画・ディレクター案件がある際、その画面対応の際にまとめて行う
  • ディレクターには、案件を画面毎にまとめるように整理してもらう

e.g.
– ホームが重いので改善する作業中に、haptic feedbackの導入や、お気に入りのハートを他の画面と同期するように仕組みを入れました
– 検索画面を改修する際に、保存した検索条件に起因する部分もまとめて、書き直し整理を行ったりしました

Pocket

Leave a comment | Categories: Uncategorized

Org-modeを半年くらい使ってみた

13 December 2018 by Okawa

Org-modeを半年くらい使ってみた

Enigmo Advent Calendar 2018の12日目の記事です。

こんにちは、エンジニアの@t4kuです。半年ほどorg-modeを使ってメモや、日々のタスク管理を行ってきたのでやってみた感想を共有しようと思います。

org-modeとは何か?

org-modeとはemacs上で動作するアウトライナーです。

アウトライナーは有名なところでいうとMacアプリではOmnioutliner
webアプリでもworkflowyなどがあります。

workflowlyについてはこちらの紹介記事がわかりやすいです(丸投げ)

無料で無制限のアウトライナーDynalistのWorkflowyとの比較メモ

課題やタスクのブレークダウンなど考えをまとめたりするのに
org-modeではこのようなツリー構造をプレーンテキストで書いておけば
鞍上いい感じに表示してくれます。

markdownでも同じようなことができますが、ノードを移動したりインデントを変えたりするのが面倒なのでそういう用途でmarkdownを使う人はいないと思います。

また、スケジュール機能やTODOやタグやクロック機能もあるのでこれだけで
見積もりや振り返りがプレーンテキストで完結します。

org-modeのここがいい

自分が使っていて特によいと思った機能です。

テーブル表記の入力が楽

勝手にフィールドの幅を調整してくれたりなかなか便利だなと思いました。

体験すると、qiitaのmarkdownでtableを書くことが苦行というかほとんど罰ゲームに感じるようになってきました。

画像や数式が差し込める

プレーンテキストでありながら画像も入れれるので、gitなどで履歴管理しつつ最低限わかりやすい
ビジュアルをキープできるので、プログラマのメモとしてはいいバランスだと思います。

Latex記法で書いたものは数式が表示されます。

ソースコードが実行できる

org-babelという拡張があるのでソースコードブロックで書いたものを評価して、結果を表示できます。

※ob-ipythonというjupyterに繋ぐ拡張が必要ですが
ob-ipython
org-babel integration with Jupyter for evaluation of (Python by default) code blocks

スケジュール機能(アジェンダ)

ノードにスケジュールを設定しておくと、アジェンダコマンドを利用してその日にスケジュールされたタスク一覧(アジェンダビュー)を表示することができます。

※実際のファイルが出せないのでテキトーなタスクなのでわかりにくくてすいません

アジェンダビューはスケジュール日別に出したり、deadlineごとに出したり、タグごとに出したりいろいろカスタマイズできますが、自分の場合は、オペレーション系のタスクとプロジェクトごとのタスクごとに一覧化するようにして、一日毎の作業を管理する別のorgファイルにコピーします。

名称未設定2.png

一日のタスクを直列に並べると、あんまり余計なことを考えずにただこなしていけるような気がします。

クロック機能が便利

各ノードにTODOステータスやスケジュールを設定するだけでなく、実際に作業をする時にクロックインすると
時間を記録してくれます。また、任意の期間でレポートを作成できます。

活用法

何も考えずに単体のorgファイルをそのまま使っててもいいのですが、自分の場合は下記のように
Dropbox配下のディレクトリを分けてメモと予定/振り返りを管理しています。

orgファイル間は簡単にリンクを貼って辿れるので、アジェンダファイル(Agenda/work.org)内のトピックから必要なファイルにリンクを張っておけば、だいたい事足ります。

どのようにファイルをオーガナイズするかやどういう単位で分割するかということについては深遠なテーマで、半年くらい使った素人ではまだキャッチアップできない(というか一生できる気がしない)のですが、下記のyoutubeシリーズはすごく勉強になりました。

org-mode tutorials

半年くらい使ってみた感想

何をどこに書くべきかが決まってきてキーバインドにも馴染んでくると、フローを壊さずに開発してるときもも打ち合わせしてるときも、アイデアをためておけるので、何かのインタラプションがあっても、安心して忘れられる他、ググる回数やブラウザで遷移する回数が減ったきがします。

また、テキストなのですべてgitで管理できるので、週次ごとにプルリクエストを作るようにすると
diffを見れば振り返るのが一目瞭然です。

あと副産物ですが、普段プログラミングをする際はvimを使っているのですが、org-modeのためだけにemacsを使うようになり少しemacsの良さがわかってきました。そして両方の宗教を理解することで、世界平和に少し貢献できるような気がしてきました。

参考

Pocket

Leave a comment | Categories: Uncategorized

React DnDでスマホでもドラッグアンドドロップ

12 December 2018 by Yamamoto

Enigmo Advent Calendar 2018の12日目の記事です。

注意: この記事のサンプルコードで使われている各ライブラリのバージョンは下記になります。

react 16.4.0
react-dnd 4.0.2
react-dnd-html5-backend 4.0.2
react-dnd-touch-backend 0.5.1

React DnD

Reactでドラッグアンドドロップでの並び替えを実装する際によく使われるのがReact DnDというライブラリです。
このライブラリではHTML5のDrag and Drop APIを利用してドラッグアンドドロップを実現していますが、このAPI自体がスマートフォンなどのタッチデバイスには対応しておらず、スマホでそのままドラッグアンドドロップを実装することができません。

TouchBackend

React DnDを使う際、ドラッグアンドドロップしたいコンポーネントを DragDropContext という HOC(Higer Order Component) に渡します。
この DragDropContext の最初の引数に渡すのは通常、 HTML5Backend というバックエンドモジュールです。

import HTML5Backend from 'react-dnd-html5-backend'
import { DragDropContext } from 'react-dnd'

class YourApp {
    /* ... */
}

export default DragDropContext(HTML5Backend)(YourApp)

前述した通りタッチデバイスの場合はこの HTML5Backend は使えません。 しかしタッチデバイス対応した TouchBackendというものがあるのでそちらを使います。

import HTML5Backend from 'react-dnd-html5-backend'
import TouchBackend from 'react-dnd-touch-backend';
import { DragDropContext } from 'react-dnd'

const isTouchDevice = () => {
 /* タッチデバイス判定 */
}

class YourApp {
    /* ... */
}
export default DragDropContext(isTouchDevice() ? TouchBackend : HTML5Backend)(YourApp)

これだけでタッチデバイス対応ができました。
しかし、 HTML5Backend のようにいい感じにプレビューされません。

HTML5Backendではちゃんとプレビューされている



TouchBackendではプレビューされていない!
※ ChromeのDevToolsでスマートフォンをエミュレートして録画しているためマウスカーソルが表示されています。

DragLayer

React DnD にはDragLayerという、ドラッグ時のプレビュー表示をカスタマイズできるAPIがあります。
これを使うことでタッチデバイスでもいい感じのプレビューを表示することができます。

利用側のサンプルコードは以下です。

import React from 'react'
import DragLayer from 'react-dnd/lib/DragLayer'
import TouchBackend from 'react-dnd-touch-backend';
import { DragDropContext } from 'react-dnd'

function collect(monitor) {
  const item = monitor.getItem()
  return {
    currentOffset: monitor.getSourceClientOffset(),
    previewProps: item && item.previewProps,
    isDragging:
      monitor.isDragging() && monitor.getItemType() === 'IMAGE'
  }
}

function getItemStyles(currentOffset) {
  if (!currentOffset) {
    return {
      display: 'none'
    }
  }

  const x = currentOffset.x
  const y = currentOffset.y
  const transform = `translate(${x}px, ${y}px) scale(1.05)`

  return {
    WebkitTransform: transform,
    transform: transform,
  }
}

class PreviewComponent extends React.Component {
  render() {
    const { isDragging, previewProps, currentOffset } = this.props
    if (!isDragging) {
      return null
    }

    return (
      
{/*...*/}
) } } const DragPreview = DragLayer(collect)(PreviewComponent) class YourApp { render() { return (
{/* ... */}
) } } export default DragDropContext(TouchBackend)(YourApp)

かんたんに解説

DragLayer の引数 collect 関数ではDragLayerMonitorのオブジェクトが渡されます。
monitor.getItem()DragSource にアクセスすることができ、 任意で渡した props(今回の場合は previewProps という名前で渡していますが、どんな名前でも渡すことができます) にアクセスできます。
また、 monitor.isDragging で実際にドラッグされているか判定することができます。
同一画面の他のコンポーネントでもドラッグアンドドロップするために、 DragDropContext が複数ある場合は monitor.getItemType() でどのコンテキストなのかを判定するとよいでしょう。
プレビューがタッチした部分に追従するように monitor.getSourceClientOffset() を使ってオフセット座標を返しておきます。
collect 関数の返り値のオブジェクトはそのままプレビュー用のコンポーネントで props として受け取ることができます。
getItemStyles 関数では受け取った props.currentOffset を使ってCSSを調整しています。

DragDropContext に渡したコンポーネントで DragLayer を描画することで、ドラッグ時にプレビューを表示することができます。



スマホでもプレビューができた!
※ ChromeのDevToolsでスマートフォンをエミュレートして録画しているためマウスカーソルが表示されています。

最後に

スマートフォンなどのタッチデバイスでHTML5のようなドラッグアンドドロップを実現する方法を解説しました。
実際に実装する際は、TouchBackendのリポジトリ に完全に動作するサンプルがあるのでそちらも参考にしてみてください。

参考リンク

http://react-dnd.github.io/react-dnd/about
https://github.com/yahoo/react-dnd-touch-backend

Pocket

Leave a comment | Categories: Uncategorized

Apache Airflow で実現するSQL ServerからBigQueryへのデータ同期

11 December 2018 by Kimura

はじめに

この記事はEnigmo Advent Calendar 2018の11日目です。

Enigmoでは、データウェアハウス(DWH)としてBigQueryを使っていて、サービスのアクセスログやサイト内の行動ログ、データベースのデータをBigQueryへ集約させています。

データベースからBigQueryへのデータ同期にはApache Airflowを使っていて、今日はその仕組みについて紹介します。

Apache Airflowとは

Airflowは、pythonでワークフロー(DAG)を定義すると、そのとおりにタスク(オペレーター) をスケジューリングして起動してくれるツールです。GCPでもGKE上でAirflowを動かすCloud Composerというサービスが提供されていてご存知の方も多いと思います。

データの処理の単位をオペレータで定義し、その処理の依存関係を反映したワークフローをDAGで定義してやればデータ処理のパイプラインを実現することが可能となります。

DBからBigQueryへのデータパイプライン

データの流れ

データの流れとしては、上の図の通り大きく2フェーズに分かれていて、まずはDB(SQL Server)からGoogle Cloud Storage(GCS)へデータをアップロードしています。その次にGCSからBigQueryへそのデータをロードしています。

それぞれのフェーズをAirflowのタスクの単位であるオペレーターで実現していて、さらに2つのオペレーターはそれぞれ同期するテーブルごと別のタスクとして存在し、それらをDAGという1つのワークフローの単位でまとめています。

SQL ServerからGCSへ

JdbcToGoogleCloudStorageOperator

SQL ServerからGCSへのデータの移動は JdbcToGoogleCloudStorageOperator というAirflowのオペレーターが担当します。

DBがMySQLの場合はMySqlToGoogleCloudStorageOperatorというAirflowに組み込みのオペレーターがあるんですが、バイマのデータベースはSQL Serverなので、JDBCのクライアントで同様の働きをするオペレーターを自前で作ったものが JdbcToGoogleCloudStorageOperator です。Airflowのプラグインとして公開しています。

https://github.com/enigmo/jdbc_to_gcs_airflow_plugin

このオペレータでの処理は、まずDBからSQLでデータを抽出し、一度JSONL形式のファイルとしてのオペレーターが動くサーバーのローカルに保存され、それがGCSへアップロードされるという流れです。BigQueryへロードするときにスキーマ定義が必要なので、データファイルとは別にスキーマ定義のファイルもJSON形式でGCSへアップロードされます。

スケジューリングと更新差分抽出の仕組み

DAGのスケジューリング間隔は1時間に設定しています。するとAirflowは時間を1時間ごとに期間を分けてDAGにその期間の開始時刻(execution_date)、終了時刻(next_execution_date)をテンプレートのパラメーターとして渡してくれます。それらを
データ抽出SQLのWHERE句のところでレコードの更新日時を記録するカラム(下の例ではupdated_at)を基準に期間指定すると、その期間に更新があったレコードだけが抽出され、BigQuery側へ送られる仕組みです。

SELECT 
  * 
FROM 
  table1 
WHERE 
  "{{execution_date.strftime('%Y-%m-%d %H:%M:%S')" <= updated_at 
  AND updated_at < "{{next_execution_date).strftime('%Y-%m-%d %H:%M:%S')}}"

もし間隔を変えてもDAGを編集することなくSQLがその期間に合わせて変わってくれるので便利です。

GCSからBigQueryへ

GoogleCloudStorageToBigQueryOperator

GCSからBigQueryへはその名の通りAirflow組み込みのGoogleCloudStorageToBigQueryOperatorというオペレーターがやってくれます。

BigQuery側のデータセットは同期元DBのデータベース単位、テーブルは同期元DBのテーブル単位に分けています。BigQuery側のテーブルはDB側のレコードの更新日ごとに日付分割しています。

BigQueryの更新はDMLは使わずに、ファイルを読み込みジョブで更新されます。そうするとDB側のレコードが更新されるとBigQuery側には重複してレコードが溜まっていくのですが、それは後述の重複除外ビューで解決しています。

BigQuery側でレコードの重複を除外

BigQuery側のテーブルでは、次のようなSQLでビューテーブルを作ることで、同期元のDBでレコードが何度も更新されても常に最新のレコードしか現れない仕組みになっています。

この例は、主キーがidで更新日時のカラムが updated_at の場合のSQLです。同一idに対して常に最新のupdated_at をもつレコードしかこのビューには出てきません。

SELECT *
FROM (
    SELECT *, ROW_NUMBER() OVER (
      PARTITION BY id
      ORDER BY updated_at DESC)  etl_row_num
    FROM
        `db1.table1_*`)
WHERE etl_row_num = 1

Airflowで便利だった機能

Airflowの機能でこの仕組みをつくるのに助けられた機能がいくつかあったので紹介します。書ききれてないですが、ほかにもたくさんあります。

Catchup

DAGのスケジュールを過去の期間にさかのぼって実行してくれる機能なんですが、非常にありがたかったです。
過去のデータの移行でも差分同期の仕組みがそのまま使えましたし、一度に同期せずに、期間を区切って少しずつデータを持っていけたので、同期元のDBにも負荷をかけずにすみました。

Connection、Variable

Connectionは接続先となるDBやGCPへの認証情報を一元管理してくれ、一度設定すればどのDAGからアクセスできて便利でした。次のPoolも同じなんですが、設定はGUIでもCLIでも設定できるので、ansibleなどのプロビジョニングツールでも設定できたのもありがたかったです。

Variableも単なるキーと値を設定できるだけなんですが、DAGを汚すことなくdevやproductionなどリリースステージごとに値を切り替えられて便利でした。

Pool

タスクの同時実行数を制限する機能です。Poolはユーザーが定義でき、そのPoolにオペレーターを紐付けるとそのオペレーターはそのPoolのslot数を超えて同時実行されません。データ抽出のタスクが1つのDBに対して多数同時実行されてしまうとそのDBのコネクションも同時に消費され、枯渇しかねませんが、このPoolで上限数を設定できたので安心でした。

まとめ

最初は手っ取り早くcronとスクリプトで作ってしまおうと思ったのですが、すこしなれるまで時間はかかったもののAirflowで作って良かったです。開発が進むにつれ、特にプロダクション環境で動かすにあたっていろいろ考慮すべきことが出てくると思うのですが、作りながらほしいと思った機能が先回りされているかのように用意されていてとても助かりました。全て使いきれてないですが、ワークフロー運用のノウハウがたくさん詰まった良いプロダクトだと思いました。

Pocket

Leave a comment | Categories: Uncategorized

OptunaとLightGBMを使って、Kaggle過去コンペにsubmitする

10 December 2018 by shoji

この記事はEnigmo Advent Calendar 2018の10日目です。

はじめに

OptunaはPFN社が公開したハイパーパラメータ自動最適化フレームワークです。

https://research.preferred.jp/2018/12/optuna-release/

目的関数さえ決めれば、直感的に最適化を走らせることが可能のようです。

今回、最適化自体の説明は割愛させていただきますが、機械学習の入門ということを考えるとハイパーパラメータの調整としては、gridsearchやRandomizedSearchCVで行う機会が多いと思います。
スキル、あるいはリソースでなんとかするということになるかと思いますが、特に、kaggleのような0.X%の精度が向上が重要になるような状況では、ハイパーパラメータのチューニングが大きなハードルの一つになります。
そこで、titanicでのsubmitはあるものの、Kaggleの経験がほぼゼロな筆者でも、Optunaで簡単にチューニングができるかどうかを試してみようと思います。

今回の対象コンペ

既にcloseしているコンペの中で、下記のPorto Seguro’s Safe Driver Predictionを選びました。
https://www.kaggle.com/c/porto-seguro-safe-driver-prediction
選定理由は以下の通りです。

  • データがそれほど大きくない
  • 手元(自宅)のラップトップのRAMは8GBと大きくないので、XGboostではなくメモリ消費が抑えられるLightGBMでやってみたい
  • 解法がシンプルかつ、LightGBMで上位のスコアを解法を公開しているカーネルがすぐに見つかった

公開解法の再現

https://www.kaggle.com/xiaozhouwang/2nd-place-lightgbm-solution

上記をそのままコピペして一回submitします。
Python2対応のようなので、下記のようにPython3で動くように修正しました。

# part of 2nd place solution: lightgbm model with private score 0.29124 and public lb score 0.28555

import lightgbm as lgbm
from scipy import sparse as ssp
from sklearn.model_selection import StratifiedKFold
import numpy as np
import pandas as pd
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder

def Gini(y_true, y_pred):
    # check and get number of samples
    assert y_true.shape == y_pred.shape
    n_samples = y_true.shape[0]

    # sort rows on prediction column
    # (from largest to smallest)
    arr = np.array([y_true, y_pred]).transpose()
    true_order = arr[arr[:, 0].argsort()][::-1, 0]
    pred_order = arr[arr[:, 1].argsort()][::-1, 0]

    # get Lorenz curves
    L_true = np.cumsum(true_order) * 1. / np.sum(true_order)
    L_pred = np.cumsum(pred_order) * 1. / np.sum(pred_order)
    L_ones = np.linspace(1 / n_samples, 1, n_samples)

    # get Gini coefficients (area between curves)
    G_true = np.sum(L_ones - L_true)
    G_pred = np.sum(L_ones - L_pred)

    # normalize to true Gini coefficient
    return G_pred * 1. / G_true


cv_only = True
save_cv = True
full_train = False


def evalerror(preds, dtrain):
    labels = dtrain.get_label()
    return 'gini', Gini(labels, preds), True


path = "input/"

train = pd.read_csv(path+'train.csv')
train_label = train['target']
train_id = train['id']
test = pd.read_csv(path+'test.csv')
test_id = test['id']

NFOLDS = 5
kfold = StratifiedKFold(n_splits=NFOLDS, shuffle=True, random_state=218)

y = train['target'].values
drop_feature = [
    'id',
    'target'
]

X = train.drop(drop_feature,axis=1)
feature_names = X.columns.tolist()
cat_features = [c for c in feature_names if ('cat' in c and 'count' not in c)]
num_features = [c for c in feature_names if ('cat' not in c and 'calc' not in c)]

train['missing'] = (train==-1).sum(axis=1).astype(float)
test['missing'] = (test==-1).sum(axis=1).astype(float)
num_features.append('missing')

for c in cat_features:
    le = LabelEncoder()
    le.fit(train[c])
    train[c] = le.transform(train[c])
    test[c] = le.transform(test[c])

enc = OneHotEncoder(categories='auto')
enc.fit(train[cat_features])
X_cat = enc.transform(train[cat_features])
X_t_cat = enc.transform(test[cat_features])

ind_features = [c for c in feature_names if 'ind' in c]
count=0
for c in ind_features:
    if count==0:
        train['new_ind'] = train[c].astype(str)+'_'
        test['new_ind'] = test[c].astype(str)+'_'
        count+=1
    else:
        train['new_ind'] += train[c].astype(str)+'_'
        test['new_ind'] += test[c].astype(str)+'_'

cat_count_features = []
for c in cat_features+['new_ind']:
    d = pd.concat([train[c],test[c]]).value_counts().to_dict()
    train['%s_count'%c] = train[c].apply(lambda x:d.get(x,0))
    test['%s_count'%c] = test[c].apply(lambda x:d.get(x,0))
    cat_count_features.append('%s_count'%c)

train_list = [train[num_features+cat_count_features].values,X_cat,]
test_list = [test[num_features+cat_count_features].values,X_t_cat,]

X = ssp.hstack(train_list).tocsr()
X_test = ssp.hstack(test_list).tocsr()

learning_rate = 0.1
num_leaves = 15
min_data_in_leaf = 2000
feature_fraction = 0.6
num_boost_round = 10000
params = {"objective": "binary",
          "boosting_type": "gbdt",
          "learning_rate": learning_rate,
          "num_leaves": num_leaves,
           "max_bin": 256,
          "feature_fraction": feature_fraction,
          "verbosity": 0,
          "drop_rate": 0.1,
          "is_unbalance": False,
          "max_drop": 50,
          "min_child_samples": 10,
          "min_child_weight": 150,
          "min_split_gain": 0,
          "subsample": 0.9
          }

x_score = []
final_cv_train = np.zeros(len(train_label))
final_cv_pred = np.zeros(len(test_id))
for s in range(16):
    cv_train = np.zeros(len(train_label))
    cv_pred = np.zeros(len(test_id))

    params['seed'] = s

    if cv_only:
        kf = kfold.split(X, train_label)

        best_trees = []
        fold_scores = []

        for i, (train_fold, validate) in enumerate(kf):
            X_train, X_validate, label_train, label_validate = \
                X[train_fold, :], X[validate, :], train_label[train_fold], train_label[validate]
            dtrain = lgbm.Dataset(X_train, label_train)
            dvalid = lgbm.Dataset(X_validate, label_validate, reference=dtrain)
            bst = lgbm.train(params, dtrain, num_boost_round, valid_sets=dvalid, feval=evalerror, verbose_eval=100,
                            early_stopping_rounds=100, )
            best_trees.append(bst.best_iteration)
            cv_pred += bst.predict(X_test, num_iteration=bst.best_iteration)
            cv_train[validate] += bst.predict(X_validate)

            score = Gini(label_validate, cv_train[validate])
            print(score)
            fold_scores.append(score)

        cv_pred /= NFOLDS
        final_cv_train += cv_train
        final_cv_pred += cv_pred

        print("cv score:")
        print(Gini(train_label, cv_train))
        print("current score:", Gini(train_label, final_cv_train / (s + 1.)), s+1)
        print(fold_scores)
        print(best_trees, np.mean(best_trees))

        x_score.append(Gini(train_label, cv_train))

print(x_score)
pd.DataFrame({'id': test_id, 'target': final_cv_pred / 16.}).to_csv('model/lgbm3_pred_avg.csv', index=False)
pd.DataFrame({'id': train_id, 'target': final_cv_train / 16.}).to_csv('model/lgbm3_cv_avg.csv', index=False)

公開解法でのsubmit

Private Scoreで0.29097。5169チーム中46位のスコアとなり、シルバーメダル圏内に入りました。
コンペは終了しているので、もちろんスコアボードの本体は更新はされません。

なお、実際のコンペでは、カーネルの著書から他のNeral Networkでの予測値の平均と記載があるので、2位のsubmitの再現というわけにならないようです。

しかし、このようなシンプルな方法でシルバーメダルのスコアを取れるのは、個人的にもKaggleに積極してみたいという励みになったと感じています。

ハイパーパラメータのチューニング

さて、ハイパーパラメータのチューニングをフレームワークの力を借りて、ハードルをぐっと下げようという、本題に移ります。

他のKaggleのコンペや、Stack over flowで雑に調査し、パラメータの範囲を決めました。
そうしてできた修正したソースコードが、以下のようになります。

import lightgbm as lgbm
import optuna
from scipy import sparse as ssp
from sklearn.model_selection import StratifiedKFold
import numpy as np
import pandas as pd
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder

def Gini(y_true, y_pred):
    # check and get number of samples
    assert y_true.shape == y_pred.shape
    n_samples = y_true.shape[0]

    # sort rows on prediction column
    # (from largest to smallest)
    arr = np.array([y_true, y_pred]).transpose()
    true_order = arr[arr[:, 0].argsort()][::-1, 0]
    pred_order = arr[arr[:, 1].argsort()][::-1, 0]

    # get Lorenz curves
    L_true = np.cumsum(true_order) * 1. / np.sum(true_order)
    L_pred = np.cumsum(pred_order) * 1. / np.sum(pred_order)
    L_ones = np.linspace(1 / n_samples, 1, n_samples)

    # get Gini coefficients (area between curves)
    G_true = np.sum(L_ones - L_true)
    G_pred = np.sum(L_ones - L_pred)

    # normalize to true Gini coefficient
    return G_pred * 1. / G_true

cv_only = True
save_cv = True
full_train = False

def evalerror(preds, dtrain):
    labels = dtrain.get_label()
    return 'gini', Gini(labels, preds), True

path = "input/"

train = pd.read_csv(path+'train.csv')
#train = train.sample(frac=0.1, random_state=0).reset_index(drop=True)
train_label = train['target']
train_id = train['id']
test = pd.read_csv(path+'test.csv')
#test = test.sample(frac=0.1, random_state=0).reset_index(drop=True)
test_id = test['id']

NFOLDS = 4
kfold = StratifiedKFold(n_splits=NFOLDS, shuffle=True, random_state=218)

y = train['target'].values
drop_feature = [
    'id',
    'target'
]

X = train.drop(drop_feature,axis=1)
feature_names = X.columns.tolist()
cat_features = [c for c in feature_names if ('cat' in c and 'count' not in c)]
num_features = [c for c in feature_names if ('cat' not in c and 'calc' not in c)]

train['missing'] = (train==-1).sum(axis=1).astype(float)
test['missing'] = (test==-1).sum(axis=1).astype(float)
num_features.append('missing')
train.shape
for c in cat_features:
    le = LabelEncoder()
    le.fit(train[c])
    train[c] = le.transform(train[c])
    test[c] = le.transform(test[c])

# 事前にlabelEncoderを行っているから、この使い方でユニークな値で割り当てられる。引数categories = 'auto'で警告を消す
enc = OneHotEncoder(categories='auto')
enc.fit(train[cat_features])
X_cat = enc.transform(train[cat_features])
X_t_cat = enc.transform(test[cat_features])


ind_features = [c for c in feature_names if 'ind' in c]
count=0
for c in ind_features:
    if count == 0:
        train['new_ind'] = train[c].astype(str)+'_'
        test['new_ind'] = test[c].astype(str)+'_'
        count += 1
    else:
        train['new_ind'] += train[c].astype(str)+'_'
        test['new_ind'] += test[c].astype(str)+'_'

cat_count_features = []
for c in cat_features+['new_ind']:
    d = pd.concat([train[c],test[c]]).value_counts().to_dict()
    train['%s_count'%c] = train[c].apply(lambda x:d.get(x,0))
    test['%s_count'%c] = test[c].apply(lambda x:d.get(x,0))
    cat_count_features.append('%s_count'%c)

train_list = [train[num_features+cat_count_features].values, X_cat]
test_list = [test[num_features+cat_count_features].values, X_t_cat]

X = ssp.hstack(train_list).tocsr()
X_test = ssp.hstack(test_list).tocsr()


def objective(trial):
    drop_rate = trial.suggest_uniform('drop_rate', 0, 1.0)
    feature_fraction = trial.suggest_uniform('feature_fraction', 0, 1.0)
    learning_rate = trial.suggest_uniform('learning_rate', 0, 1.0)
    subsample = trial.suggest_uniform('subsample', 0.8, 1.0)
    num_leaves = trial.suggest_int('num_leaves', 5, 1000)
    verbosity = trial.suggest_int('verbosity', -1, 1)
    num_boost_round = trial.suggest_int('num_boost_round', 10, 100000)
    min_data_in_leaf = trial.suggest_int('min_data_in_leaf', 10, 100000)
    min_child_samples = trial.suggest_int('min_child_samples', 5, 500)
    min_child_weight = trial.suggest_int('min_child_weight', 5, 500)

    params = {"objective": "binary",
              "boosting_type": "gbdt",
              "learning_rate": learning_rate,
              "num_leaves": num_leaves,
              "max_bin": 256,
              "feature_fraction": feature_fraction,
              "verbosity": verbosity,
              "drop_rate": drop_rate,
              "is_unbalance": False,
              "max_drop": 50,
              "min_child_samples": min_child_samples,
              "min_child_weight": min_child_weight,
              "min_split_gain": 0,
              "min_data_in_leaf": min_data_in_leaf,
              "subsample": subsample
              }

    x_score = []
    final_cv_train = np.zeros(len(train_label))
    final_cv_pred = np.zeros(len(test_id))

    cv_train = np.zeros(len(train_label))
    cv_pred = np.zeros(len(test_id))

    params['seed'] = 0

    kf = kfold.split(X, train_label)

    best_trees = []
    fold_scores = []

    for i, (train_fold, validate) in enumerate(kf):
        print('kfold_index:', i)
        X_train, X_validate, label_train, label_validate = \
            X[train_fold, :], X[validate, :], train_label[train_fold], train_label[validate]
        dtrain = lgbm.Dataset(X_train, label_train)
        dvalid = lgbm.Dataset(X_validate, label_validate, reference=dtrain)
        bst = lgbm.train(params, dtrain, num_boost_round, valid_sets=dvalid, feval=evalerror, verbose_eval=100,
                        early_stopping_rounds=100)
        best_trees.append(bst.best_iteration)
        cv_pred += bst.predict(X_test, num_iteration=bst.best_iteration)
        cv_train[validate] += bst.predict(X_validate)

        score = Gini(label_validate, cv_train[validate])
        print(score)
        fold_scores.append(score)


    cv_pred /= NFOLDS
    final_cv_train += cv_train
    final_cv_pred += cv_pred

    print("cv score:")
    print(Gini(train_label, cv_train))
    print("current score:", Gini(train_label, final_cv_train / (s + 1.)), s+1)
    print(fold_scores)
    print(best_trees, np.mean(best_trees))

    x_score.append(Gini(train_label, cv_train))
    print(x_score)


    pd.DataFrame({'id': test_id, 'target': final_cv_pred / 16.}).to_csv('model/lgbm3_pred_avg_2.csv', index=False)
    pd.DataFrame({'id': train_id, 'target': final_cv_train / 16.}).to_csv('model/lgbm3_cv_avg_2.csv', index=False)

    return (1 - x_score[0])

study = optuna.create_study()
study.optimize(objective, n_trials=150)

パラメータの設定の範囲を抜粋すると以下のようになります。

drop_rate = trial.suggest_uniform('drop_rate', 0, 1.0)
feature_fraction = trial.suggest_uniform('feature_fraction', 0, 1.0)
learning_rate = trial.suggest_uniform('learning_rate', 0, 1.0)
subsample = trial.suggest_uniform('subsample', 0.8, 1.0)
num_leaves = trial.suggest_int('num_leaves', 5, 1000)
verbosity = trial.suggest_int('verbosity', -1, 1)
num_boost_round = trial.suggest_int('num_boost_round', 10, 100000)
min_data_in_leaf = trial.suggest_int('min_data_in_leaf', 10, 100000)
min_child_samples = trial.suggest_int('min_child_samples', 5, 500)
min_child_weight = trial.suggest_int('min_child_weight', 5, 500)

なお、Optuna自体の使用方法は、下記の記事と公式リファレンスを参考させていただきした。

https://qiita.com/ryota717/items/28e2167ea69bee7e250d
https://optuna.readthedocs.io/en/stable/index.html

(18/12/11 19:41追記)
コメントいただけた通り、’verbosity’は、警告レベルの表示を制御するパラメータであり、予測性能の最適化としては意味の無いパラメータでした。ですので、チューニングの対象にはすべきではありませんでした。

以下のように試行回数を定めていますが、

n_trials=150 

時間が足りなくなった関係で、その時点で計算されたパラメータで最適化を中断しております。
20時間ほど回し回しましたが、ハイパーパラメータによって検証の時間は1分から60分程度となり、
100回くらいの試行数だったようです。

そうしてできてパラメータが、以下のように、2位の解法と比較すると以下のようになります。

ハイパーパラメータ 今回のチューニング結果 2位の解法
drop_rate 0.3015600134599976 0.1
feature_fraction 0.46650703511665226 0.6
learning_rate 0.004772377676601769 0.1
subsample 0.8080720420805803 0.9
num_leaves 718 15
verbosity -1 0
num_boost_round 1942 10000
min_data_in_leaf 212 150
min_child_samples 68 10
min_child_weight 151 150

2位コンペとの解法とは、雰囲気が異なるセットとなり、公開解法の再現ということにはならないようです。
K_fold=4 でやっていることも異なる要因になると思います。

算出できたハイパーパラメータでsubmit

最初のpython3のスクリプトからパラメータを入れ替え、予測値を算出しました。
K_fold =4,
また、ランダムシートの数を16から4に減らしております。

結果

スコアは下がってます。

1176位相当。。ハイパーパラメータ次第でシルバーメダル圏内ということを考えると、微妙な結果です。

所感

結果としては残念ですが、grid searchだけに頼らない、ハイパーパラメータの最適化方法の導入のきっかけになりました。
また、非常に手軽に使えたというのもあり、今後もチューニングの場面でOptunaを活用してみたいと思います。

反省としては、探索するハイパーパラメータの設定が悪く、計算の効率化が著しく悪くなった恐れがあります。
validationの際に、fold数の全て計算するのではなく、スコアが下がらなそうなら、そのハイパーパラメータの計算をやめるとか、一定時間以上かかってしまったらまた、次に試行に移るとかできれば効率化できたように思えます。
フレームワークはブラックボックスでもある程度は動かすことができますが、やはり中身をある程度理解しないと遠回りしてしまうというのは、当然の結果と言えます。
もっと使いこなせるよう精進しなければと思いました。

公式リファレンスでも、OptunaでLightGBMをチューニングする例が出ており、そちらの例も参考にしながらリベンジしたいと思います。

https://github.com/pfnet/optuna/blob/master/examples/lightgbm_simple.py

最後にですが、この記事が何かの役に経てば幸いです。

Pocket

Leave a comment | Categories: Uncategorized

← Older posts