Rails アプリケーションに gRPC を導入したときの話

Rails アプリケーションに gRPC を導入したときの話

こんにちは、エンジニアの齊藤です。
この記事は Enigmo Advent Calendar 2020 の10日目の記事です。

本日は、Kubernetes にデプロイした複数の Rails アプリケーション間のデータのやり取りに
gRPC を採用した開発について ruby の実装を中心にいくつか共有したいと思います。

ruby を使った gRPC の開発という内容はオフィシャルのスタートガイドで細かく説明されているものがありますので、そちらを踏まえて実際どのように Rails アプリケーションに組み込んだのかという内容でまとめてみました。

まずは Protocol Buffers についてです。

Protocol Buffers によるサービスの定義

gRPC を利用するためには Protocol Buffers という IDL を使ってサービスの内容を定義します。

以下は Seller という出品者情報のエンティティとなるメッセージを定義した上でそれらを操作するためのプロシージャ名とそれぞれのリクエストパラメーターとレスポンスをメッセージとして定義 したサービスの実装(sellers.proto)の一部です。

サービスの定義でのポイントはエンティティとなるデータ構造は必ず定義して、これを直接レスポンスのメッセージとしては利用せずにリクエストパラメーターとなるメッセージとレスポンスとなるメッセージを必ずそれぞれ定義するようにしました。 以下の実装でいうと UpsertSellerResponse では単一の Seller をレスポンスで返却し、GetSellers では複数の Seller を返すような実装になります。 例えば同じ内容のリクエストパラメーターやレスポンスを複数のプロシージャで共有することは可能だとは思いますが、冗長でもプロシージャごとにそれぞれリクエストとレスポンスメッセージを用意しておくほうが API としてわかりやすく定義できるのではと思います。

今のプロジェクトの Protocol Buffers を振り返ると一つのサービスにたくさんのプロシージャを定義してしまっているケースも見受けられ、そういった場合は package の記述で ruby のネームスペースを利用することができるのである程度階層化することで解消できたのではと思いっています。

syntax = "proto3";

package Buyma.RPC.PersonalShopper;

import "google/protobuf/timestamp.proto";

service Sellers {
  rpc UpsertSeller(UpsertSellerRequest) returns (UpsertSellerResponse) {} // 出品者情報作成・更新
  rpc GetSellers(GetSellersRequest) returns (GetSellersResponse) {} // ページング可能な出品者一覧を取得
}

message Seller {
  uint64 seller_id = 1;
  string nickname = 2;
  google.protobuf.Timestamp created_at = 4;
  google.protobuf.Timestamp updated_at = 5;
}

message UpsertSellerRequest {
  unit64 seller_id = 1
  string nickname = 2;
}

message UpsertSellerResponse {
  Seller seller = 1; // 新規に登録または更新された Seller を返す
}

message GetSellersRequest {
  int32 page = 1;
}

message GetSellersResponse {
  repeated Seller sellers = 1; // 複数の Seller を返す
  int32 current_page = 2;
  int32 total_pages = 3
  int32 per_page = 4
}

サービス定義からのコード生成

gRPC は Protocol Buffers でサービス内容を定義したファイルからサーバー/クライアント向けのコードを自動生成するツールが提供されています。
それらを利用することでサーバーとクライアントを任意の言語で実装できるようになっています。

今回のケースでは定義(.proto)ファイルと自動生成された ruby のコードを Gem としてパッケージしそれぞれの Rails アプリケーションで利用することにしました。

ちなみに .proto 自体は開発言語に依存しないので複数の言語で利用されることを想定した場合は別の独立したリポジトリで管理するのが良いと思います。
今回は利用するアプリケーションのスコープと開発言語が ruby に限定されていたので Gem に内包する形で問題ないと判断しました。

ruby で利用するために必要なライブラリは同じく Gem として提供されている grpcgrpc-tools でこれらを gem スペックに記述して導入します。
gruf というライブラリについては後述します。

# 一部省略
Gem::Specification.new do |spec|
# 一部省略
  spec.add_dependency 'activerecord', '6.0.0'
  spec.add_dependency 'activesupport', '6.0.0'
  spec.add_dependency 'google-protobuf', '3.9.0'
  spec.add_dependency 'grpc', '1.22.0'
  spec.add_dependency 'gruf', '2.7.0'
# 一部省略
  spec.add_development_dependency 'bundler', '~> 2.0'
  spec.add_development_dependency 'grpc-tools', '1.22.0'
end

Gem の開発環境は ruby を導入した docker コンテナを利用しました。 以下の grpc_tools_ruby_protoc というコマンドを実行することで必要なコードを生成できます。
Stub と呼ばれるこれらのコードの生成についてはこちらに詳しく説明がされています。

$ docker-compose exec -w $PWD/lib ash bundle exec grpc_tools_ruby_protoc --ruby_out . --grpc_out . $(cd lib && find . -name '*.proto')

このコマンドを実行することによって lib/sellers_pb.rb (定義したすべてのメッセージを含む Protocol Buffers の実装), lib/sellers_service.rb (サービスを実装するための基盤となるクラスとサービスに接続するための Stub と呼ばれるクラス) という2つのファイルが生成されます。
これらを require することでサーバーとクライアントを実装することが可能になります。

今回は更に Gruf という Gem を使ってこれらのコードを Rails アプリケーションに導入しました。

Gruf を使ったサーバー実装

こちらに説明があるように生成されたコードを使ってサーバーの実装を行い gRPC サーバーのプロセスを起動することは可能ですが、既存の Rails アプリケーションにうまく組み込んでいくにはある程度の設計と共通化するための機能や設定周りの実装が発生します。
今回はその問題を解消してくれる Gruf というライブラリを使って利用できる実行環境を用意しました。 Gruf についてはこちらの Wiki に詳しい説明があります。

Gruf を使った RPC サーバーの実装は app/rpc/sellers_controller.rb といった RPC 向けのコントローラーファイルを使い Rails に馴染んだ設計でサーバーの実装をすることが可能になります。

以下のコントローラークラスの例は先程の Sellers サービスのコントローラーです。生成された Buyma::RPC::PersonalShopper::Sellers::Service を bind することでコントローラーのメソッドとマッピングされます。

upsert_seller メソッドは先程の定義ファイルの rpc UpsertSeller の処理を実装したものです。 Buyma::RPC::PersonalShopper::UpsertSellerResponse を返しています。

各コントローラーメソッドには自動的に request というオブジェクトが参照できるようになっており、request.message でリクエストの RPC メッセージが参照できるようになっています。 これは Rails コントローラーでリクエストパラメーターを参照できる params の仕組みに類似した設計だと思いました。

class SellersController < ::Gruf::Controllers::Base
  bind Buyma::RPC::PersonalShopper::Sellers::Service

  def upsert_seller
    Buyma::RPC::PersonalShopper::UpsertSellerResponse.new(
      seller: Buyma::RPC::PersonalShopper::Seller.new(seller.to_grpc_hash.slice(:seller_id, :nickname, :created_at, :updated_at))
    )
  end

  def get_sellers
    Buyam::RPC::PersonalShopper::GetSellersResponse.new(sellers: sellers, ...
  end

  private

  def seller
    params = request.message.to_rails_hash
  end
# 一部省略
end

seller: Buyma::RPC::PersonalShopper::Seller.new( 部分の実装について Seller メッセージのパラメーターは AcitveRecord のインスタンスである seller から生成していますが seller.to_grpc_hash という実装は先程の Gem のなかで ActiveRecord::Base に追加しているメソッドです。google.protobuf.Timestamp 型のフィールドには Time 型の値以外をアサインできない制約があるためこのメソッドで to_time への変換を行うことでその問題を回避しています。

[1] pry(main)>Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current)
Google::Protobuf::TypeError: Invalid type ActiveSupport::TimeWithZone to assign to submessage field 'created_at'.
from (pry):32:in `initialize`
[2] pry(main)> Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current.to_time)
<Buyma::RPC::PersonalShopper::Seller: seller_id: 0, nickname: "", created_at: <Google::Protobuf::Timestamp: seconds: 1607499175, nanos: 658255400>, updated_at: nil>

Gruf は Interceptor と呼ばれる gRPC が提供している、メソッドの前後に処理を行うための仕組みもサポートしています。

今回のプロジェクトでは ActiveRecord::RecordInvalidActiveRecord::RecordNotFound などの例外を各サーバーメソッドで共通してハンドリングするために利用しています。

require_relative '../error/handler'

module Buyma
  module RPC
    module PersonalShopper
      module Interceptor
        class HandleError < Gruf::Interceptors::ServerInterceptor
          def call
            yield
          rescue StandardError => e
            Buyma::RPC::PersonalShopper::Error::Handler.call(self, e)
          end
        end
      end
    end
  end
end

インターセプターの登録は config/initializers で行います。

config/initializers/gruf.rb

Gruf.configure do |c|
  c.interceptors.use(Buyma::RPC::PersonalShopper::Interceptor::HandleError)
end

サーバーの起動は以下のコマンドで、これで bind したすべてのコントローラーのハンドリングが可能になります。

$ bundle exec gruf

Gruf を使ったクライアント実装

