盆暗の学習記録

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

S3 Files + Lambdaの構成をSAMで作る

最近リリースされたAWS S3 Filesを触ってみました。AWS Serverless Application Model (AWS SAM) で試していたのですが、思ったより詰まるポイントが多かったのでコードを共有しておきます。

S3 Files とは

S3 Files は S3 バケットをファイルシステムとして EC2・Lambda・EKS・ECS にマウントして使えるようにするものです。

通常の S3 操作はSDK 経由(Pythonだと boto3.client('s3').get_object(...) など)で行いますが、S3 Files を使うと open('data.csv') のような通常のファイル操作で S3 のデータにアクセスできます。SDKの実装部分が不要になって開発が楽になるというわけです。

aws.amazon.com

SAMで作る

S3 Filesを使うには

  • LambdaをマウントするVPC
  • TCP 2049で通信するセキュリティグループ
  • ファイルシステムからバケットにアクセスするためのIAMロール

など、色々と設定する必要があります。

docs.aws.amazon.com

またSAM特有?の問題として、1つのCloudFormation Stackでまとめてデプロイしようとしたところ、AWS::S3Files::MountTarget が作成完了したもの利用可能なステータスになる前にLambdaのリソースを作り始めてしまってエラーになりました。(エラーメッセージ:"mount targets created in all availability zones the function will execute in, but not all are in the available life cycle state yet. Please wait for them to become available and try the request again.")(現時点で最新のSAM CLI ver.1.158.0の問題です)

そこで

  1. S3 FilesやVPCなどの作成用のStack
  2. LambdaのStack

に分けて、時間を空けて作ることでうまくデプロイできるようになりました。

S3 Filesなどを作るStackのtemplate.yaml

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

