盆暗の学習記録

データサイエンス ,エンジニアリング,ビジネスについて日々学んだことの備忘録としていく予定です。初心者であり独学なので内容には誤りが含まれる可能性が大いにあります。

japanize-matplotlibの代替ライブラリ matplotlib-fontja

japanize-matplotlibライブラリがPython3.12以降で動かないという問題について、先日はこのような記事を書きました。

nigimitama.hatenablog.jp

記事の内容としては「ライブラリに依存せず自分で日本語対応する場合はこうすればいいよ」というものです。

ただ、調べたところjapanize-matplotlibをforkしたmatplotlib-fontjaというライブラリも存在するようです。

github.com

使い方

pipを使う場合、次のようにインストールできます。

pip install matplotlib-fontja

使い方はjapanize-matplotlibと同じでimportするだけです

import matplotlib.pyplot as plt
import matplotlib_fontja

plt.title("てすと")
plt.xlabel("X軸")

なお、Python3.12の問題に対処したからといって他のバージョンへの対応が弱いと言ったことはなく、3.12より前のバージョンでもちゃんと使えました。 今後japanize-matplotlibの使用感を維持したいならこちらに置き換えていけばよさそうです。

LOWESSのアルゴリズムとPythonでの再現実装例

LOWESS (locally weighted scatterplot smoothing) は散布図に対して下記のような近似曲線を描いてくれる便利な手法です。

statsmodelsパッケージだと

import statsmodels.api as sm
smoothed = sm.nonparametric.lowess(exog=x, endog=y, frac=0.5, it=3)

といった感じで気軽に実行できますが、frac(使用するサンプルの割合)やit(繰り返し数)といったハイパーパラメータがどう作用するのかについては、アルゴリズムを知らないとよくわからずモヤッとしたので調べてみました。

今回はLOWESSが提案されたとされる論文

Cleveland, W. S. (1979). Robust locally weighted regression and smoothing scatterplots. Journal of the American statistical association, 74(368), 829-836.

を読んだので、アルゴリズムPythonでの実装例をメモしておきます。

LOWESSのアルゴリズム

 i番目のサンプルの目的変数 y_iを特徴量 x_iとノンパラメトリックの平滑化関数 g(x_i)で近似することを考えます。

 \displaystyle y_i = g(x_i) + \epsilon_i

ここで \epsilon_iは平均0で分散が一定の確率変数です。

重みの計算と r個の最近傍サンプルの取得

 x_iについて、 j=1,\dots,nにわたって |x_i - x_j|で距離を測り、 r番目に近いサンプルとの距離を h_iとします。

重み関数 W(\cdot)を用いて、 k=1,\dots,nについて

 \displaystyle w_k(x_i) = W(h_i^{-1}(x_k - x_i))

を計算します。

ここで重み関数 W(\cdot)は以下の性質を満たすものとします。

  1.  |x| \lt 1 について  W(x) > 0
  2.  W(-x)=W(x)
  3.  W(x) x \geq 0について 非増加関数
  4.  |x| \geq 1について W(x)=0