クライアント側の実装は Gruf::Client に実行したい RPC サービス名(ここでは Buyma::RPC::PersonalShopper::Sellers) を指定してサービスに接続するスタブのインスタンスを生成します。
.call メソッドでプロシージャ名とパラメーターを指定して実行することができます。

[1] pry(main)> client = Gruf::Client.new(service: Buyma::RPC::PersonalShopper::Sellers, options: { hostname: ENV['GRUF_SERVER'] })
[2] pry(main)> client.call(:UpsertSeller, seller_id: 1, nickname: 'Foo').message.inspect

calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller
"<Buyma::RPC::PersonalShopper::UpsertSellerResponse: seller: <Buyma::RPC::PersonalShopper::Seller: seller_id: 1, nickname: \"Foo\", created_at: <Google::Protobuf::Timestamp: seconds: 1597815761, nanos: 131317000>, updated_at: <Google::Protobuf::Timestamp: seconds: 1607500796, nanos: 652204000>>>"

レスポンスメッセージについても Gem で Google::Protobuf::MessageExts に追加した to_rails_hash メソッドで必要な型変換を行った Hash として取得し以降の処理 (ActiveRecordインスタンスの生成等)でデータ型による問題を回避する工夫を行いました。

[1] pry(main)> client.call(:UpsertSeller, seller_id: 490652, nickname: 'Foo').message.to_rails_hash

calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller
{
    :seller => {
         :seller_id => 490652,
          :nickname => "Foo",
        :created_at => Wed, 19 Aug 2020 14:42:41 JST +09:00,
        :updated_at => Wed, 09 Dec 2020 16:59:56 JST +09:00
    }
}

gRPC サーバーの HealthCheck

Kubernetes にサーバーをデプロイする場合 readinessProbelivenessProbe の設定に必要になるヘルスチェックの実装についても共有します。

ヘルスチェックの実装にはこちら 記事を参考に実装しました。 gPRC にはヘルスチェックのためのプロトコルとそれを実行するための grpc-health-probe というツールがあるのでそちらを導入します。

ツールは gRPC サーバーを起動する Rails アプリケーションコンテナの Dockerfile でインストールしました。

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 \
    && wget -qO/bin/grpc_health_probe \
        https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 \
    && chmod +x /bin/grpc_health_probe

アプリケーションには Grpc::Health::V1::Health::Service を バインドしたプロシージャを実装をします。
実装内容はアプリケーション上のヘルスチェックを行った上で適切なレスポンスメッセージを返すだけです。 こちらも Gruf のコントローラーとして実装しています。

require 'grpc/health/v1/health_services_pb'

class HealthCheckController < ::Gruf::Controllers::Base
  bind Grpc::Health::V1::Health::Service

  def check
    if alive? # アプリケーション上のヘルスチェックを実行する
      Grpc::Health::V1::HealthCheckResponse
      .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::SERVING)
    else
      Grpc::Health::V1::HealthCheckResponse
      .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::NOT_SERVING)
    end
  end
end

動作確認をします。

正常な場合

$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001
status: SERVING

問題がある場合

$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001
service unhealthy (responded with "NOT_SERVING")

あとは Kubernetes の deployment でヘルスチェックコマンドを導入して完了です。

livenessProbe:
  exec:
    command:
    - grpc_health_probe
    - -addr=:9001
  failureThreshold: 3
  initialDelaySeconds: 20
  periodSeconds: 10
  successThreshold: 1
  timeoutSeconds: 5
readinessProbe:
  exec:
    command:
    - grpc_health_probe
    - -addr=:9001
  failureThreshold: 3
  initialDelaySeconds: 10
  periodSeconds: 10
  successThreshold: 1
  timeoutSeconds: 5

最後に

Rails アプリケーションに gRPC を導入した際のポイントをいくつか共有させていただきました。 今回実際に導入してみて gRPC は IF の仕様が明確にできアプリケーション間で相互にデータをやり取りするようなケースに適している点やそれを仕組み化する基盤としての便利さを体感することができました。 今後の開発でさらに利用する機会を広げて行ければと思いました。

明日はマッドサイエンティストステェーヴェン・ル・ボエデック氏です。
よろしくお願いします。

機械学習で競馬必勝本に勝てるのか? 〜Pythonで実装するランク学習〜

こんにちは。データサイエンティストの堀部です。

この記事は Enigmo Advent Calendar 2020 の9日目の記事です。

何か社外のデータを使っていい感じのことができないかなと思っていたところ、3日目の竹本さんの記事がおもしろく、パクリ二次創作しました。

短期間で実装したので汚いコードで見苦しいかもしれないですがご了承ください。ちなみに、私は競馬は簡単なルールを知っているくらいでズブの素人です。

目次

使用したライブラリ

import urllib.parse
import urllib.request as req
from time import sleep

import category_encoders as ce
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from bs4 import BeautifulSoup
from selenium import webdriver
from tqdm.auto import tqdm

インストール手順は割愛します。*1

データ取得

オッズだけでモデルを組むのはつまらないので簡単に取得できる範囲で下記を追加しました。

  • 馬連
  • 馬名
  • 斤量
  • 騎手
  • 厩舎
  • 馬体重とその増減
  • 年齢
  • 性別

データ取得にあたり、下記の関数とクラスを用意しました。 sleep関数で1秒以上の間隔を空けてnetkeibaからスクレイピングしています。

def get_raceids(date):
    url = "https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=" + date
    res = req.urlopen(url)
    racesoup = BeautifulSoup(res, "html.parser")
    sleep(1)
    racelist = racesoup.select(
        "#RaceTopRace > div > dl > dd > ul > li > a:nth-of-type(1)"
    )
    raceids = [
        urllib.parse.parse_qs(urllib.parse.urlparse(race.get("href")).query)["race_id"][
            0
        ]
        for race in racelist
    ]
    return raceids


def set_selenium():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    driver = webdriver.Chrome("chromedriver", options=options)
    driver.implicitly_wait(15)
    return driver