Resources:
  # S3 Files を利用するための VPC とサブネットを定義
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true

  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [0, !GetAZs ""]

  # TCP 2049で通信するセキュリティグループ(Lambda用とS3Files MountTarget用)
  LambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for Lambda
      VpcId: !Ref VPC
 
  MountTargetSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for S3 Files mount target
      VpcId: !Ref VPC

  LambdaToMountTargetEgress:
    Type: AWS::EC2::SecurityGroupEgress
    Properties:
      GroupId: !GetAtt LambdaSecurityGroup.GroupId
      Description: Allow NFS to S3 Files mount target
      IpProtocol: tcp
      FromPort: 2049
      ToPort: 2049
      DestinationSecurityGroupId: !GetAtt MountTargetSecurityGroup.GroupId

  MountTargetFromLambdaIngress:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !GetAtt MountTargetSecurityGroup.GroupId
      Description: Allow NFS from Lambda
      IpProtocol: tcp
      FromPort: 2049
      ToPort: 2049
      SourceSecurityGroupId: !GetAtt LambdaSecurityGroup.GroupId


  # ファイルシステムからバケットにアクセスするためのIAMロール
  # 参考: https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-prereq-policies.html#s3-files-prereq-iam
  S3AccessRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowS3FilesAssumeRole
            Effect: Allow
            Principal:
              Service: elasticfilesystem.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub "arn:aws:s3files:${AWS::Region}:${AWS::AccountId}:file-system/*"
      Policies:
        - PolicyName: S3AccessPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: S3BucketPermissions
                Effect: Allow
                Action:
                  - s3:ListBucket
                  - s3:ListBucketVersions
                Resource: !Sub "arn:aws:s3:::${S3Bucket}"
                Condition:
                  StringEquals:
                    aws:ResourceAccount: !Ref AWS::AccountId

              - Sid: S3ObjectPermissions
                Effect: Allow
                Action:
                  - s3:AbortMultipartUpload
                  - s3:DeleteObject*
                  - s3:GetObject*
                  - s3:List*
                  - s3:PutObject*
                Resource: !Sub "arn:aws:s3:::${S3Bucket}/*"
                Condition:
                  StringEquals:
                    aws:ResourceAccount: !Ref AWS::AccountId

              - Sid: EventBridgeManage
                Effect: Allow
                Action:
                  - events:DeleteRule
                  - events:DisableRule
                  - events:EnableRule
                  - events:PutRule
                  - events:PutTargets
                  - events:RemoveTargets
                Resource: "arn:aws:events:*:*:rule/DO-NOT-DELETE-S3-Files*"
                Condition:
                  StringEquals:
                    events:ManagedBy: elasticfilesystem.amazonaws.com

              - Sid: EventBridgeRead
                Effect: Allow
                Action:
                  - events:DescribeRule
                  - events:ListRuleNamesByTarget
                  - events:ListRules
                  - events:ListTargetsByRule
                Resource: "arn:aws:events:*:*:rule/*"

  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${AWS::AccountId}-s3-files-example"
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

  S3Files:
    Type: AWS::S3Files::FileSystem
    Properties:
      Bucket: !GetAtt S3Bucket.Arn
      RoleArn: !GetAtt S3AccessRole.Arn
      SynchronizationConfiguration:
        ExpirationDataRules:
          # キャッシュされたデータが期限切れになるまでの日数
          - DaysAfterLastAccess: 1
        ImportDataRules:
          - Prefix: ""
            # SizeLessThan: 特定のサイズ未満のファイルをキャッシュ対象にする
            SizeLessThan: 1073741824 # 1GB
            Trigger: ON_DIRECTORY_FIRST_ACCESS

  S3FilesAccessPoint:
    Type: AWS::S3Files::AccessPoint
    Properties:
      FileSystemId: !GetAtt S3Files.FileSystemArn
      PosixUser:
        Uid: "1000"
        Gid: "1000"
      RootDirectory:
        Path: /accesspoint # /にするとPermission deniedで書き込めなかったため指定(S3FilesがマウントされたLambda内で/mnt/s3filesに保存するとS3上の/accesspointディレクトリに保存される)
        CreationPermissions:
          OwnerUid: "1000"
          OwnerGid: "1000"
          Permissions: "0755"

  S3FilesMountTarget:
    Type: AWS::S3Files::MountTarget
    Properties:
      FileSystemId: !Ref S3Files
      SubnetId: !Ref PrivateSubnet
      SecurityGroups:
        - !GetAtt MountTargetSecurityGroup.GroupId

  # Lambdaデプロイ用のStackで参照しやすくするためParameter storeに保存
  PrivateSubnetIdParam:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /s3-files/PrivateSubnetId
      Type: String
      Value: !Ref PrivateSubnet

  S3FilesAccessPointArnParam:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /s3-files/S3FilesAccessPointArn
      Type: String
      Value: !GetAtt S3FilesAccessPoint.AccessPointArn

  LambdaSecurityGroupIdParam:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /s3-files/LambdaSecurityGroupId
      Type: String
      Value: !GetAtt LambdaSecurityGroup.GroupId

Lambdaを作るStackのtemplate.yaml

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

Resources:
  LambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: LambdaFunction
      CodeUri: src/
      Handler: app.handler
      Runtime: python3.12
      Architectures:
        - x86_64
      Timeout: 60
      MemorySize: 1024
      Environment:
        Variables:
          S3_BUCKET_NAME: !Sub "${AWS::AccountId}-s3-files-example"
      Role: !GetAtt S3FilesRoleForFunction.Arn
      VpcConfig:
        SubnetIds:
          - "{{resolve:ssm:/s3-files/PrivateSubnetId}}"
        SecurityGroupIds:
          - "{{resolve:ssm:/s3-files/LambdaSecurityGroupId}}"
      FileSystemConfigs:
        - Arn: "{{resolve:ssm:/s3-files/S3FilesAccessPointArn}}"
          LocalMountPath: /mnt/s3files

  LambdaFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${LambdaFunction}"
      RetentionInDays: 14

  # S3 Files を Lambda から利用するための IAM ロールを定義
  # ref: https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-prereq-policies.html#s3-files-prereq-iam
  S3FilesRoleForFunction:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: S3FilesClientPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: S3FilesMount
                Effect: Allow
                Action:
                  - s3files:ClientMount
                  - s3files:ClientWrite
                Resource: "{{resolve:ssm:/s3-files/S3FilesAccessPointArn}}"
              - Sid: S3ReadWriteAccess
                Effect: Allow
                Action:
                  - s3:Get*
                  - s3:Put*
                Resource: !Sub "arn:aws:s3:::${AWS::AccountId}-s3-files-example/*"
              - Sid: S3BucketListAccess
                Effect: Allow
                Action:
                  - s3:ListBucket
                Resource: !Sub "arn:aws:s3:::${AWS::AccountId}-s3-files-example"