論文では  Wの例として tricube function が使われています(ちなみに、Wikipediaではもっと新しいやり方としてGaussian functionを使う方法が書いてありました:Local regression - Wikipedia

 \displaystyle W(x) = \begin{cases} (1-|x|^3)^3 & \text { for } \quad|x|<1 \\ 0 \quad & \text { for } \quad|x| \geqslant 1 \end{cases}

これはグラフを描くと次の図のようになる関数です。

 h_i^{-1}(x_k - x_i)は分子の x_k - x_iの絶対値が分母の h_iより大きければ |h_i^{-1}(x_k - x_i)| \geq 1になるので重みが0になります。つまり、サンプルとして回帰に使用されなくなります。なので重み関数は i番目のサンプルの近傍の r個のサンプルを取り出しつつ、 r個のサンプルにも距離に応じた重みをかけているわけです。

statsmodelsのlowessのパラメータ「frac(使用するサンプルの割合)」は、この rの計算に使われているものと思われます。

多項式回帰のフィッティング

非線形回帰として d次の多項式回帰を行います。

 \displaystyle \min_{\beta_0,\dots,\beta_d} ~ \sum_{k=1}^n w_k\left(x_i\right)\left(y_k-\beta_0-\beta_1 x_k-\ldots-\beta_d x_k^d\right)^2
 \displaystyle \hat{y}_i=\sum_{j=0}^d \hat{\beta}_j\left(x_i\right) x_i^j

ロバスト性重み \deltaの計算

続いて、外れ値の影響を除外するための重みを計算します。次のように定義される bisquare weight function  B(x)を用います。

 \displaystyle B(x) = \begin{cases} (1 - x^2)^2 & \text { for } \quad|x| < 1 \\ 0 \quad & \text { for } \quad|x| \geqslant 1 \end{cases}

残差 e_i = y_i - \hat{y}_iの絶対値 |e_i|の中央値を sとします。ロバスト性重み(robustness weights)を

 \displaystyle \delta_k = B(e_k / 6s)

と定義します。 B(x)もtricube functionと似た形状であり、残差の絶対値の中央値の6倍( 6s)以上の絶対値の残差 |e_k/6s| \geq 1を持つ外れ値は重み \delta_kがゼロになり、推定に含まれなくなるので推定が外れ値の影響を受けなくなります。

 \deltaを使って重み付け回帰を行う

 \delta_k w_k (x_i) を重みとして再び d多項式回帰を行い、新たな推定値 \hat{y}_iを得ます。

繰り返す

  • ロバスト性重み \deltaの計算
  •  \deltaを使って重み付け回帰を行う

のステップを t回繰り返します。

Pythonでの実装例

例として、次のデータを使います

# サンプルデータ
import numpy as np
n = 100
np.random.seed(0)
x = np.linspace(0, 7, n)
y = np.sin(x) + np.random.normal(0, 0.2, n)

図にするとこんな感じです。

重み関数を定義しておきます

def tricube(x: np.array) -> np.array:
    w = (1 - np.abs(x)**3)**3
    w[np.abs(x) >= 1] = 0
    return w

def bisquare(x: np.array) -> np.array:
    w = (1 - np.abs(x)**2)**2
    w[np.abs(x) >= 1] = 0
    return w

ハイパーパラメータを定義してLOWESSを推定します

# ハイパーパラメータの定義
frac = 0.66 # 使用するサンプルの割合
r = int(frac * n)  # 使用する近傍のサンプル数
d = 3  # 多項式回帰の次数
t = 3  # iteration

# 前処理:d次多項式を作るための特徴量生成
X = np.vstack([x**j for j in range(d)]).T

# LOWESSの推定
n = X.shape[0]
delta = np.ones_like(x)
y_pred = np.zeros_like(x)
for _ in range(t):
    for i in range(n):
        # 重みの計算
        dist = x - x[i]
        idx = np.argsort(np.abs(dist))[:r]
        h_i = np.abs(dist[idx]).max() # r番目に近いdiff
        w = tricube(dist / h_i)
        W = np.diag(delta * w)

        # WLS
        beta = np.linalg.inv(X.T @ W @ X) @ X.T @ W @ y
        y_pred[i] = X[i,:] @ beta

    e = y - y_pred
    s = np.median(np.abs(e))
    delta = bisquare(e / (6 * s))

推定した結果をplotすると次のようになります

japanize_matplotlibを使わないmatplotlibの日本語対応

背景

日本語フォントが入っていない環境(Docker など)において日本語の含まれるグラフを matplotlib で描くとき、japanize_matplotlib パッケージ が便利でした。

しかし、2020 年から更新が停止されており、Python3.12 以降では使用できません。

japanize_matplotlib がやってくれることは、同梱している日本語対応フォント(IPAex ゴシック)を matplotlib の使用フォントに設定することなので、同様の処理を自分で書けば日本語対応できます。

Python のベースイメージを使った Docker 環境(あるいは DebianLinux OS)での日本語対応の方法を調べたのでメモしておきます。

方法

フォントのダウンロード

例えば IPA ゴシックはfonts-ipafont-gothicでダウンロードできます。

FROM python:3.12

RUN apt update && \
    apt install -y fonts-ipafont-gothic

matplotlib で使用するフォントの指定

一時的な指定:import 後に rcParams を書き換え

インストールした日本語フォントを plt.rcParams["font.family"] に指定すれば matplotlib でそのフォントが使われるようになります。

import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "IPAPGothic"

この方法は手軽ですが、毎回上記の 1 行を書かないといけないのがやや面倒な場合があるかもしれません

永続的な指定: matplotlibrc ファイルを書き換える

matplotlibrcという設定ファイルを working directory などに置けばそれが読み込まれて適用されるようです(詳細は公式ドキュメント)。

matplotlibrcの全文は長いですが、変更したい箇所だけ書けばいいので、今回のケースでは

font.family: IPAPGothic

という一文だけを入れたテキストファイルを作れば大丈夫です

ファイル全体を含めた例も載せておきます。 以下の 3 つのファイルから成ります。

├── Dockerfile
├── main.py
└── matplotlibrc

まず Dockerfile は以下のようにします。 こちらではフォントは Noto Sans(源ノ角ゴシック)を使っています

FROM python:3.12
WORKDIR /workdir

# 日本語に対応しているフォントのダウンロード
RUN apt update && \
    apt install -y fonts-noto-cjk

# Python環境のセットアップ
RUN pip3 install matplotlib
COPY matplotlibrc .

# 今回の実験用のスクリプト
COPY main.py .
CMD ["python3", "main.py"]

main.pyは以下になります

import matplotlib.pyplot as plt

# 例
plt.title("てすと")
plt.xlabel("X軸")
plt.savefig("test.png")

matplotlibrcは以下のようにします。

font.family: Noto Sans CJK JP

これらを同一ディレクトリに配置し、docker を起動します

docker build . -t japanize
docker run -v .:/workdir japanize

test.pngを開くと、日本語が文字化けせず表示されていることが確認できます

緯度経度をXYZ方式のタイル座標に変換する

2024年4月より、国交省不動産情報ライブラリという素敵なツールとそのAPIを公開しています。これにより、不動産の価格がわかる取引データや、用途地域や災害危険区域や駅ごとの乗降客数など、不動産に関するデータに簡単にアクセスできるようになりました。

しかし、用途地域など地理情報系のAPIは入力するパラメーターにXYZ方式のタイル座標を指定する必要があり、「この座標、どうやって求めればいいんだ?」と思った方も多いのではないでしょうか(私は思いました)

本記事では、緯度経度の座標をタイル座標に変換する方法を共有いたします。

XYZ方式とは

地図タイル

GoogleマップのようなWeb上の地図は、メルカトル図法の地図の一部を切り取ったタイルの集まりで表現し、必要なタイルだけを都度読み込んで高速化しています。

タイルはズームレベル(z)と横方向の番号(x)、縦方向の番号(y)によって座標が表現されます。例えば国土地理院の標準地図だと

https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png

の形式でURLが構成されています。

例えば座標がz=6, x=57, y=23だと、 https://cyberjapandata.gsi.go.jp/xyz/std/6/57/23.png となるわけです。

なお、他の地図タイルも同様のURLの構造になっておりまして、OpenStreetMap(オープンデータの地図)だと https://tile.openstreetmap.org/6/57/23.png となります。

ちなみに国土地理院のタイルの確認には国土地理院のこちらのページが便利です。

maps.gsi.go.jp

座標の仕組み

この座標がどうやって決まるかというと、メルカトル図法の地図全体を1つの正方形に収めた状態をzoom level = 0として、縦横半分に切って4つに分割した状態をzoom level = 1とし、同様に4分割するたびにzoom levelが上がっていくものとみなして座標を割り振っています。

このあたりは国土地理院のこのページがわかりやすく参考になります。

maps.gsi.go.jp

座標の変換方法

さて、本題です。

緯度経度からXYZ座標への変換については、実はOpenStreetMapWiki

wiki.openstreetmap.org

に計算式が紹介されています。

この数式をPythonで再現すると以下のようになります。

from math import radians, cos, tan, log, pi, floor

def latlon_to_tile(lat, lon, zoom):
    n = 2 ** zoom
    lat_rad = radians(lat)
    x = floor(n * ((lon + 180) / 360))
    y = floor(n * (1 - (log(tan(lat_rad) + 1 / cos(lat_rad)) / pi)) / 2)
    return x, y

六本木駅の座標でズームレベル15のタイルを見てみます。

z = 15
lat, lon = 35.6638271, 139.7316455 # 六本木駅
x, y = latlon_to_tile(lat, lon, zoom=z)
print(f"https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png")

このコードの出力は https://cyberjapandata.gsi.go.jp/xyz/std/15/29102/12905.png になります。ちゃんと駅周辺が写っています。

AWS Amplifyのログイン画面の「Email」「Password」などを非表示にしてGoogle認証だけにする

課題

AmplifyのAuthenticatorコンポーネント

ui.docs.amplify.aws

を使うと、EmailやPasswordなどの項目がデフォルトで表示されている。

私の調べた限りでは、現状これらを非表示にするオプションはない。Amplify Gen2だとdefineAuthGoogle認証などのOAuthだけにすることはできない(「At least one of email or phone must be enabled.」エラーが発生する。参考

しかし、GoogleなどOAuthでのログインだけを許可したい場合でも残ってしまうのは面倒。せめて非表示にして使えなくしたい。

そこで、以下のように「Sign In with Google」だけを残してほかを非表示にする方法をメモしておく。

非表示にする手順

Create Accountを消す

<Authenticator>hideSignUpをつけるとCreate Accountなどのタブを消すことができる

- <Authenticator socialProviders={["google"]}>
+ <Authenticator socialProviders={["google"]} hideSignUp>
...
</Authenticator>

CSSを追加する

次に、以下のCSSを追加する

[data-amplify-form] > :not(.federated-sign-in-container),
.federated-sign-in-container > hr {
  display: none;
}
.federated-sign-in-container {
  padding: 0 !important;
}

こう書いた意図を下に述べていく

残った<Authenticator>オブジェクトの構造を見てみると、一番外側の<form> タグはdata-amplify-form属性がついている

Google認証などがある部分はfederated-sign-in-containerのclassがついた<div>。「Sign In with Google」ボタンの下にある区切り線の<hr>タグもこの<div>の中にある。

さきほどのCSSの一行目の

[data-amplify-form] > :not(.federated-sign-in-container)

は、data-amplify-form属性の要素の子要素のうち、federated-sign-in-containerのclass以外を対象に指定している。仮に

[data-amplify-form] > :not(.federated-sign-in-container) { 
  display: none;
}

だけを書くと、以下のようになる

区切り線(<hr>)が残ってるので消すため、

.federated-sign-in-container > hr 

も指定してdisplay: noneすると

となり、「Sign In with Google」ボタンだけにできる。

federated-sign-in-containerのpaddingが残っていてバランスが悪いので、上のcssではpadding: 0を指定している。

非表示にする方法まとめ

やるべきことまとめ。

(1) <Authenticator>hideSignUpをつける

- <Authenticator socialProviders={["google"]}>
+ <Authenticator socialProviders={["google"]} hideSignUp>

(2) 以下のCSSを追加する

[data-amplify-form] > :not(.federated-sign-in-container),
.federated-sign-in-container > hr {
  display: none;
}
.federated-sign-in-container {
  padding: 0 !important;
}

AWS SAMの基本の使い方メモ:APIキー認証のAPIを例に

Serverless Frameworkの有料化に伴い、AWS SAMを使う機会が増えたためメモする。

前提知識

環境構築

公式ドキュメントのInstallationに従い、AWS CLIとSAM CLIを入れておく

AWS Serverless Application Model (AWS SAM) Documentation

入力補完される環境を作るのが良さげ

VS Codeを使う場合、CloudFormationの入力補完をするExtensionを入れると楽。

例えば、AWS CloudFormation Snippets - Visual Studio Marketplace

コマンド

# 新規プロジェクト作成
sam init

# ビルドとデプロイ
sam build && sam deploy

# 作ったリソースの削除
sam delete

samconfig.toml

confirm_changeset = false にしておくとデプロイ時にいちいち確認されずに済む

[default.deploy.parameters]
confirm_changeset = false

API-KEY認証つきのREST APIを作る例

ポイントは、AWS::Serverless::ApiAWS::Serverless::Function に加えて

  • AWS::ApiGateway::ApiKey
  • AWS::ApiGateway::UsagePlan
  • AWS::ApiGateway::UsagePlanKey

も必要になるということ。

正直、長くて覚えていられないのでここにメモしておく。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
  # Api Gatewayの設定
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      Auth:
        ApiKeyRequired: true # API Key認証の設定
      EndpointConfiguration:
        Type: REGIONAL # EndpointTypeを指定したい場合(任意)

  # API Keyの設定
  MyApiKey:
    Type: AWS::ApiGateway::ApiKey
    DependsOn: # API Gatewayの特定のStageが作成されてからApi Keyを作るようにする(エラー回避のため)
      - MyApiProdStage
    Properties:
      Enabled: true
      StageKeys:
        - RestApiId: !Ref MyApi
          StageName: Prod

  # Usage Planの設定。API keyでアクセスを許可するために必要
  MyApiUsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn:
      - MyApiKey
    Properties:
      ApiStages:
        - ApiId: !Ref MyApi
          Stage: Prod

  MyApiUsagePlanKey: # UsagePlanKeyはAPI keyとUsagePlanを紐づけるために必要
    Type: AWS::ApiGateway::UsagePlanKey
    DependsOn:
      - MyApiUsagePlan
    Properties:
      KeyId: !Ref MyApiKey
      KeyType: API_KEY
      UsagePlanId: !Ref MyApiUsagePlan

  # APIで呼び出すLambda Functionの設定
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-MyFunction  # 任意。ランダムな文字列になることを避けたいなら明示する
      PackageType: Image
      Architectures:
        - x86_64
      Events:
        # Lambdaをトリガーするイベント(EventBridge, API Gatewayなど)
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref MyApi
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./src
      DockerTag: python3.12-v1

  # (任意)ログの設定。ログもSAMで管理したいなら設定が必要
  MyFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${MyFunction}
      RetentionInDays: 14

Outputs:
  MyAPi:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"

TIPS

LogGroupの指定

上記のtemplate.ymlの例にはLogGroupの設定も含めてある。AWS::Logs::LogGroup についての記述はなくても動くが、LogGroupの設定もしっかり行うことが望ましい。

LogGroupについて指定しないとデプロイ時に自動で生成されるが、SAMのStackの中にLogGroupが含まれないので、あとで SAMのStackを削除したあともLogGroupだけ残り続けてしまう参考)。

また、RetentionInDays(ログの保持期間)も指定しないとデフォルトだと一生ログをため続けて、 使わないログのために費用を支払うことになるので、そういう意味でもLogGroupは指定して、ついでにRetentionInDaysも指定したほうがいい

FunctionNameの指定

FunctionNameは省略可能なのですが、デフォルトだと

[Stack名]-[Functionのセクションに使った名前]-[ランダムに生成された文字列]

という名前になります。そのままでも問題ないというか、むしろランダム生成した文字列は名前被りを防いでくれて合理的ではあるものの、人間にとっての扱いやすさを考えると

FunctionName: !Sub ${AWS::StackName}-MyFunction

など明示的に指定してあげたほうがよい気がしています。

詰まりやすいポイント

リソース作成順序の管理

上記のymlにも書いたが、「このリソースを作るにはこのリソースが存在することが前提」のような依存関係があることもあり、AWS側でうまく管理してくれるわけではない様子

「〇〇 not exists」みたいなエラーが出てデプロイに失敗したらこの問題が起きているので、Logical ID(下記の例だとMyApiProdStage)を推測してDependsOnに書いてやる必要がある

# 例(再掲、上述の例から抜粋)
(前略)
Resources:
  # Api Gatewayの設定
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
 (中略)
  # API Keyの設定
  MyApiKey:
    Type: AWS::ApiGateway::ApiKey
    DependsOn: # API Gatewayの特定のStageが作成されてからApi Keyを作るようにする(エラー回避のため)
      - MyApiProdStage
(後略)

ROLLBACK_COMPLETE state でデプロイできない

時折、以下のようなエラーが出ることがある

Error: Failed to create changeset for the stack: (あなたのAPI名), An error occurred (ValidationError) when calling the CreateChangeSet operation: Stack:(あなたの関数のStack ID) is in ROLLBACK_COMPLETE state and can not be updated.

初回のデプロイに失敗した場合、それ以降更新できなくなるらしい。こうなるとStackを削除する必要がある。

方法は2つ

  1. AWSマネジメントコンソールからCloudFormationのページに移動して手動でStackを削除する
  2. sam delete コマンドを打つ

※Stackを削除すると、その後作られるAPIのURLも変わるので注意

React Leafletで凡例を表示する

leafletの実装について少しメモ。

背景

PythonやRのleafletパッケージだと簡単に凡例をつけられた覚えがあるが、Reactで実装しているとそういう関数は見当たらなかった。

自分で実装する方法を探していたところ

How to add a legend to the map using react leaflet? - CodeSandbox

など、いくつか解説サイトを見つけた。

それらのサイトでは、L.DomUtil.createで凡例のdivを作ってL.control().addTo(map) で地図に紐づける実装だったが、バージョンが現行とはだいぶ違うためか再現できなかった。

なので自分で実装する方法を探した。

実装

コードを一部抜粋するとこんな感じ

const Legend = () => {
  // 自作の凡例のdiv
  return (
    <div
      style={{ backgroundColor: "rgb(255, 255, 255, 0.8)", margin: "10px 10px 25px", padding: "10px" }}
      className="leaflet-bottom leaflet-right"
    >
      {/* LegendContent には凡例に書く内容が入っている想定 */}
      <LegendContent />
    </div>
  )
}

function App() {
  const position: LatLngExpression = [51.505, -0.09]

  return (
    <MapContainer center={position} zoom={13} scrollWheelZoom={false} style={{ height: "300px", width: "600px" }}>
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      {/* 自作のLegendを MapContainer 内に置く */}
      <Legend />
    </MapContainer>
  )
}

export default App

実装の考え方は以下の通り:

  • React Leafletでは、<MapContainer></MapContainer> の間に要素を入れれば地図に追加される。
    • つまり、addTo(map)を書く必要はない。
    • 自作のLegendの要素を作って<MapContainer></MapContainer>に入れればいい
  • 自作のLegendの要素はReactの普通のコンポーネント作成方法に則り、JSX.Elementなどで作ればいい
  • その際に className="leaflet-bottom leaflet-right" などをつけると、自分でcssを書かずとも凡例の位置を指定できて楽

コード全文

github.com

環境

  • react: 18.3.1
  • react-leaflet: 4.2.1
  • leaflet: 1.9.4