Go言語で五目並べを作ってみた

はじめに

数か月前、Go言語で書かれたソースコードを読まなければいけなくなり A Tour of Go を読んで勉強したり、 実際にソースコードを読んだりしていました。 それ以来まったくGo言語を使っていなかったのですが、 最近Go言語を使ってみたいと思い、 A Tour of Goを読んで勉強しなおしました。(まだgoroutineの手前までしか読んでないけど)

何か作らないといろいろ忘れてしまいそうなので Go言語で五目並べを実装することにしました。 禁じ手などの実装が面倒なので、 禁じ手なし、5つ以上石を並べれば勝利のシンプルなルールで実装しました。

今回作ったものはこちらです。

実装

まず、盤面の状態を表現する型Boardを定義します。 盤面のサイズは15x15で固定で石は'X'と'O'、石がない場所は'-'で表現することにしたので 1次元のbyte型の配列を使いました。

const (
    Length      = 15
    Size        = 225
    ChainLength = 5
)

type Board [Size]byte

コンストラクNewBoardを定義しました。 配列をすべて'-'で初期化するだけです。

func NewBoard() *Board {
    var board Board
    for i := 0; i < Size; i++ {
        board[i] = '-'
    }
    return &board
}

石を指定した位置に置くメソッドPlaceを定義しました。

  • 石が'X'または'O'であるか
  • 指定された位置は盤面内か
  • 指定された位置に石がないか

をチェックし、 すべてのチェックが通ったら 配列の指定された位置に石を表現する文字を代入します。

func (b *Board) Place(stone byte, x, y uint8) error {
    if stone != 'O' && stone != 'X' {
        return InvalidStoneError(stone)
    }

    if x >= Length || y >= Length {
        return OutOfRangeError{x, y}
    }

    pos := x + y*Length

    if b[pos] != '-' {
        return AlreadyExistError{x, y}
    }

    b[pos] = stone
    return nil
}

最後に指定した位置の石を含む5つ以上の並びを見つけるメソッドIsChainを定義しました。

Go言語のMin関数はfloat64型にしか対応していなかったので 自分でuint8型のmin関数を定義しました。

shiftメソッドはposの位置からstepで指定した方向に同じ石が何個並んでいるか数える関数です。 引数maxIterで指定した回数以上数えないようにできます。

IsChainは縦・横・斜め4方向にshift関数を適用し、 同じ石が5つ並んでいる場所がひとつでもあればtrue、 ひとつもなければfalseを返します。 私はプログラム苦手なのでこんな方法しか思いつかないですが、 もっと効率の良い方法があるかもしれません。

func min(vals ...uint8) uint8 {
    minVal := vals[0]
    for _, val := range vals[1:] {
        if val < minVal {
            minVal = val
        }
    }
    return minVal
}

func (b *Board) shift(stone byte, pos uint8, step uint8, maxIter int) uint8 {
    var length uint8 = 0
    for i := 0; i < maxIter; i++ {
        pos += step
        if b[pos] != stone {
            break
        }
        length++
    }
    return length
}

func (b *Board) IsChain(x, y uint8) (bool, error) {
    if x >= Length || y >= Length {
        err := OutOfRangeError{x, y}
        return false, err
    }

    var (
        pos   = x + y*Length
        stone = b[pos]
    )

    if stone != 'O' && stone != 'X' {
        return false, InvalidStoneError(stone)
    }

    steps := [4]uint8{1, Length - 1, Length, Length + 1}
    for _, step := range steps {
        var (
            length         uint8 = 1
            maxShifts      uint8 = Length - 1
            shiftV, shiftH uint8
        )

        if step != 1 {
            shiftV = maxShifts - y
        } else {
            shiftV = Length
        }

        switch step % Length {
        case 0:
            shiftH = Length
        case 1:
            shiftH = maxShifts - x
        default:
            shiftH = x
        }

        maxIter := int(min(ChainLength-length, shiftV, shiftH))
        length += b.shift(stone, pos, step, maxIter)

        if shiftV != Length {
            shiftV = maxShifts - shiftV
        }

        if shiftH != Length {
            shiftH = maxShifts - shiftH
        }

        maxIter = int(min(ChainLength-length, shiftV, shiftH))
        length += b.shift(stone, pos, -step, maxIter)

        if length >= ChainLength {
            return true, nil
        }
    }

    return false, nil
}