電子書籍の自作のためのアプリを作った

先日、国立国会図書館が文書のスキャン画像に特化したOCR「NDLOCR-Lite」を公開されていました。

GPUなしでも高速・高精度に動作し、ローカル環境だけで書籍のOCRが賄える…。 しかも、ちょっと触った感じだとTesseract OCRより精度がよさげ…。

「これは電子書籍の自炊にぴったりだな」と思い、自分の目的に合うようにGUI部分だけ作ってみることにしました。

(作ったというにはおこがましいかもしれません。大部分をClaude Codeに書かせました。)

作ったもの

github.com

スキャンした文書の画像をもとに、電子書籍として扱える(テキストが検索やコピー可能で、目次がついた)PDFファイルを生成するツールです。

機能・使用方法

使い方は次のようになります。

まず画像を選択します。

画像の編集

図表や文字をくっきりさせたい場合がありますので、画像の

  • 輝度変換(コントラスト調整など)
  • リサイズ
  • 圧縮(JPEGの品質の指定)

などもできるようにしています。

「仕上がり確認」で処理前後の比較が確認できます。出来上がるファイルの概算のファイルサイズも確認できます。

目次の作成

作成後のpdfファイルをpdfビューアやKindleなどで読む時、目次をクリックしてその箇所に飛べると便利です。

そのような目次情報をpdfに埋め込むための機能をつけました。

目次っぽいページを自動判定(+人力で補正)し、それらの読み取り結果をもとに「目次に追加」で追加できます。

ファイル名の指定

「書籍名.pdf のようなファイル名にするだろう」という仮定のもと、一応読み取り結果から書籍名の自動推定もしています。しかし表紙はレイアウトが多様なので現状は結構外します。

とはいえ表紙ページ(1枚目の画像)のOCR結果をすぐ見られるようにしているので簡単に補正できます。

アプリ作成時の使用技術

Pythonで書かれたNDLOCR-LiteのコードをOCR処理の部分に組み込みたかったのもあり、全部Pythonで書きました。

GUIアプリの作成にはPySide6を使っております。今回初めて使いましたが様々なデザインのコンポーネントがあるようで使い勝手よさそうです。

また、開発序盤~中盤は Pencil.dev を使ってUIを作っていました。Claude CodeにUIを.pen形式で作らせる→手作業で微調整→「.penファイルをもとにUIをPySide6で作れ」と指示、という感じでやっていました。これはイマイチだったので結局途中でやめました。PySide6はWebのUIとは違う技術なのでうまく従いにくいのか、微妙に違うものが出てくるんですよね。

Web開発だったらPencil.devはうまくいったかも。また今度別の個人開発プロジェクト等で使ってみたいです。

CRXJSを使ってブラウザ拡張機能の開発環境を簡単に構築する

hot-reload(ソースコードの変更が開発環境に自動で反映される)が可能なブラウザ拡張機能の開発環境を作るとき、CRXJSというのが非常に便利だったのでメモ。

crxjs.dev

使い方

(※CRXJS v2.3.0時点の情報になります)

https://crxjs.dev/guide/installation/create-crxjs に記載の通りなのですが、非常に簡単に作れます。

1. プロジェクトの新規作成

npx create-crxjs

で対話的なCUIが出てきて

を行うと、CRXJSの設定済みの Vite 環境が作られます。