class HorceRacing:
    def __init__(self, race_id, driver):

        self.race_id = race_id
        self.driver = driver
        try:
            self.result = pd.read_html(
                "https://race.netkeiba.com/race/result.html?race_id=" + self.race_id
            )
            sleep(1)
        except BaseException:
            print("no result yet")

        self.odds = self._get_odds()
        self.info = self._get_info()

        self.dict_columns = {
            "馬番": "horse_no",
            "枠": "gate",
            "馬名": "horse_name",
            "斤量": "burden_weight",
            "騎手": "jockey_name",
            "厩舎": "stable",
            "馬体重": "horse_weight",
            "馬体重_増減": "horse_weight_change",
            "性別": "sextype",
            "年齢": "age",
            "オッズ": "odds",
            "着順": "target",
        }

    def _get_odds(self):
        self.driver.get(
            "https://race.netkeiba.com/odds/index.html?type=b1&race_id="
            + self.race_id
            + "&rf=shutuba_submenu"
        )
        html = self.driver.page_source.encode("utf-8")
        tanhukusoup = BeautifulSoup(html, "html.parser")
        tanhuku_df = pd.read_html(str(tanhukusoup.html))[0].loc[:, ["馬番", "オッズ"]]
        sleep(1)
        return tanhuku_df

    def _get_info(self):
        info_df = pd.read_html(
            "https://race.netkeiba.com/race/shutuba.html?race_id=" + self.race_id
        )[0]
        sleep(1)
        info_df.columns = [col[0] for col in info_df.columns]
        info_df = info_df.loc[:, ["馬番", "枠", "馬名", "性齢", "斤量", "騎手", "厩舎", "馬体重(増減)"]]
        info_df["馬体重"] = (
            info_df["馬体重(増減)"].str.split("(").str[0].replace("--", np.nan).astype(float)
        )
        info_df["馬体重_増減"] = (
            info_df["馬体重(増減)"]
            .str.split("(")
            .str[1]
            .str.replace(")", "")
            .replace("--", np.nan)
            .replace("前計不", np.nan)
            .astype(float)
        )
        info_df["性別"] = info_df["性齢"].str[0]
        info_df["年齢"] = info_df["性齢"].str[1:].astype(int)
        info_df.drop(["馬体重(増減)", "性齢"], axis=1, inplace=True)
        return info_df

    # 同着があり複数パターンある場合は1番初めのパターンだけ取得
    def result_sanrentan(self):
        _result = self.result[2].set_index(0).loc["3連単", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def result_sanrenpuku(self):
        _result = self.result[2].set_index(0).loc["3連複", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" ")))[:3],
        )

    def result_tansyo(self):
        _result = self.result[1].set_index(0).loc["単勝", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def get_df(self):
        df = self.info.merge(self.odds, on="馬番").merge(
            self.result[0].loc[:, ["馬番", "着順"]], on=["馬番"]
        )

        # カラムが日本語だとモデルの学習ができないので置換
        df.columns = df.columns.map(self.dict_columns)

        df["race_id"] = self.race_id

        # 着順が数値以外のものを除外・置換
        df = df.loc[~df["target"].isin(["中止", "除外", "取消"]), :]
        df["target"] = df["target"].replace("失格", 20).astype(int)

        # オッズが数値以外のものを置換
        df["odds"] = df["odds"].replace("---.-", np.nan).astype(float)

        # lighgbmのlambdarankは数値が大きい方がランクが高いという定義なのでtargetを変換
        df["target"] = df["target"].max() - df["target"] + 1
        return df

データの取得期間は下記のように分けました。

  • 訓練データ:2020年9月5日〜2020年10月31日(540レース分)
  • 検証データ:2020年11月1日〜2020年11月23日(252レース分)
  • テストデータ:2020年11月28日〜2020年11月29日(48レース分)*2

モデルの訓練に使えるようなデータ形式で取得し、払戻金の計算が後ほどできるように当たった場合の金額を取得しています。*3

list_date_train = [
    "20200905",
    "20200906",
    "20200912",
    "20200913",
    "20200919",
    "20200920",
    "20200921",
    "20200926",
    "20200927",
    "20201003",
    "20201004",
    "20201010",
    "20201011",
    "20201017",
    "20201018",
    "20201024",
    "20201025",
    "20201031",
]

list_date_val = [
    "20201101",
    "20201107",
    "20201108",
    "20201114",
    "20201115",
    "20201121",
    "20201122",
    "20201123",
]

list_date_test = ["20201128", "20201129"]

list_train_df = []
dict_train_result = dict()
for date in tqdm(list_date_train):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        train_df = hr.get_df()
        train_df["date"] = date
        list_train_df.append(train_df)
        dict_train_result[race_id] = {
            "sanrentan": hr.get_result_sanrentan()[0],
            "sanrenpuku": hr.get_result_sanrenpuku()[0],
            "tansyo": hr.get_result_tansyo()[0],
        }

list_val_df = []
dict_val_result = dict()
for date in tqdm(list_date_val):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        val_df = hr.get_df()
        val_df["date"] = date
        list_val_df.append(val_df)
        dict_val_result[race_id] = {
            "sanrentan": hr.result_sanrentan()[0],
            "sanrenpuku": hr.result_sanrenpuku()[0],
            "tansyo": hr.result_tansyo()[0],
        }

list_test_df = []
dict_test_result = dict()
for date in tqdm(list_date_test):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        try:
            hr = HorceRacing(race_id, driver)
            test_df = hr.get_df()
            test_df["date"] = date
            list_test_df.append(test_df)
            dict_test_result[race_id] = {
                "sanrentan": hr.result_sanrentan()[0],
                "sanrenpuku": hr.result_sanrenpuku()[0],
                "tansyo": hr.result_tansyo()[0],
            }
        except BaseException:
            pass

train = pd.concat(list_train_df)
train = train.sort_values("race_id")
train.reset_index(inplace=True, drop=True)

val = pd.concat(list_val_df)
val = val.sort_values("race_id")
val.reset_index(inplace=True, drop=True)

test = pd.concat(list_test_df)
test = test.sort_values("race_id")
test.reset_index(inplace=True, drop=True)

前処理

文字列はそのままだとモデルに入れられないので数値に置き換えます。 今回モデルはlightgbmを使うので、OrdinalEncoderを利用しました。 また、重要そうなオッズを中心に特徴量を追加しました。(関数:add_features)

lightgbmのランク学習(lambdarank)の場合、回帰・分類予測と違い上から順に○行は同じレースだよというqueryを用意する必要があるので作成しておきます。

categorical_cols = ["horse_name","jockey_name","stable","sextype"]

ce_oe = ce.OrdinalEncoder(
    cols=categorical_cols, handle_unknown="return_nan", handle_missing="return_nan"
)

train = ce_oe.fit_transform(train)
val = ce_oe.transform(val)
test = ce_oe.transform(test)

def add_features(df):
    odds_min = df.groupby("race_id")["odds"].min()
    # オッズの最小値との差分
 df["odds_diff"] = df["odds"] - df["race_id"].map(odds_min)
    # オッズの最小値との倍率
    df["odds_ratio"] = df["odds"] / df["race_id"].map(odds_min)
 # オッズの偏差値
    df["odds_deviation"] = (df["odds"] - df["odds"].mean()) / df["odds"].std()
    # 斤量 + 馬の体重
    df["all_weight"] = df["burden_weight"] + df["horse_weight"]


add_features(train)
add_features(val)
add_features(test)

# レースIDを後で参照できるように保持
arr_train_race_ids = train["race_id"].unique()
arr_val_race_ids = val["race_id"].unique()
arr_test_race_ids = test["race_id"].unique()

# ランク学習に必要なqueryを作成
train_query = train.groupby("race_id")["horse_no"].count().values.tolist()
val_query = val.groupby("race_id")["horse_no"].count().values.tolist()
test_query = test.groupby("race_id")["horse_no"].count().values.tolist()

# 学習に不要なカラムを削除
drop_cols = ["race_id","date"]

train.drop(drop_cols, axis=1, inplace=True)
val.drop(drop_cols, axis=1, inplace=True)
test.drop(drop_cols, axis=1, inplace=True)

# 目的変数を分離
target = train_df.pop("target")
val_target = val_df.pop("target")
test_target = test_df.pop("target"

学習

モデルはlightgbmのscikit-learn APILGBMRankerを利用しました。評価指標はNDCG)*4です。

lgb_params = {
    "objective": "lambdarank",
    "metric": "ndcg",
    "n_estimators":2000,
    "boosting_type": "gbdt",
    "num_leaves":31,
    "learning_rate":0.01,
    "importance_type": "gain",
    "random_state": 42,
}
lgb_fit_params = {
    "eval_metric":"ndcg",
    "eval_at":(1,2,3),
    "early_stopping_rounds": 50,
    "verbose": 10,
    "categorical_feature": categorical_cols,
}

lgb_model = lgb.LGBMRanker(**lgb_params)

lgb_model.fit(
    train,
    target,
    group=train_query,
    eval_set=[(train,target),(val,val_target)],
    eval_group=[train_query,val_query],
    **lgb_fit_params
)

学習結果です。出馬情報だけだと予測が難しそうですね。

training's ndcg@1: 0.550162  training's ndcg@2: 0.608893 training's ndcg@3: 0.649182
valid_1's ndcg@1: 0.444402  valid_1's ndcg@2: 0.513605  valid_1's ndcg@3: 0.550583

特徴量の重要度(feature_importance)を算出してみました。オッズ関連が予測に効いていますね。

fti =pd.Series(lgb_model.feature_importances_,index=train.columns).sort_values()
fti.plot(kind="barh")

f:id:enigmo7:20201204172819p:plain

予測・評価

検証データ(val)とテストデータ(test)、それぞれに対して予測結果から単勝・3連複・3連単での的中率と100円ずつ買った場合いくら儲かるのか?を計算してみます。比較としてオッズの低い順(人気順)に買ってみた場合も出してみます。

def get_result(
        target, pred, query, race_ids, dict_result, is_higher_better=True, bet_yen=100
    ):
        ind = 0
        correct_first = 0
        refund_first = 0

        correct_sanrenpuku = 0
        refund_sanrenpuku = 0

        correct_sanrentan = 0
        refund_sanrentan = 0

        for q, race_id in zip(query, race_ids):
            _true_first = np.argmax(target[ind : ind + q])
            if is_higher_better:
                _pred_first = np.argmax(pred[ind : ind + q])
            else:
                _pred_first = np.argmin(pred[ind : ind + q])
            if _true_first == _pred_first:
                correct_first += 1
                refund_first += dict_result[race_id]["tansyo"] / 100 * bet_yen
            else:
                refund_first -= bet_yen

            _true_sanren = np.argsort(target[ind : ind + q].values)[::-1][:3]
            if is_higher_better:
                _pred_sanren = np.argsort(pred[ind : ind + q])[::-1][:3]
            else:
                _pred_sanren = np.argsort(pred[ind : ind + q])[:3]

            if len(set(_true_sanren) & set(_pred_sanren)) == 3:
                correct_sanrenpuku += 1
                refund_sanrenpuku += dict_result[race_id]["sanrenpuku"] / 100 * bet_yen
            else:
                refund_sanrenpuku -= bet_yen

            if _true_sanren.tolist() == _pred_sanren.tolist():
                correct_sanrentan += 1
                refund_sanrentan += dict_result[race_id]["sanrentan"] / 100 * bet_yen
            else:
                refund_sanrentan -= bet_yen

            ind += q
        print(
            f"単勝  的中率: {correct_first / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_first}"
        )
        print(
            f"3連複 的中率: {correct_sanrenpuku / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrenpuku}"
        )
        print(
            f"3連単 的中率: {correct_sanrentan / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrentan}"
        )
        print("-------------------------------------------")
        print(
            f"合計  回収率: {((refund_first+refund_sanrenpuku+refund_sanrentan) / (3 * bet_yen * len(race_ids))+1) * 100 :.2f}%, 払戻金-賭け金: {refund_first+refund_sanrenpuku+refund_sanrentan}"
        )

pred_val = lgb_model.predict(val,num_iteration=lgb_model.best_iteration_)
pred_test = lgb_model.predict(test,num_iteration=lgb_model.best_iteration_)

print("■検証データ(val)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    val_target,
    pred_val,
    val_query,
    arr_val_race_ids,
    dict_val_result,
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    val_target,
    val["odds"],
    val_query,
    arr_val_race_ids,
    dict_val_result,
    is_higher_better=False,
)
print("")
print("■テストデータ(test)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    test_target,
    pred_test,
    test_query,
    arr_test_race_ids,
    dict_test_result
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    test_target,
    test["odds"],
    test_query,
    arr_test_race_ids,
    dict_test_result,
    is_higher_better=False,
)

VSオッズ低い順

検証データ、テストデータ共にオッズ低い順にかけたよりも回収率が高くなりました!検証データでは残念ながらボロ負けですが、テストデータでは回収率100%超えました。特に3連複・3連単の的中率がオッズ低い順より高くなっているのがおもしろいです。

■検証データ(val)
lightgbm モデル
-------------------------------------------
単勝  的中率: 30.95%, 払戻金-賭け金: 4660.0
3連複 的中率: 6.75%, 払戻金-賭け金: -11460.0
3連単 的中率: 1.59%, 払戻金-賭け金: -17060.0
-------------------------------------------
合計  回収率: 68.44%, 払戻金-賭け金: -23860.0

オッズ低い順
-------------------------------------------
単勝  的中率: 32.14%, 払戻金-賭け金: 2450.0
3連複 的中率: 5.56%, 払戻金-賭け金: -15660.0
3連単 的中率: 0.40%, 払戻金-賭け金: -22270.0
-------------------------------------------
合計  回収率: 53.07%, 払戻金-賭け金: -35480.0

■テストデータ(test)
lightgbm モデル
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1360.0
3連複 的中率: 14.58%, 払戻金-賭け金: 1060.0
3連単 的中率: 4.17%, 払戻金-賭け金: -900.0
-------------------------------------------
合計  回収率: 110.56%, 払戻金-賭け金: 1520.0

オッズ低い順
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1310.0
3連複 的中率: 12.50%, 払戻金-賭け金: -1040.0
3連単 的中率: 2.08%, 払戻金-賭け金: -3360.0
-------------------------------------------
合計  回収率: 78.54%, 払戻金-賭け金: -3090.0

VS競馬必勝本

竹本さんの記事では検証データと同じ期間にレースを選択して、3連複のみ購入していました。

金額としては14630円負けてしまいました。

競馬必勝本は本当に当たるのかを検証!〜Pythonで実装する馬券自動選択ツール〜 - エニグモ開発者ブログ

そこで検証データの3連複のみ比較すると、

-11,460円(lightgbmモデル × 全レース) > -14,630円(競馬必勝本) > -15,660円(オッズ低い順 × 全レース)

ということで、まぐれかもしれないですが機械学習で競馬必勝本に勝てたと言ってもよいのではないでしょうか?

感想

なかなか簡単には儲かりませんね。

まだまだ改善の余地がありそうなので、気がむいたら趣味で続けてみたいと思います。

  • 特徴量の追加:コースの情報、天気、血統
  • 取得データ期間の延長
  • どのレースに賭ける/賭けないべきか?の予測
  • どの種類の馬券を買うべきか?の予測

参考資料

Welcome to LightGBM’s documentation! — LightGBM 3.1.0.99 documentation

LightGBMでサクッとランク学習やってみる - 人間だったら考えて

馬券の種類:はじめての方へ JRA

レース情報(JRA) | 出馬表やオッズ、プロ予想などの競馬情報は netkeiba.com

最後まで読んでいただきありがとうございました。

明日の記事の担当はエンジニアの齊藤さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

*1:私はpoetryを利用しました。

*2:後から気づいたのですが、正しく比較するにはテストデータを2020年11月1日〜2020年11月23日にすべきでした。

*3:3位以内に同着がある場合は、前処理の簡略化のためサイト上に一番初めに掲載されているパターンのみ取得しています。

*4:NDCGは0〜1の値をとり、高いほど精度がよいことを示します。

100日後に入社する新卒のエンジニア、コロナ禍での就活を振り返る

こんにちは。2021年度より株式会社エニグモに新卒で入社することになりました、岡本です。

普段はMac使いですが、10月にうっかり注文してしまったLenovo ThinkPad X13 Gen 1 (AMD)がもうすぐ届きます。2ヶ月待ちました。WSL2の使い心地をチェックしたいと思います。

この記事は Enigmo Advent Calendar 2020 の7日目の記事です。

私が就職活動を行った2020年は新型コロナウイルスが流行し、全世界が多大な影響を受けました。そうした中で私がどのように就職活動を行ってきたか、そして内定先企業である株式会社エニグモに入社した経緯について、その他諸々綴ります。

いわゆる就活体験記です。

5000字超えの問わず語り、ぜひお付き合いください。

目次

自己紹介

2020年11月よりアルバイトとしてサービスエンジニアリング本部に所属し、海外通販サイト『BUYMA』のWebアプリケーション開発に携わっています。

大学在学中にプログラミングの学習を始め、企業でWebアプリ開発の業務を行ったりインターンに参加していました。

エンジニアを目指したきっかけ

幼い頃から自宅のパソコンでインターネットを閲覧したり創作することが好きでした。小学校にもパソコンがあり、タイピング速度は学校で一二を争うほど速かったです。しかし中高生になった段階でスマートフォンを手に入れ、パソコンを使う機会は激減しました。

高校生の頃、部活動をせずに学校が終わったら急いで家に帰って音楽や深夜ラジオを聞いたり映画やコントのDVDを見たりカルチャー雑誌や文学作品を読んで一人の時間を楽しんでいました。地方で文化に飢えていた私、ライブや劇場に通うために大学進学を機に上京し、更にカルチャーに塗れた生活をしたいなとぼんやり考えていました。

が、紆余曲折あって上京を断念することになり、地元関西の大学の経済学部に進むことになりました。その時「困難に直面しても自力で立ち向かっていける武器を身に付けたい」と思いました。そんな中、書店で手に取った本に「これからの時代はプログラミングスキルを身に付けている人が重宝される」と書いてありました。すぐさまプログラミングについて調べ、Progateで学習し始めました。そこでプログラミングは面白いと思い、できるようになりたいと思いました。

プログラミングの学習を始めてから数ヶ月経過し、その中でスタートアップという世界があることを知りました。また、当時(2017年)は仮想通貨・ブロックチェーンがトレンドで、それらの技術に興味がありました。SNSを駆使し、仮想通貨に関する事業を行うスタートアップを見つけてインターンすることになりました。

僕が任されたのはエンジニアリング業務ではなく仮想通貨メディアのライター業務でした。エンジニアをやりたいと思ったものの、ライターの業務もやりがいがあり楽しかったです。しかし、社内にいたエンジニアたちがホワイトボードを囲んで議論したり黒い画面に向かってコードを書いているのを横目で見て、やはり彼らのように働けるようになりたいと思いました。また、別のスタートアップの社長の方からもエンジニアになるよう後押しをいただいたこともあり、再びプログラミング学習に力を入れました。

ある程度学習が進んだ大学2年の終盤にさしかかる頃、先輩の紹介でWebエンジニアのバイトを始め、サマーインターンハッカソンにも参加するようになりました。

就職活動について(どういった軸で行い、エニグモと出会ったか)

満を持して3年時に参加した複数の企業のサマーインターンで、全国から集う優秀なエンジニア学生たちを目の当たりにし、自分の無力さを痛感しました。さらに逆求人形式のイベントに参加した際にも企業から辛辣な評価を受け、完全に心が折れ、自分の行先を見失っていました。情報系の大学/大学院を受験することも考えましたが、色んな人に相談し、エンジニアとして就職する決意を固めました。

コロナ禍での就活

年が明け、企業の本選考に応募し始めた矢先、新型コロナウイルスが流行し始めました。エニグモを始め、各社オンラインでの面接が基本となったため、地方に住んでいる身としては移動のコストが掛からず、大変助かりました。

内定を貰い、アルバイト入社した現在もなお、オフィスに出社したことがありません。社員の方とも直接お会いしたことがありません。しかし、ZoomやSlackでコミュニケーションが取れるため、特に不便は感じていません。(が、せめて直接挨拶したい)

こういったことはIT企業、なおかつエンジニアという職だから実現するのだな…と、しみじみありがたく思います。

選考について

選考を受ける過程で、大事だなと思ったことや、準備をしていたことがいくつかあるのでご紹介します。

聞かれたことについて答える

面接を受ける中で一番大事だと思ったのが「聞かれたことについて答えること」です。

簡単なことだと思われるかもしれませんが、意外と難しいことだと思います。

聞かれたことについて無目的に答えるのではなく「事実と意見を区別して答えること」が重要だと感じています。事実について答えるように質問をされているのに自分の意見を話してしまったり、余計なことを答えてしまうことがあるので、聞かれたことについて的確に答えることを普段から意識しています。

内観し、自己を見出す

面接の終盤、面接官の方から「最後に質問はありませんか」と問いかけられることが多いと思います。私はよく面接全体のフィードバックを貰っていました。「僕のことどう思いましたか?」という感じで。指摘されたことはすべて紙のノートやNotionにメモしていました。

例えばある時は「淡々と抑揚のない感じで喋っている」と指摘されたので、次回から少し声を張ったり表情を緩めて話すことを意識するようにしました。

Notionには「就活」というディレクトリを作り、その下に「就活の軸」「自己紹介」「フィードバック」「想定される質問、それに対する回答」「企業の情報、面接の記録」などのファイルを作って、日々言語化を繰り返し、自分は心の中で何を思っているのかを言葉に起こす作業をしました。

心の中で思っていることを言葉にすることの有効性については、T. ウィルソン 村田光二 (訳)『自分を知り、自分を変える―適応的無意識の心理学』(新曜社、2005年)の第8章が参考になりました。

会社選びの基準としては「社内のコミュニケーションは活発になされているか、技術の面で自分が貢献できそうか」などを焦点に置いていました。今後リモートワークが継続される可能性は高いため、各社コミュニケーションの仕方をどう工夫しているか面接で尋ねていました。

今までに作ったアプリや関わったサービスについて説明できるようにしておくのは重要です。自分がどんな役割を果たしたのか、こだわりポイントやつまづいたポイント、問題をどう解決したかなどを語れる準備をしました。「喉元過ぎれば熱さを忘れる」という言葉があるように、遭遇した問題やバグは解決してしまえば案外忘れてしまうものです(よね?)。いざ聞かれると答えられないということが分かったので事前に思い出していつでも言えるように準備していました。また、普段使っているWeb技術についても説明できるようにしていました。

技術試験

コーディングテストを課す企業があるので、AtCoderを使ってA~C問題を解けるように練習していました。未だにコーディングテストは苦手です…。

Webエンジニアの場合は、まずアプリケーション作りを頑張って、使っている技術について説明できることを優先すべきだと思います。

エニグモの採用(説明会・面接・面談)への印象

就活を始めた当初、エニグモを受ける予定はありませんでした。ある日の選考で受け答えがうまくいかず、失望の眼差しで求人サイトを眺めていたところ、偶然エニグモの新卒採用の募集が目に留まりました。事業が画期的で技術スタック的にも一致すると思い、軽い気持ちでエントリーしました。

エントリー後の人事面談を経て、1次・2次面接では各回エンジニア2名との面接を行い、最終面接で社長を含む役員3名・人事部長1名との面接を行いました。いずれも面接の翌日には結果をお知らせいただいていました。意思決定の素早さに驚きました。

面接期間は人事の方と連絡を取ることが多いですが、迅速かつ丁寧に対応していただき、好印象を抱きました。

そのほかにも、歳の近い複数の社員の方とのカジュアル面談を組んでいただきました。エニグモではBUYMAという安定した基盤がある中で、若手でも柔軟に裁量をもった仕事が出来ると聞き、自分の志向に合っていると感じました。

エニグモに入社した理由

過去にアルバイトで開発していたC2CのECサービスがあるのですが、クローズすることになりました。会員登録数は増えても購買数が伸び悩んでいて、エンジニアも施策に関して意見を求められる場面がありました。自分なりの提言はしてみるものの目に見える成果は出ませんでした。エンジニアのアルバイトとは言えども、少しでも力になりたかったです。

そこで、利益をあげているECサービスにはどんなエンジニアがいて、どんな開発をしているのか、どうやってビジネスサイドと連携を取っているのか、知りたくなりました。

そんな自分にとって『BUYMA』のエニグモは魅力的でした。会社自体はかねてから官報ブログやStrainerを通じて認知していました。

テックブログを読んだりサービスをチェックし、使われている技術が自分のスタックに一致していることやOSSに関わる方がいることから、ここならエンジニアとしての腕を磨けて、自分が抱える課題にも向き合えると思い入社することにしました。ちなみに内定を承諾するまで1ヶ月ほど検討しました。早期承諾を迫られることはなく、いつまでも待つと仰って頂きました。

現在インターンとしてどのような業務を行っているか

現在大学の卒業を控えており、卒業までの間は11月よりエニグモインターンとして勤務しています。

入社して1ヶ月程度なので、BUYMAの開発フローの理解に励んでいます。BUYMAは運用年数が長きに渡る大規模サービスであり、開発の中でいろいろ複雑な点があると感じます。簡単なチケットを割り当ててもらい業務に慣れている段階です。

新卒入社の先輩である平井さんにメンターとしてお世話になっています。チーム長の大川さんも含めて3人で朝会を行い業務をスタートし、疑問があればSlackで伝えたりZoom/Google meetをつないでペアプロをします。業務の終わりに日報を書き、作業内容を整理しています。

普段気をつけていることは「抱え込まない、自分で考える」です。分からないことは速やかに相談しますが、検索したりソースコードを読んで理解できそうことは自力で解決します。

今後の抱負

4月から社員として本格的に開発に携わる予定です。事業に貢献できるエンジニアになりたいと思います。業務に加え、情報技術者試験の受験、OSSへのコミットも目標に技術力を磨いていきたいです。

エンジニアを目指す就活生へのメッセージ

エンジニアとして生きていく以上、常に勉強をすることが大切です。(自戒を込めて)

初心者あるあるですが、こんなWebサービスを作りたい!と意気込んで大きいものを作ろうとして、結局何も出来ずに終わることがあります。

何か作りたいけど何をすれば良いかわからないという場合にはCRUD操作ができるアプリケーション、CLIツールや電卓など、地味で小さいものから作ることをお勧めします。進歩が目に見えてモチベーションが持続しやすいです。Ruby gemやnpmパッケージの自作もお勧めです。

そして何より、プログラミングを楽しみましょう。エンジニアに限らないことですが、仕事をしたり人と付き合う中でつらいと感じることがあると思います。でも、コードを読み書きすることにすら喜びを感じられないのはもっとつらいです。

リーナス・トーバルズLinuxカーネルを作ったのは世のためでも人のためでもなく「僕にとって楽しかったから」です。楽しくやりましょう。

Just for Fun: The Story of an Accidental Revolutionary

Just for Fun: The Story of an Accidental Revolutionary

僕から以上です。最後までお読み頂きありがとうございました。

明日の記事の担当はエンジニアの大川さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

半年間使って便利だったVSCodeの機能紹介

はじめに

こんにちは、今年の6月にエニグモに入社したサーバーサイドエンジニアの橋本です。 この記事は Enigmo Advent Calendar 2020 の6日目の記事です。

みなさんはテキストエディタは何を使っていますか? 会社を見渡すとVimが一番多いような気がしますが、私はVSCodeを使ってます。 正直、エニグモ に入社するまではツールを入れる程度でそこまでカスタマイズしていなかったのですが、入社してからは諸先輩方の開発スピードに圧倒され、これはツールやショートカットキーを駆使して速く開発できるようにならなければ、、、という必要性に駆られ、少しずつカスタマイズを加えてきました。 この記事では初期設定でも使える便利機能やカスタマイズを加えてよかったショートカットキーやツールをピックアップして紹介していきたいと思います。

ショートカットキー

まずは使ってよかったショートカットキーについて紹介します。VSCodeではデフォルトでショートカットキーが予め設定されていますが、下記の手順でショートカットキーを追加できたり、コマンドを変更できたりします。
①下記画像のようにcmd + p でコマンドパレットを開き、>keyboardで検索し、Keyboard Shortcutsを開きます。 f:id:enigmo7:20201202100517p:plain ②下記画像のようにショートカットキー一覧が表示されます。画面上部のバーでショートカットキーが検索でき、編集したい項目をクリックすればショートカットキーを変更することができます。またwhenカラムではショートカットキーを実行するタイミングを設定することもできます。 f:id:enigmo7:20201202100753p:plain

デフォルトの設定でよく使うショートカットキー

それではデフォルトの設定でよく使ったショートカットキーを紹介していきます。 (※ショートカットキーはmacOSのキー配置に基づいて書いてます)

操作 コマンド
フォルダを開く ⌘+O
ファイル検索 ⌘+⇧+F
ファイルに移動 ⌘+P
ファイルを閉じる ⌘+W
一行選択 ⌘ + L
単語ごとに移動 ⌥+矢印キー
単語ごとに選択 ⇧+⌥+矢印キー
画面移動 ⇧+⌘+ 矢印キー
windowを右へ移動(windowが無い場合は分割) ⌃+⌘+→
windowを左へ移動(windowが無い場合は分割) ⌃+⌘+←
指定の行数まで移動 ^+G+ 行数
一番上まで移動 ⌘+↑
一番下まで移動 ⌘+↓
一番右に移動 ⌘+→
一番左に移動 ⌘+←
window1にフォーカス ⌘+1
window2にフォーカス ⌘+2
windowの切り替え ^+W

カスタマイズしたショートカットキー

次によく使うのに配置が使いにくかったり、元々割り当てられていなかったりしてカスタマイズを加えたショートカットキーの紹介です。 デフォルトの設定で既に入力しやすいショートカットキーはほとんど割り当てられているので探すのが大変ですが、自分で使いやすいようにカスタマイズを加えると一気に使いやすくなりました。

操作 コマンド
ターミナルへフォーカス ^+E
ターミナルの分割 ^+V
エディタへフォーカス ^+E
相対パスのコピー ⌘+⇧+C
絶対パスのコピー ⌘+⇧+A
windowを拡張 ^+⌘+矢印キー

相対パス/絶対パスのコピーはデフォルトの設定だと使いにくいので是非変えてみてください。私はターミナルにフォーカスしても使えるようにwhenカラムは空で設定しています。

導入してよかったツール

導入してよかったツールについて紹介します。

Ruby

Rubyを始めた人ならみんな入れると思いますが、一応。 Rubyのコードをハイライトしてくれます。

Ruby Solargraph

こちらもRubyを始めた人なら知っているとおもいますが、メソッドを予測して補完してくれます。 gemのinstallが必要です。

$ gem install solargraph

Endwise

このツールはendを自動補完してくれるツールでendの書き漏れを防いでくれると同時にコードを書くスピードを上げてくれます。

GitLens

GitLensはVSCodeでGitをより扱いやすくするツールです。色々な機能があるのですが、このツールで一番便利なのは過去のコミットが追いやすくなることだと感じました。 例えばGitで管理しているファイルで特定の行にフォーカスすることでその行の過去のコミットが表示されます。 f:id:enigmo7:20201202100841p:plain

またVSCodeの左側のBarのGitのマークをクリックすることで現在開いているファイルの過去のコミットを表示させることもできます。 f:id:enigmo7:20201202100854p:plain

上記のことはgit blameでも確認することはできますが個人的にはこっちの方が使いやすかなと思いました。

REST Client

PostmanのようにAPIにリクエストできるツールです。 使い方は簡単で拡張子がrestとなるようなファイルを作ります。 そして今回は例としてhttp://localhost:7700/user_authentication/jsonをpostするリクエストを書いていきます。

POST http://localhost:7700/user_authentication/
Content-Type: application/json

{
  "user": "user_hoge",
  "pass": "test_hoge"
}

ツールを導入している場合、下記画像のように2~3行目の間にSend Requestボタンが表示されます。 f:id:enigmo7:20201202100953p:plain

このボタンを押すことでリクエストすることができ、下記画像のようにリクエスト結果が表示されます。 f:id:enigmo7:20201202101018p:plain

導入してみて面白かったツール

VSCodeでこんなこともできるんだーと面白かったので紹介です。

Browser Preview

このツールを導入するには事前にChromeを入れる必要がありますが、なんとVSCode上でブラウザを開くことができます。 f:id:enigmo7:20201202101035p:plain

ブラウザと行き来しなくてよくなるのでフロントの開発がしやすくなるかもしれませんが、Chromeにあるような開発者ツールが無いのが残念。。。

その他やってよかったこと

フォルダを統合してワークスペースを作る

通常、VSCodeでは1windowにつき1つのフォルダしか開くことができませんが、ワークスペースを作成することで複数のフォルダを1windowで開くことができます。 今回は/配下に存在するadventとcalendarの2つのフォルダでワークスペースを作成し、1つのwindowで開けるようにしていきたいと思います。

$ ls
advent          calendar

まず、adventフォルダを開き、File>Save Workspace Asを開き、workspaceの名前をつけて保存します。(ここではworkspaceの名前はADVENT_CALENDARとしました。)

次に File>Add Folder to Workspaceを選択し、workspaceにcalendarフォルダを追加していきます。

するとVSCode左側のバーにCalendarフォルダが追加されたのが確認できます。 f:id:enigmo7:20201202101255p:plain

こんな感じでworkspaceを作成することができます!

おわりに

この記事では私が便利だと思ったツールやショートカットをピックアップして紹介してきましたが、もしもっといいツールがあれば是非紹介してください!!

明日の記事の担当はサーバーサイドエンジニアの岡本さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

2 段階認証のワンタイムパスワードってよく使うけどどういう仕組みなの???

こんにちは。サーバーサイドエンジニアの伊藤です。

この記事は Enigmo Advent Calendar 2020 の 5 日目の記事です。

さっそくですが、みなさん 2 段階認証(2FA) のワンタイムパスワードの発行には何を利用していますか?

私は普段 Authy という 2 段階認証アプリを利用しています。ただ、AWSコマンドラインから操作する時などわざわざターミナルからアプリに移動してワンタイムパスワードをコピーして貼り付けるのが面倒だと思うことがありました。

ということで、OATHTOOLpeco を利用して CLI からワンタイムパスワード(TOTP)を取得するラッパーコマンドを作成しました。

今回は折角なので 2 段階認証(2FA) についてと 2 段階認証アプリで利用されるワンタイムパスワード(TOTP) についても調査したのでまとめました。

最後に作成したラッパーコマンドについて少しだけ紹介します。

Two-Factor Authentication(2FA)

そもそも、Two-Factor Authentication(2FA) とは何なのか?

ログインする際にユーザー名とパスワードに加えて、もう一つ追加の要素を要求する認証方法です。 万が一パスワードが漏洩した場合でも追加の要素を知らない限りログインすることができません。 これにより、セキュリティの強度を高めることが可能です。

一般的に追加の要素には下記の方法が用いられます。

  • Something you know(認証を行う本人のみが知る情報)
    • e.g. 認証番号(PIN)、パスワード、秘密の質問への回答 etc...
  • Something you have(認証を行う本人が所有するもの)
  • Something you are(認証を行う本人の身体的特徴)

みなさんご存知の AuthyGoogle Authenticator といったアプリはこの Something you have を用いた認証方法の一種です。 これらのアプリでは 2 つめの要素である Time-based One-Time Password(TOTP) を生成・取得することが可能です。

f:id:sean0628:20201128110604p:plain

HOTP vs. TOTP

ここででてきた Time-based One-Time Password(TOTP) とは何なのでしょうか? TOTP についてそのもととなる HMAC-based One-Time Password(HOTP) と合わせて説明していきます。

HOTP: HMAC-based One-Time Password (RFC 4226)

8-byte のカウンター(可変値)と秘密鍵をもとにワンタイムパスワードを生成します。 これらの情報をクライアント側とサーバー側で共有することで認証を行います。

HOTP を生成するためのアルゴリズムは下記のようになっております。 詳細は省きますが可変値であるカウンターと秘密鍵を HMAC に渡してその返り値を truncate することでワンタイムパスワードを生成します。

// Algorithm
C      8-byte counter value, the moving factor.  This counter
       MUST be synchronized between the HOTP generator (client)
       and the HOTP validator (server).

K      shared secret between client and server; each HOTP
       generator has a different and unique secret K.

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

出典: HOTP: An HMAC-Based One-Time Password Algorithm

TOTP: Time-based One-Time Password (RFC 6238)

次は TOTP です。

下記のアルゴリズムからわかるように、TOTP のアルゴリズムの根本的部分は HOTP のものと同様です。唯一可変値であるカウンターの代わりに時間表現が用いられることが HOTP とは異なります。 時間表現と秘密鍵をクライアント側とサーバー側で共有しこれらをもとに発行したワンタイムパスワードを利用し認証を行います。

// Algorithm
X      represents the time step in seconds (default value X =
       30 seconds) and is a system parameter.

T0     is the Unix time to start counting time steps (default value is
       0, i.e., the Unix epoch) and is also a system parameter.

T = (Current Unix time - T0) / X
TOTP = HOTP(K, T)

出典: TOTP: Time-Based One-Time Password Algorithm

中身を見てみると HOTP も TOTP も想像よりはるかにシンプルだということがわかります。 アルゴリズム自体も簡単なものなので自前で実装してみるのも面白そうですね。

OATHTOOL ラッパーコマンド

さて、最後に少しだけ今回作成した CLI からワンタイムパスワード(TOTP)を取得する OATHTOOL のラッパーコマンドを紹介します。

Usage

$ mfa -h
usage: mfa [-h | --help] [-[no]-c | --[no]-copy] [-a <account>| --account <account>] [-l | --list]
  -v, --version                      Prints the version.
  -h, --help                         Prints this message.
  -[no]-c, --[no]-copy               Copies the generated token to the Clipboard.(default)
  -a <account>, --account <account>  Copies the generated token of <account> to the Clipboard.
  -l, --list                         Prints a list of available authenticator accounts.

基本的には peco を利用し、存在するアカウントから TOTP を取得したいアカウントを選択します。 -a|--account を利用することで TOTP を取得したいアカウントを明示的に指定することも可能です。

Demo

https://user-images.githubusercontent.com/35167423/96361564-bddfe900-1161-11eb-8371-d16c5be8469c.gif

Details

ラッパーコマンドの利用方法は下記をご覧ください。

github.com

最後まで読んでいただきありがとうございました。

明日の記事の担当はサーバーサイドエンジニアの橋本さんです。お楽しみに。

参考


株式会社エニグモ 正社員の求人一覧

hrmos.co

エンジニア未経験入社が語る、エニグモのオンボーディングについて

こんにちは、サーバーサイドエンジニアの寺田です。
この記事は Enigmo Advent Calendar 2020 の 4日目の記事です。

みなさんはエンジニアへの転職!と聞くとどんなことをイメージするでしょうか???
もちろんスキルアップできそうといったポジティブなイメージもあると思いますが、
沸沸とこんな心の声が聞こえてきそうです。。。

  • 自分のプログラミングのスキルでやっていけるのだろうか。。。
  • まともな導入もなくいきなり業務にポーン!あとはよろしく!ってされそう
  • スキルアップできると思ったのに任されるのは簡単な作業ばかり。。。
  • 新しい環境で既存社員の方と仲良くなれるかな。。。

私も!と思うことが1つでもあればぜひこの記事を見ていってください!
エニグモのオンボーディング*1を知って頂いて、エニグモで働いてみたい!と思っていただける方がいてくれると幸いです。

自己紹介

かくいう私もエニグモに入社したのは2020年の7月で、本記事の執筆時点でちょうど歴半年になります。 新卒で入社したSIerを退職して、エニグモが2社目になります。 前職はSIerと言ってもプログラミングは全くしないポジションで、顧客折衝や開発担当のマネジメントが中心でした。なのでエンジニアとしては業務未経験と言える状態でした( ;∀;)

加えて私が入社したのはコロナウイルスによる感染予防真っ只中の7月、 エニグモも例に漏れず原則フルリモートでの勤務でした。 なかなか人と直接会うこともできない状況下で現メンバーと円滑にコミュニケーションをとって仕事進められるのか?不安でいっぱいでした。

そんな中大きな支えとなったのがエニグモのオンボーディングです。
本記事では実際に私が経験したことについて具体的に説明していきます!

完全サポート!メンター制度

エニグモでは新人エンジニアには先輩社員をメンターとしてつけてもらえます!
メンターはこんなことの面倒を見てくれます。

  • 毎日朝会を開催してタスクの進捗状況をウォッチしてくれる。 また困り事があれば質問タイムを設けてくれるのでたちまち解決できます。
  • 毎週振り返りをして今週のタスクや勉強から学んだことを確認。今の自分に足りていないスキルを分析して、次にどんな勉強をしたらいいか?どんなタスクにアサインして欲しいか?など相談できます。
  • 技術的な相談。設計で悩んだところや、コードの不明点、エラーハンドリングなどなんでもOK(Slackに投げると秒で教えてくれます(つД`)ノ)

このメンターについてもらえるというのは精神的な部分でやっぱり安心できます!新しい職場で誰に相談したらいいかもわからない。。。というのはありがちですが、この制度のおかげで全く問題なかったです!またエニグモは現在フルリモート勤務ですが、少なくともメンターと毎日コミュニケーションを取れるので、寂しさを感じることもありません。

成長できる環境

エニグモには業務以外でもスキルを伸ばせる環境があります。 その中でも本記事では以下の3つについて紹介させていただければと思います。

勉強会

若手の社員を中心に週1回の勉強会が開催されています。 形式は輪講という形で、順番に勉強した内容をスライドにまとめて発表し、みんなが質問したり議論したりわいわいと勉強しています! ここでは私が半年間、実際に勉強会で読んできた本を紹介します。

gihyo.jp

オブジェクト指向設計の名著と呼ばれる1冊ですね! 理解が難しいオブジェクト指向の概念ですが、豊富な実装例が示されており初学者でも理解しやすい点が魅力です。 またオブジェクト指向本はサンプルコードがJavaなことが多いですが、この本はRubyで書かれていることもポイントです。

www.oreilly.co.jp

Rubyにおいて強力な武器であるメタプログラミングを学べる1冊です! メタプログラミングそのものを学べることも魅力ですが、 その仕組みに触れることでRubyという言語のコアな部分を学ぶことができ実は初学者にとってもとても有益な本だと思います。

題材とする本は毎回メンバーみんなで決めています。
自由に学びたいことを学べるそんな勉強会になっています!

モブプログラミング

若手とベテラン社員数人でzoomをつないでモブプログラミングをしています。 モブプログラミングとは、ドライバーと呼ばれる一人がコーディングしている画面をみながら、そこにいるメンバーが意見や、提案しながら進めるプログラミング手法の一つです。もう少し詳しく知りたい方は以下の記事などは参考になるかと思います!

モブプログラミングスタートアップマニュアルを更新しました! | TAKAKING22.com

モブプログラミングのメリットはいろいろあると言われていますが、 新人観点で見ると何よりベテランの手元を覗けること、それが一番のいいところだと思っています。コーディングしている時の思考方法とか、ベテランのスピード感を目の前で感じることができます。またエンジニアはそれぞれ自分の生産性を上げるために開発環境にこだわっていろいろカスタマイズしています。ですがそれを教えてもらえる機会というのはなかなかないんですよね。モブプログラミングでベテランの画面を見ている時、すごっ!!何それ!!って思う機能は見て盗んで自分のものにしちゃうこともできます。

オリジナルアプリ開発

あと非常にありがたいのが、スキルアップのために自由にテーマを決めて開発させてくれることです! レビューもあり、フィードバックを受けられますので自己研鑽の何倍も成長できます。別に業務に関係のあるテーマでなくてもいいです。ちなみに私は競馬予想アプリの開発をさせてもらっています(笑)

f:id:enigmo7:20201130175234p:plain
開発している競馬予想アプリ

未経験エンジニアとして入社すると、入社してしばらくの業務は簡単な作業から。。。というのは仕方ない部分でもありますが、それだけだとスキル面で中々成長できないというジレンマがあります。ただこのように自分でテーマを決めて0からアプリを開発することで、設計などのちょっと高い次元のスキルを習得できるのではないかと思います。

いかがでしたでしょうか? エニグモには現在100名ほどの社員がおりますが、 そのような規模感の会社だと入社時のオンボーディングの取り組みが不十分なのでは?と不安になることもあるかと思います。(実際私もそうでした)しかしいざ入社してみると全然そんなことはなく、安心して業務に取り組むことができています!

この記事を見てエニグモに興味を持ってもらえるエンジニアの方が1人でもいてくだされば幸いです!
明日の記事の担当はサーバーサイドエンジニアの伊藤さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

*1:オンボーディングとは、組織に新たに加入した人に手ほどきや支援を行い、定着を促したり慣れてもらう活動のこと。新人研修やそのあと配属後に受けられる支援プログラムなどのこと。

競馬必勝本は本当に当たるのかを検証!〜Pythonで実装する馬券自動選択ツール〜

こんにちは、サーバーサイドエンジニアの竹本です。 この記事は Enigmo Advent Calendar 2020 の3日目の記事です。

みなさまは2020年に買った中でよかったものはなんでしょう?

私はiPadです。

最新 Apple iPad Pro (12.9インチ, Wi-Fi, 128GB) - シルバー (第4世代)

最新 Apple iPad Pro (12.9インチ, Wi-Fi, 128GB) - シルバー (第4世代)

  • 発売日: 2020/03/25
  • メディア: Personal Computers

主にkindleを見開きで読むことに活用しています。

エニグモの福利厚生の一つ「エンジニアサポート」で5万円の補助を受けました。わーい。 https://enigmo.co.jp/recruit/culture/

そしてみなさまは馬券、買っていますか?
馬券は競馬に賭ける際に購入する投票券です。
1口100円から、ネットでも気軽に購入することができます。(競馬は20歳から)
弊社にも数名競馬好きが在籍しており、時折競馬トークで盛り上がることもあります。

「競馬必勝本」は本当に当たるのか?

「競馬必勝本」というものが巷にはあります。
こういった本では勝った時の馬券と払戻金だけセンセーショナルに紹介されていることが多く、 実際どのくらい賭けてどのくらい当たっているのかわからないことが多いです。
なので実際に本の通りに馬券を購入していたらどのくらい勝つのかを検証してみます。

今回参考にするのは「競馬力を上げる馬券統計学の教科書」です。

競馬力を上げる馬券統計学の教科書

競馬力を上げる馬券統計学の教科書

世にある競馬必勝本の中でも、オッズのデータから勝ち馬を選ぶという、タイトルの通り統計的な要素の多い本になっています。
また馬やジョッキーに関係なくオッズのみを参考にしているので、さまざまなレースがあるなかで汎用的に活用できるという利点があります。

この本に書いてあることをざっくりまとめると以下のようになります。

  • 万馬券を狙え
  • 的中率ではなく回収率にこだわれ

3000円分の馬券を書い続けて3回に1回10000円が当たれば1000円プラスということですね。つまり当たれば万馬券になるようなレース、「穴馬」が勝ち馬にいる馬券を買う必要があります。

馬連オッズの壁」の法則で穴馬を選ぼう

本書の中で最もシンプルな穴馬選択方法として紹介されているのが「馬連オッズの壁」の法則です。 (馬連とは上位2頭を当てる馬券のこと)

この法則を簡単に紹介すると

  • レース
    • 14頭以上出走する
    • 馬連1位人気オッズが9倍以上
    • 単勝オッズ30倍以内の馬が10頭以上
    • 馬連オッズ1位人気馬に単勝1位人気馬が含まれる
  • 穴馬
    • 単勝1位人気馬の馬連オッズを人気順に並べた時1.8倍以上の壁がある時、その壁の前2頭を選ぶ
  • 馬券の組み立て方

本書ではさまざまな条件を組み合わせて馬券を選択していますが、 今回は実装の簡便さのため条件も簡略化していることをご了承ください。

実際のコード

今回はGoogle Colabratoryを利用しました。 Googleアカウントがあれば環境構築もなしにpythonが使えて便利ですね。 https://colab.research.google.com/

2020/11/27現在動くことを確認しているのでみなさまもぜひ使ってみてください。

まず必要なライブラリのインストールします

!apt-get update
!apt install chromium-chromedriver
!cp /usr/lib/chromium-browser/chromedriver /usr/bin
!pip install selenium
!pip install lxml

importします

import pandas as pd
from bs4 import BeautifulSoup
import urllib.request as req
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import numpy as np
import urllib.parse

今回用意した関数

def set_selenium():
  options = webdriver.ChromeOptions()
  options.add_argument('--headless')
  options.add_argument('--no-sandbox')
  options.add_argument('--disable-dev-shm-usage')
  driver = webdriver.Chrome('chromedriver',options=options)
  driver.implicitly_wait(15)
  return driver

def get_raceids(date):
  url = "https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=" + date
  res = req.urlopen(url)
  racesoup = BeautifulSoup(res, "html.parser")
  racelist = racesoup.select("#RaceTopRace > div > dl > dd > ul > li > a:nth-of-type(1)")
  raceids = [ urllib.parse.parse_qs(urllib.parse.urlparse(race.get('href')).query)['race_id'][0] for race in racelist ]
  return raceids

def get_tansho_ichiban(raceid):
  driver = set_selenium()
  driver.get("https://race.netkeiba.com/odds/index.html?type=b1&race_id="+raceid+"&rf=shutuba_submenu")
  html = driver.page_source.encode('utf-8')
  tanhukusoup = BeautifulSoup(html, "html.parser")
  tanhukudfs = pd.read_html(str(tanhukusoup.html))
  tansho_ichiban = tanhukudfs[0][tanhukudfs[0]['オッズ'] == tanhukudfs[0]['オッズ'].min()]['馬番'].values[0]
  return tansho_ichiban, tanhukudfs[0]

def get_umarenodds(raceid):
  driver = set_selenium()
  driver.get("https://race.netkeiba.com/odds/index.html?type=b4&race_id="+raceid+"&housiki=c0&rf=shutuba_submenu")
  html = driver.page_source.encode('utf-8')
  soup = BeautifulSoup(html, "html.parser")
  dfs = pd.read_html(str(soup.html))
  umarendf = pd.DataFrame(index=[1])

  for i, df in enumerate(dfs):
    umarendf = pd.concat([umarendf, df.set_index(str(i+1)).dropna(how='all', axis=1)], axis=1)

  if umarendf.isin(['取消']).values.any() | umarendf.isin(['除外']).values.any():
    return False

  umarendf[umarendf.index.max()]=0
  umarenodds = pd.DataFrame(umarendf.fillna(0).astype('float64').values + umarendf.astype('float64').fillna(0).values.T, columns=list(map(int,map(float,umarendf.columns))), index=umarendf.index).replace(0,np.nan)
  return umarenodds

def get_baken(raceid):
  tansho_ichiban, tanhukudf = get_tansho_ichiban(raceid)
  umarenodds = get_umarenodds(raceid)
  if umarenodds is False:
    return False
  
  umarenninki = umarenodds.min()
  umaren_ichiban = umarenninki[umarenninki == umarenninki.min()].index

  if umarenninki.min() >= 9 and any(umaren_ichiban == tansho_ichiban) and umarenninki.index.max() >= 14 and sum(tanhukudf["オッズ"]<=30)>=10:
    ninkiuma = umarenodds[tansho_ichiban].sort_values()

    anaumalist = []
    for idx in np.where((ninkiuma/ninkiuma.shift(1) > 1.8).values == True)[0]:
      two = idx -1
      anaumalist = anaumalist + (ninkiuma/ninkiuma.shift(1) > 1.8).index.values[two:two+2].tolist()
    if not anaumalist:
      return False

    formation1 = ninkiuma.fillna(0).sort_values()[0:4].index.values
    formation2 = ninkiuma.fillna(0).sort_values()[4:8].index.values
    return {'anauma':anaumalist, 'formation1':formation1, 'formation2':formation2}

  return False

def get_dayresult(date):
  kakekin = 0
  modorikin = 0

  raceids = get_raceids(date)
  for raceid in raceids:
    baken = get_baken(raceid)
    if not baken:
      continue
    result = pd.read_html("https://race.netkeiba.com/race/result.html?race_id="+raceid)
    sanrenpuku = list(map(int,result[2].set_index(0)[1]['3連複'].split()))
    money = int(result[2].set_index(0)[2]['3連複'].replace(',','').replace('円',''))

    if bool(set(sanrenpuku) & set(baken['formation1'])) & bool(set(sanrenpuku) & set(baken['formation2'])) & bool(set(sanrenpuku) & set(baken['anauma'])):
      kakekin += 100*len(baken['formation1'])*len(baken['formation2'])*len(baken['anauma'])
      modorikin += money
    else:
      kakekin += 100*len(baken['formation1'])*len(baken['formation2'])*len(baken['anauma'])
  
  cols = ["賭け金","払戻金"]
  return pd.Series([kakekin,modorikin],index=cols,name=date)

netkeibaからSelenium + BeautifulSoupでオッズのデータをスクレイピングします。
その結果をpandasで前処理し、上記の馬連オッズの壁」の法則から馬券を選択
選択した馬券が当たっているのかを検証します。

回収率にこだわるのが本書の方針なので、検証項目として

  • 条件に合致した全ての三連複馬券を100円で購入 = 賭け金
  • 当たったレースの実際の払戻金

以上の差額を見ることにします。

今回は11月の1~23日までのレースを検証します。

# 開催日のリスト
datelist = [
            '20201101',
            '20201107',
            '20201108',
            '20201114',
            '20201115',
            '20201121',
            '20201122',
            '20201123'
]

moukaridf = pd.DataFrame()
for date in datelist:
  onedaydf = get_dayresult(date)
  moukaridf = moukaridf.append(onedaydf)

時間かかりますが待ちましょう

結果

moukaridf.sum()['払戻金'] - moukaridf.sum()['賭け金']
# 出力
-14630.0

金額としては14630円負けてしまいました。

moukaridf
払戻金 賭け金
20201101 33500.0 12800.0
20201107 0.0 9600.0
20201108 5560.0 16000.0
20201114 0.0 25600.0
20201115 45510.0 25600.0
20201121 0.0 0.0
20201122 0.0 9600.0
20201123 0.0 0.0

日別に見ると勝っている日もありますね。

本書では回収率を上げるための馬券選択方法がさらに細かく紹介されていたので、その通りに実装すればもっと良い結果となるかもしれません。

t検定をすると、「賭け金、払戻金の差額の平均が0ではない(正負どちらかに傾く)」という帰無仮説が棄却されます。(p > 0.05 平均 -1828.8 ± 14776 円)

sagaku =  moukaridf[['払戻金']].values - moukaridf[['賭け金']].values
# 平均
print(sagaku.mean())
# 標準偏差
print(sagaku.std())
# 出力
-1828.75
14776.743076114573

よって結論は「勝つこともあれば負けることもある!」

馬券を買おう

ではせっかく作ったので実際に賭けてみようと思います!
検証では最終オッズから馬券を選択していましたが、当日は10:30のオッズを元に馬券を選択します。
予算の関係で条件に合致した全ての馬券ではなく、1レース選択してフォーメーション3連複馬券を購入します。
11/29の10:30時点で候補が4レースありました。

date = "20201129"
raceids = get_raceids(date)
for raceid in raceids:
  baken = get_baken(raceid)
  if baken:
    print('https://race.netkeiba.com/race/shutuba.html?race_id='+raceid)
    print(baken)
    print("=======================")

 

# 出力
# 条件に合致したレースのURLと購入すべき馬券がプリントされる
https://race.netkeiba.com/race/shutuba.html?race_id=202005050907
{'anauma': [4, 16], 'formation1': array([7, 6, 3, 2]), 'formation2': array([10, 14, 15,  8])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202005050911
{'anauma': [11, 6, 1, 16], 'formation1': array([ 4, 14,  9, 13]), 'formation2': array([12,  5,  2,  3])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202009050904
{'anauma': [3, 7], 'formation1': array([ 8, 15,  5,  1]), 'formation2': array([16,  4, 13, 12])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202009050912
{'anauma': [7, 6], 'formation1': array([13,  3, 10, 11]), 'formation2': array([ 2,  9, 15,  5])}
============

今回は東京7Rに賭けます! (出力で一番上のレース)

穴馬が「4,16」1~4位が「7, 6, 3, 2」,5~8位が「10, 14, 15, 8」です。

f:id:enigmo7:20201130123855p:plain 2020年11月29日アクセス
https://www.ipat.jra.go.jp/

頼むぞ〜!

結果は…

1位 2番
2位 10番
3位 8番

3歳以上2勝クラス 結果・払戻 | 2020年11月29日 東京7R レース情報(JRA) - netkeiba.com

残念!穴馬候補だった4番と16番が3位以内に入りませんでした。惜しかったですね。
それではみなさまも良い競馬ライフをお送りください。

スクレイピング、クローリングはアクセス先に配慮してやりましょう。

参考資料

競馬力を上げる馬券統計学の教科書

増補改訂Pythonによるスクレイピング&機械学習 開発テクニック

ColaboratoryでSeleniumが使えた:JavaScriptで生成されるページも簡単スクレイピング - Qiita

明日の記事の担当はサーバーサイドエンジニアの寺田さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co