上で説明した以外にもターミナルに表示するためのコードをいろいろ書きました。 \033[Gとか\033[Aみたいなエスケープシーケンス?といわれるものを使って 石を置くたびに盤面の表示を更新するようにしました。

完成

下のようなプログラムが完成しました。

f:id:stkns1024:20211219113059j:plain
できたもの

Go言語の良いところ

文法がシンプルで覚えやすかったです。 Go言語の文法を思い出すためにA Tour of GOを読みましたが、 数日でほとんど読み終わり、 すぐにコードを書き始めることができました。

この記事内で説明していない箇所で構造体の埋め込みを使ったのですが、 便利な機能だと思いました。

おわりに

Go言語でプログラムを書くのははじめてでしたが、 書いていて楽しかったので、 今後も使い続けてGo言語マスターを目指したいです。

五目並べのプログラムを作ったあと、 一緒に五目並べをしてくれる友達がいないことに気付いたので友達を作りたいです。

RustでPythonモジュールをつくってみた

はじめに

最近スパースモデリングに興味があり、 スパース推定法による統計モデリング を読んでいました。(まだ途中までしか読んでないけど) ちょうどRustで何か書いてみたいと思っていたので この本の中に登場するエラスティックネットをRustで実装しようと思いました。

EvcxrやPolarsなどのライブラリを使えば、Jupyter NotebookでRustを使ったり、 EDAや前処理もできるのですが、 個人的にRustでEDAや前処理するのが辛いなと感じました。 そこでRustで書いたエラスティックネットをPythonから呼び出せるようにしました。

今回つくったものはこちらです。

ライブラリ

Pythonから呼び出せるモジュールをつくるために以下のライブラリを使いました。 ほかにもパラメータ推定のためにrand, ndarray-randを使っています。

  • Rustライブラリ
    • PyO3: PythonからRustでつくったプログラムを呼び出す用
    • rust-numpy: NumPy配列とRustのndarrayを変換する用
    • ndarray: Rustで使える多次元配列のライブラリ
  • Pythonライブラリ

Cargo.tomlの設定

プログラムのビルドと外部ライブラリを使うためCargo.tomlに以下を追加しました。 cdylibは共有ライブラリをつくるために必要らしいです。

[lib]
crate-type = ["cdylib"]
name = "gmelasticnet" # ライブラリの名前

[dependencies]
ndarray = "0.15.3"
ndarray-rand = "0.14.0"
numpy = "0.15.0"
pyo3 = { version = "0.15.1", features = ["extension-module"] }
rand = "0.8.4"

Rustの実装

RustのプログラムをPythonから使うために Rustのコードを書き換える必要がありました。 PyO3のユーザーガイドを参考にRustコードを書きました。

エラスティックネットのパラメータ推定のコードはsrc/elasticnet.rsに書きました。 パラメータ推定に使うクラスElasticNetElasticNet構造体やそのメソッド定義にアトリビュートをつけることで実装します。 自作モジュールgmelasticnetelasticnetという名前でサブモジュールを追加するために elasticnet関数を定義しています。 この関数内でElasticNetクラスをモジュールに追加しています。

// Pythonに公開する構造体
#[pyclass]
struct ElasticNet {
    scaler: StandardScaler,
    y_mean: f64,
    xy_cov: Array1<f64>,
    x_cov: Array2<f64>,
}

// Pythonに公開しないメソッド
impl ElasticNet {
    fn n_features(&self) -> usize {
        self.xy_cov.len()
    }
}

// Pythonに公開するメソッド
#[pymethods]
impl ElasticNet {
    #[new] //コンストラクタを定義
    fn new(y: PyReadonlyArray1<f64>, x: PyReadonlyArray2<f64>) -> Self {
        // NumPy配列をRustのndarrayに変換
        let y = y.as_array();
        let x = x.as_array();

        // なんかいろいろ計算する

        Self {
            scaler,
            y_mean,
            xy_cov,
            x_cov,
        }
    }

    #[args(l1 = "0.1", l2 = "0.1", max_iter = "1000", tolerance = "1e-4")] // デフォルト引数を設定できる
    fn fit(
        &self,
        l1: f64,
        l2: f64,
        max_iter: i32,
        tolerance: f64,
        random_state: Option<u64>, // Optionの引数はNoneがデフォルト値
        model: Option<&ElasticNetParams>, // たしか Option<ElasticNetParams> だとエラーになった
    ) -> ElasticNetParams {
        // なんかいろいろ計算する

        ElasticNetParams::new(self.scaler.clone(), self.y_mean, coef)
    }
}

#[pymodule]
fn elasticnet(_py: Python, m: &PyModule) -> PyResult<()> {
    // Rustの構造体をPythonのクラスとして追加
    m.add_class::<ElasticNet>()?;
    m.add_class::<ElasticNetParams>()?;
    Ok(())
}

src/lib.rsは以下のとおりです。 上のsrc/elasticnet.rsに同名のelasticnet関数を定義したせいで分かりにくいですが、 elasticnet関数をm.add_wrapped(wrap_pymodule!(elasticnet))とすることで gmelasticnetelasticnetをサブモジュールとして追加しています。

mod elasticnet;

use elasticnet::*;
use pyo3::prelude::*;
use pyo3::wrap_pymodule;

// ここの関数名はCargo.tomlで設定したライブラリの名前と同じにしないといけないみたい
#[pymodule]
fn gmelasticnet(_py: Python, m: &PyModule) -> PyResult<()> {
    // elasticnet関数を渡すことで
    // elasticnetをgmelasticnetのサブモジュールとして追加
    m.add_wrapped(wrap_pymodule!(elasticnet))?;
    Ok(())
}

ビルド

setuptools-rustREADME.mdを参考にいろいろ設定しました。

setup.pyは下のようになりました。

from setuptools import setup
from setuptools_rust import Binding, RustExtension

setup(
    name="gmelasticnet",
    version="0.1.0",
    rust_extensions=[
        RustExtension("gmelasticnet.gmelasticnet", binding=Binding.PyO3)
    ],
    packages=["gmelasticnet"],
    zip_safe=False,
)

下のコマンドを実行して自作モジュールをインストールします。

python ./setup.py install

pip listで確認すると

gmelasticnet                 0.1.0

自分で作成したモジュールがインストールされていました!!

おわりに

今回作成したモジュールだとRustのコードで変更した箇所は

  • アトリビュートの追加
  • モジュール、クラスを追加する関数の定義
  • NumPy配列の変換

だけなので、意外と簡単にRustでPythonモジュールをつくることができました。

データサイエンティストになりたいのでタイタニックコンペに挑戦してみた

データサイエンティストになりたいので初心者向けコンペの タイタニック(Titanic - Machine Learning from Disaster)に挑戦してみました。 今回はKaggle Notebook上でPythonを使ってモデルの学習と予測をしました。 LightGBMでGBDT(勾配ブースティング木)、Kerasでニューラルネットワークのモデルを学習し、 それぞれの予測値の平均をとってアンサンブルしました。

分析の準備

必要なモジュールをインポートします。

from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
import lightgbm as lgb
from tensorflow import keras
from tensorflow.keras import layers

データを読み込みます。 このコンペではtrain.csv、test.csvとgender_submission.csvが与えられます。

data_dir = Path("/kaggle/input/titanic")

train = pd.read_csv(data_dir / "train.csv")
test = pd.read_csv(data_dir / "test.csv")
sample_submission = pd.read_csv(data_dir / "gender_submission.csv")

各カラムの説明は以下のとおりです。

  • Survival - 生存したか(0=死亡、1=生存)
  • Pclass - チケットクラス
  • Name - 名前
  • Sex - 性別
  • Age - 年齢
  • SibSp - タイタニックに乗船した兄弟/配偶者の人数
  • Parch - タイタニックに乗船した親/子の人数
  • Ticket - チケット番号
  • Fare - 運賃
  • Cabin - 客室番号
  • Embarked - 乗船港

特徴量の作成

preprocess関数で前処理をしています。 与えられたデータからTitleとFamilyという特徴量を作りました。 前処理の内容は以下のとおりです。

  • Title - Nameに含まれる敬称(Mr、Miss、Mrs、Master、それ以外)を整数値に変換
  • Cabin - 先頭のアルファベットを抽出し整数値に変換
  • Sex - 女性=0、男性=1に変換
  • Family - SibSpとParchの合計
  • Fare - 分布に偏りがあるのでlog(x+1)で変換
  • Embarked - 整数値に変換

LightGBMは入力に欠損値があっても学習できるのでpreprocessでは欠損値補完していません。 ニューラルネットワーク用にカテゴリ変数(Title、Cabin、Embarked)をone-hot encodingしました。 PassengerId、Name、Ticketは不要なので削除しました。

def preprocess(df):
    df = df.copy()

    df["Title"] = 0
    title_dict = {
        " Mr\. ": 1,
        " Miss\. ": 2,
        " Mrs\. ": 3,
        " Master\. ": 4,
    }
    for pattern, label in title_dict.items():
        df.loc[df["Name"].str.contains(pattern), "Title"] = label
    df["Title"] = pd.Categorical(df["Title"], categories=range(5))

    cabin_dict = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "T": 8}
    df["Cabin"] = df["Cabin"].str[0].replace(cabin_dict).fillna(0)
    df["Cabin"] = pd.Categorical(df["Cabin"], categories=range(9))

    df["Sex"] = df["Sex"].replace({"female": 0, "male": 1})
    df["Family"] = df["SibSp"] + df["Parch"]
    df["Fare"] = np.log1p(df["Fare"])
    df["Embarked"] = df["Embarked"].fillna(0).replace({"C": 1, "Q": 2, "S": 3})
    df["Embarked"] = pd.Categorical(df["Embarked"], categories=range(4))

    dummies = pd.get_dummies(df[["Title", "Cabin", "Embarked"]], drop_first=True)
    df = pd.concat([df, dummies], axis=1)
    df = df.drop(columns=["PassengerId", "Name", "Ticket"])

    return df


_train = preprocess(train)
train_X = _train.drop(columns="Survived")
train_y = _train["Survived"]
test_X = preprocess(test)

モデルの学習・予測

今回はGBDTとニューラルネットワークの2つのモデルを作成し、 予測値の平均をとってアンサンブルします。

GBDT

GBDTの学習・予測は以下のとおりです。 検証データに対する正解率は0.843でした。

cols = ["Pclass", "Sex", "Age", "Fare", "Cabin", "Embarked", "Title", "Family"]
train_X_lgb, val_X_lgb, train_y_lgb, val_y_lgb = train_test_split(
    train_X[cols],
    train_y,
    test_size=0.3,
    shuffle=True,
    stratify=train_y,
    random_state=42,
)
test_X_lgb = test_X[cols]

# データセット作成
train_dataset = lgb.Dataset(train_X_lgb, train_y_lgb)
val_dataset = lgb.Dataset(val_X_lgb, val_y_lgb)

# ハイパーパラメータの設定
params = {
    "objective": "binary",
    "num_leaves": 7,
    "learning_rate": 0.1,
    "bagging_fraction": 0.9,
    "bagging_freq": 1,
    "lambda_l1": 0.5,
    "lambda_l2": 1,
    "seed": 42,
}
categorical_features = ["Cabin", "Embarked", "Title"]

# 学習
model = lgb.train(
    params,
    train_dataset,
    num_boost_round=100,
    valid_sets=[train_dataset, val_dataset],
    early_stopping_rounds=10,
    categorical_feature=categorical_features,
    verbose_eval=10,
)

# 予測
val_pred_lgb = model.predict(val_X_lgb)
test_pred_lgb = model.predict(test_X_lgb)

# 評価
acc = accuracy_score(val_y_lgb, np.where(val_pred_lgb > 0.5, 1, 0))
print("Accuracy:", acc)

ニューラルネットワーク

build_modelニューラルネットワークのモデルを構築しています。 one-hot encodingしたカテゴリ変数を選択しています。 ニューラルネットワークは欠損値を扱えない、スケーリングが必要なのでSimpleImputerで欠損値補完、StandardScalerで標準化しています。

検証データに対する正解率は0.832でした。

def build_model(num_inputs):
    model = keras.Sequential([
        layers.Dense(64, activation="relu"),
        layers.Dropout(0.1),
        layers.Dense(32, activation="relu"),
        layers.Dropout(0.1),
        layers.Dense(1, activation="sigmoid"),
    ])
    model.compile(
        optimizer="adam",
        loss="binary_crossentropy",
        metrics=["accuracy"],
    )

    return model


cols = [
    "Pclass", "Sex", "Age", "SibSp", "Parch", "Fare",
    "Title_1", "Title_2", "Title_3", "Title_4",
    "Cabin_1", "Cabin_2", "Cabin_3", "Cabin_4", "Cabin_5", "Cabin_6", "Cabin_7", "Cabin_8",
    "Embarked_1", "Embarked_2", "Embarked_3",
]
scaler = make_pipeline(
    StandardScaler(),
    SimpleImputer(),
)
train_X_nn = scaler.fit_transform(train_X[cols])
train_X_nn, val_X_nn, train_y_nn, val_y_nn = train_test_split(
    train_X_nn,
    train_y,
    test_size=0.3,
    shuffle=True,
    stratify=train_y,
    random_state=43,
)
test_X_nn = scaler.transform(test_X[cols])

# 学習
model = build_model(len(cols))
early_stopping = keras.callbacks.EarlyStopping(patience=10, restore_best_weights=True)
model.fit(
    train_X_nn,
    train_y_nn,
    batch_size=32,
    epochs=100,
    callbacks=[early_stopping],
    validation_data=(val_X_nn, val_y_nn),
    shuffle=True,
)

# 予測
val_pred_nn = model.predict(val_X_nn).flatten()
test_pred_nn = model.predict(test_X_nn).flatten()

# 評価
acc = accuracy_score(val_y_nn, np.where(val_pred_nn > 0.5, 1, 0))
print("Accuracy:", acc)

アンサンブル

GBDTとニューラルネットワークの予測値を平均します。

test_pred = test_pred_lgb * 0.5 + test_pred_nn * 0.5

提出ファイルの作成

submission = sample_submission.assign(Survived=np.where(test_pred > 0.5, 1, 0))
submission.to_csv("./submission.csv", index=False)

結果

スコアは0.77751でした。 今後もコンペに参加してデータサイエンティストを目指していきたいです!!

f:id:stkns1024:20211014201807p:plain