2. プロジェクトのディレクトリへ移動

cd [project-name]

3. パッケージのインストール

npm install

4. 開発用サーバーの立ち上げ

npm run dev

5. 開発中の拡張機能の確認

例としてChromeの場合、拡張機能のページ chrome://extensions/ に行き、デベロッパーモードをONにして「パッケージ化されていない拡張機能を読み込む」を押して、プロジェクトのディレクトリのdist/ディレクトリを参照させて読み込むことができます。

読み込んだ拡張機能のアイコンをクリックするとポップアップが出ることが確認でき、src/popup/内のファイルを書き換えると即座に反映されることが確認できます。

6. ビルド

またビルドするとzipファイルが出力され、そのままChromeウェブストアに登録できるようになっています

npm run build

LintやFormat、型チェックを自動実行する方法まとめ

lintや型チェックなどの静的解析はバグを未然に防ぐのに役立ちますし、formatterの実行は規約に反するコードを自動で修正してきれいなコードベースを保つうえで便利です。

しかし、コードを変更するたびに手動でコマンドを実行するのは面倒なので、自動で実行したいです。

LintやFormatを自動化する方法をいくつかまとめます(主にPython向けですが、他の言語でも考え方は同じです)

VS Codeで実行する

拡張機能の導入

VS Code を使っている場合、拡張機能で Lint や Format、型チェックの機能を簡単に導入できます。

例えば

などを導入するとファイルの編集中に自動的に静的解析が実行され、Lint や型チェックが機能します。

なお Pylance はデフォルトで型チェックが off になっているため VS Code の Settingsにおける python.analysis.typeCheckingMode の項目で base 以上の設定に変更する必要があります。

Formatter の実行

手動実行する場合は右クリックのメニューから「Format Document」をクリックします。

自動実行させたい場合、VS Code の Settings に行き、editor.formatOnSave という項目にチェックを入れます。こうするとファイルの保存時に Formatter が実行されるようになります。

workspace の setting として設定した場合、.vscode/settings.json が作成されて保存されます。逆にこのjsonを書き換えることでSettingsに反映させたりできます。

// .vscode/settings.json
{
    // 型チェック
    "python.analysis.typeCheckingMode": "standard",
    // Linting
    "editor.formatOnSave": true,
    "ruff.lineLength": 120
}

commit 時に Git hook で実行する

Git hook は gitのcommitやpushなどを実行する前後に別の処理を実行させる設定のことです。

commitの前に指定した処理(LintやFormat)を実行させたい場合、pre-commit フレームワークを使うことで簡単にgit hookを設定できます。

1. pre-commitのインストール

pre-commitはPyPIで公開されており、pipやuvでインストールできます

# pipの場合の例
pip install pre-commit
# uvの場合の例
uv add pre-commit --dev

2. 設定ファイルの作成

.pre-commit-config.yaml を作成し、そこに実行したい処理の設定を入れます。githubリポジトリを紐づける形になります。

例えば ruff でLintとFormatを、 pyright で型チェックをする場合は次のようにします。

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.13.0
  hooks:
  - id: ruff-format
  - id: ruff-check
    args: [--fix, --exit-non-zero-on-fix]

- repo: https://github.com/northisup/pyright-pretty
  rev: v0.1.0
  hooks:
  - id: pyright-pretty

これでcommitの実行時にチェックが走るようになります。パスしなければcommitできなくなるので、ある程度厳密に静的解析を運用したいときに向きます。

$ git commit
ruff format..............................................................Passed
ruff check...............................................................Passed
Python type checker [pyright]............................................Passed

Github Actions で実行する

静的解析をGithub Actionsで実行する

静的解析のパッケージをGithub Actions環境にインストールして実行することでpush時に実行できます

name: Lint & Typecheck (Python)

on:
  pull_request:
  push:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -U pip
      - run: pip install ruff pyright
      - run: ruff check
      - run: pyright .

pre-commitをGithub Actionsで実行する

また、pre-commitをGithub Actionsで実行するという方法もあります。

パッケージをpipで管理している場合は pre-commit/action を使うと楽かもしれません。

uvを使っている場合は astral-sh/setup-uv のあとに uv run pre-commit run を実行すればよいです。

name: pre-commit

on:
  pull_request:
  push:
    branches: [main]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: astral-sh/setup-uv@v6
    # devDependencies環境のもとでpre-commitを実行
    - run: uv run --group dev pre-commit run --show-diff-on-failure --all-files

Overleafをローカル環境で起動する

OverleafWebブラウザ上でLaTeXの文書を作成・共同編集できるとても便利なLaTeXエディタです。

OverleafはWebサービスとして提供されていますが、オープンソースのエディタであるため自分のPC上で立ち上げることもできます。

ローカルで起動する場合は共同編集はできませんが、自分ひとりで簡易的に使いたい場合は十分な機能を持っています。

環境構築手順

0. 前提

環境構築には次のものが必要です

  • git
  • docker

1. リポジトリをcloneします

overleaf/toolkit をcloneします。

git clone https://github.com/overleaf/toolkit.git ./overleaf-toolkit

cd ./overleaf-toolkit

2. bin/initを実行し、configファイルを作ります

bin/init

3. bin/up を実行するとdocker composeが動いてOverleafが起動します

bin/up

http://localhost からアクセスできるようになります。

使ってみる

アクセスしたらまずログインフォームが出てくるので、まずアカウントを作る必要があります。

初回は http://localhost/launchpad でAdminユーザーの作成を行います。

ローカルに保存されるだけの情報なのでテキトーに hoge@example.compassword とかで大丈夫だと思います。

あとはログインして普通のOverleafと同様にProjectを作って使えばOKです。

日本語対応する

Webサービスとして提供されているOverleafとはバックエンドで動いているLaTeXの環境が若干異なるようです。

とくに日本語はそのままだと全く使えないのでフォントなどを入れてあげる必要があります。

一時的な対応

手っ取り早い対応としては、起動中のoverleafのDockerコンテナに入って日本語文書用のファイルをダウンロードする方法です。

# Dockerコンテナに入る
bin/shell
# docker exec -it sharelatex bash でも可

# パッケージマネージャを更新
tlmgr update --self

# LuaLaTeX系の日本語パッケージ等をインストール
tlmgr install \
  collection-luatex \
  collection-langjapanese \
  xkeyval

Recompileすると、すぐ反映されてエラーなく日本語の文書が作れるようになっています。

なお上記はコンパイラをLuaLaTeXにしていることを前提としているので、Overleafの左上の「Menu」ボタンから設定を開いてコンパイラをLuaLaTeXに変更する必要があります。

恒久的な対応

方法1. 自分でDockerfileを書く(おすすめ)

./config/docker-compose.override.yml というファイルを作って変更したい要素を書けばコンテナ環境を変更できます。

まず、 ./config/ にDockerfileと docker-compose.override.ymlを作成します。

./config
├── Dockerfile
├── docker-compose.override.yml
...

./config/Dockerfile は公式のイメージに日本語パッケージを追加するように書いておきます

FROM sharelatex/sharelatex:latest

# コンテナのパッケージマネージャを更新
RUN tlmgr update --self

# LuaLaTeX系の日本語パッケージ等をインストール
RUN tlmgr install \
    collection-luatex \
    collection-langjapanese \
    xkeyval

./config/docker-compose.override.yml には作成したDockerfileを使ってビルドするように書いておきます。

services:
  sharelatex:
    build:
      context: ..
      dockerfile: config/Dockerfile
    # ビルドしたイメージに名前をつける(必須)
    image: local/my-sharelatex:latest

image:の部分に書くイメージ名は適当で大丈夫そうですが、省略してしまうと bin/up したときにoverrideがうまく働かなかったので項目自体は必須のようです。

これで立ち上げると日本語対応できているはずです。

方法2. DockerHub上の日本語対応イメージを指定する

もっと楽な、自分でDockerfileを書かなくていい方法としては、日本語対応済みのOverleafのDockerImageをDockerHubから拾ってくる方法です。

config/overleaf.rcOVERLEAF_IMAGE_NAMEという変数を書き換えることで参照先を変更できます。 (デフォルトは公式のsharelatex/sharelatex です)

例えば tuetenk0pp/sharelatex-full公式イメージ sharelatex/sharelatex に TeX Liveのfullを足し合わせたイメージとなっている ので、これを参照すれば日本語対応できます(なおTeX Liveのfullはファイルサイズが大きく、上記Imageは約14GBあるのでご注意を)。

# Uncomment the OVERLEAF_IMAGE_NAME variable to use a user-defined image.
OVERLEAF_IMAGE_NAME=tuetenk0pp/sharelatex-full

⚠️ただし、こうした非公式Docker Imageは性善説が前提となる(セキュリティリスクがある)のと、数年後には更新されなくなっていて代替を探す必要が出てきたりすることもよくありますので注意が必要です。

参考

相関の希薄化とバイシリアル相関係数

相関の希薄化という現象と、それに対処する方法の一つである「バイシリアル相関係数」の構造についてメモ

相関の希薄化

相関の希薄化(attenuation of correlation) とは、データの測定誤差によって2つの確率変数 X,  Yの間の積率相関係数がゼロに近づく方向のバイアスをもつ現象です。

Spearman (1904) によって提案された希薄化を修正する式は次のようになっています。

 \rho_{xy} = \frac{r_{xy}}{\sqrt{r_{xx}} \sqrt{r_{yy}}}
  •  r_{xy}:観測された相関
  •  \rho_{xy}:真の相関、あるいは信頼性が完璧なもとでの相関
  •  r_{xx}:変数 Xの信頼性(測定誤差の少なさ)
  •  r_{yy}:変数 Yの信頼性(測定誤差の少なさ)

この式を変形すると

 r_{xy} = \rho_{xy} \sqrt{r_{xx}} \sqrt{r_{yy}}

となり、観測される相関係数は真の相関係数に対して、測定の信頼性の影響を受けている構造であることがわかります。

この信頼性をどう得るのかについては、例えば古典的テスト理論においては再テスト法(例えば  xを再び測定したときの相関 =  r_{xx})や信頼性係数といった方法についての議論が広がっていったようです。しかし本記事ではカテゴリカル変数の相関係数に焦点を当てたいと思います。

カテゴリカル変数の相関の希薄化

相関を測りたい確率変数にカテゴリカル変数(質的変数)が含まれており、かつそれが「もともと連続変数だったものが離散化された形で観測された」と考えられるものだった場合、本来よりも解像度が粗い測定をしているので信頼性が低い(測定誤差が大きい)ということで相関の希薄化の問題が生じます。

「もともと連続変数だったものが離散化された形で観測された」というのは、例えば次のようなデータです:

  • 例1(満足度のアンケート):回答者の心の中にある「満足度」が質問紙の「満足」「やや満足」「どちらでもない」などの離散的な選択肢によって離散化された形で観測されている
  • 例2(英語の試験の設問):ざっくり言えば、回答者の「英語力」が高いなら設問への回答が「正解」を選ぶ傾向が高くなり逆も然りとなる → 正解・不正解の二値へ離散化された形で英語力が観測されていると考えられる

二値化による相関の希薄化

連続変数を離散化したときにどのくらい相関が希薄化されるかは、もともとの連続変数が従う分布や離散化の閾値の取り方などによって異なります。

例えば相関係数 \rhoの2変量正規分布に従う連続変数 X,  Yのうち片方の変数、例えば Yを標準化して、分布の中央の点(平均値 = 中央値)を閾値に2等分するように二値化して Y_dとおくことにします。この場合、観測される積率相関係数  r = \operatorname{Cov}(X, Y_d) と元々の相関係数 \rho

 r = 0.798 \rho

という関係にあることが報告されています(Peters & Van Voorhis, 1940)。

実際に2変量正規分布からの疑似乱数を生成させて二値化して実験してみてもそのような結果になります。

import numpy as np
from scipy.stats import pearsonr

# 正規分布からのサンプリング
rho = 0.5 # 真の相関係数
np.random.seed(0)
data = np.random.multivariate_normal(mean=[1, 5], cov=[[1, rho], [rho, 1]], size=100_000)
x, y = data[:, 0], data[:, 1]

# 平均値を閾値に二値化
yd = 1 * (y >= y.mean())
r = pearsonr(x, yd)[0]  # 二値化後の相関係数
print(f"真の相関={rho:.3f}, 二値化後の相関係数={r:.3f}, 比率={r/rho:.3f}")
真の相関=0.500, 二値化後の相関係数=0.399, 比率=0.798

真の相関係数の条件を変えて( -0.5, 0.25, 0.75 の3条件で)、また疑似乱数の生成回数を増やしてモンテカルロシミュレーションしてみます(1回のサンプル数=10,000、試行回数=10,000)。

いずれの場合も、二値化後のピアソンの積率相関係数 r は真の相関 \rho よりゼロに近い値へと希薄化されており、二値化後の相関係数と真の相関の比率は0.798となっています。

この0.798という値は閾値の位置によって異なりますが、一般化すると

 e(p) = \frac{\phi(z)}{\sqrt{p(1 - p)}}

という関数であることが知られています(Cohen, 1983)。

イシリアル相関係数

相関の希薄化の問題に対処している相関係数のひとつに イシリアル(biserial)相関係数 というものがあります。これは前節の「正規分布に従う連続変数を二値化したカテゴリカル変数と連続値の間の相関」を測る相関係数で、

 r_{\text{bi}} := r_{\text{Pearson}} \times \frac{\sqrt{p(1 - p)}}{\phi(z)}

という相関係数になります。

これはちょうどピアソンの積率相関係数 r_{\text{Pearson}}を前述の希薄化の影響 e(p)の逆数で補正をかけた構造になっています。

私がバイシリアル相関係数の式を初めて見たときはなぜこういう式にしているのかがさっぱりわかりませんでしたが、相関の希薄化のことを知っていれば「既知の量のバイアスが入るから補正しよう」というかなり素直な式をしていることがわかります。

二値ではなく多値の場合は…?

二値化ではなく、任意のカテゴリ数への離散化の場合はどうすればいいのでしょうか。 その場合に向けて一般化した相関係数も存在します。

  • 連続変数 と カテゴリカル変数 の相関 → ポリシリアル(polyserial)相関係数
  • カテゴリカル変数 と カテゴリカル変数 の相関 → ポリコリック(polychoric)相関係数

なお、これらの相関係数は明示的に補正係数を掛けるバイシリアル相関係数とは異なり、「正規分布に従う変数が任意の閾値で区切られて観測された」という状況をモデリングし、そのモデルのもとで最も当てはまりがいい相関係数最尤推定するアプローチをとっています(詳しくは Drasgow 1986 などをご参照ください)。

また、バイシリアル、ポリシリアル、ポリコリックなどの相関係数Pythonで実行できるライブラリを作ったことがあるのでもしご興味のある方はご利用ください。

github.com

参考文献

  • Cohen, J. (1983). The cost of dichotomization. Applied psychological measurement, 7(3), 249-253.
  • Drasgow, F. (1986). Polychoric and polyserial correlations In: Kotz S, Johnson N, editors. The Encyclopedia of Statistics.
  • Spearman, C. (1904). The proof and measurement of association between two things. Am J Psychol, 15, 72-101.
  • Peters, C. C., & Van Voorhis, W. R. (1940). Statistical procedures and their mathematical bases. McGraw-Hill.

FastAPIを使ってOpenAPI仕様書と、TypeScriptクライアントを自動生成する

FastAPIを使うと色々便利そうだなとあらためて認識して触ってたのでメモしておきます。

FastAPI → OpenAPI仕様書

FastAPIはPythonでのWebAPI開発が簡単にできるフレームワークです。

Pythonの型ヒントをもとにデータ型のバリデーションもしてくれて、簡潔なコードでAPI開発ができます。

from fastapi import FastAPI
from typing import Literal

app = FastAPI()

Animal = Literal["cat", "dog", "bird"]

@app.get("/favorite-animal")
async def favorite(name: Animal) -> str:
    return f"{name}s are cute!"

FastAPIの便利機能の一つが、ソースコードからOpenAPIによる仕様書を自動生成してくれる点です。

fastapi.tiangolo.com

これにより仕様書を自分で書く必要がなくなります。

OpenAPI → TypeScriptのクライアント

フロントエンド側でAPIを呼び出すコードをTypeScriptで書く場合もOpenAPIがあると役立ちます。

OpenAPI TypeScript

OpenAPI TypeScript を使うと、APIのメソッドの入出力の型定義ファイルを生成できます。

インストール

npm i -D openapi-typescript typescript

tsconfig.json も追加します。

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noUncheckedIndexedAccess": true
  }
}

型定義ファイルの生成

次のコマンドで型定義ファイルを生成できます。生成されるのは.d.tsファイル1つだけです。

npx openapi-typescript ./path/to/my/openapi.json -o ./path/to/my/schema.d.ts

openapi-fetch

OpenAPI TypeScriptはfetchやSWRやTanStack Quaryのラッパーの生成機能もあります。

npm i openapi-fetch
import createClient from "openapi-fetch";
import type { paths } from "./my-schema"; // 生成したd.tsファイル
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const { data,  error } = await client.GET("/blogposts/{post_id}", {
  params: { path: { post_id: "123" }  },
});

非常に便利です。

openapi-ts.dev

@hey-api/openapi-ts

@hey-api/openapi-ts はOpenAPI TypeScriptよりも明示的にクライアントコードを生成するパッケージです。

https://github.com/hey-api/openapi-ts

まだ発展途上のようですが、頻繁にアップデートされています。

次のコマンドでデモを実行することができます。

npx @hey-api/openapi-ts \
  -i https://get.heyapi.dev/hey-api/backend \
  -o src/client

こんな感じでたくさんコードが生成されます。

src
└── client
    ├── client
    │   ├── client.ts
    │   ├── index.ts
    │   ├── types.ts
    │   └── utils.ts
    ├── client.gen.ts
    ├── core
    │   ├── auth.ts
    │   ├── bodySerializer.ts
    │   ├── params.ts
    │   ├── pathSerializer.ts
    │   └── types.ts
    ├── index.ts
    ├── sdk.gen.ts
    └── types.gen.ts

ただ、少し使ってみたところ、シンプルなAPIに対してでも多量のコードが生成され、認証がないAPIについても認証についてのコードが生成されるなど、個人的にはよくわからない挙動があったりで使いにくく感じました (理解が深まれば問題ないのかもしれませんが、学習コストが高そう)

OpenAPI → テスト(余談)

ちなみに、OpenAPIから property-based testing(PBT;仕様書通りの入出力ができるかのテスト)ができる Schemathesis というパッケージも存在します。

schemathesis.readthedocs.io

導入がとても簡単で、コマンドから実行したり

schemathesis run https://your-api.com/openapi.json

pytestで実行するスクリプトを作ったり

import schemathesis
schema = schemathesis.openapi.from_url("https://your-api.com/openapi.json")

@schema.parametrize()
def test_api(case):
    case.call_and_validate()  # Finds bugs automatically

Github Actionsに3行足すだけで実行できたりします

- uses: schemathesis/action@v2
  with:
    schema: "https://your-api.com/openapi.json"

ただ、FastAPIから作成したOpenAPI仕様書だと両者の間に差異は無いはずなので、多くの場合でPBTはパスします。そのため有効性はあんまり高くない気がします…。

私が簡単に試した感じでは、Pythonにおいてはboolがintのsubclassであるのに対してSchemathesisのテストではboolとintを明確に区別することを期待していた関係でそこだけFailしてました。