primer3 の Rust バインディングを触ってみた

PCR プライマー設計のデファクトスタンダードである primer3。長年 C 言語の本体と、その上に載る Python バインディング primer3-py が定番でしたが、最近になって Rust バインディング primer3-rs が公開されました。

この記事では、「まず動かす」ところを目標に、C 本体・primer3-py と対比しながら primer3-rs の使い方を簡単に紹介します。

primer3 の「3 つのレイヤー」

primer3 まわりのツールは、同じ C エンジンを異なるインターフェースで包んだものと捉えると整理しやすいです。

レイヤー 実体 使い方
C 本体 primer3_core, oligotm, ntthal などの実行ファイル Boulder IO 形式(KEY=VALUE)のテキストを標準入出力でやり取り
primer3-py Python C-API バインディング dict を渡して結果を dict で受け取る
primer3-rs(新) 3 クレート構成(後述) ビルダー + 型付き構造体 + Result

primer3-rs はこの中で最も新しく、README にも「primer3-py の API を参考にした」と明記されています。つまり primer3-py を触ったことがあれば、ほぼそのまま移行できます。

primer3-rs のクレート構成

primer3-rs は 3 つのクレートに分かれています。用途に応じて必要なものだけ使えば OK です。

  • primer3-sys : bindgen で生成した生の FFI バインディング(unsafe の世界)
  • primer3 : 安全でイディオマティックな Rust API。普段はこれを使う
  • primer3-tool : tm / hairpin / homodimer / heterodimer / design サブコマンドを持つ CLI

C ソースは同梱されており、ビルド時に自動でコンパイルされます。システムに primer3 が入っていなくても動くのが嬉しいポイントです。

インストール

ライブラリとして使う

Cargo.toml に追記するだけです。

[dependencies]
primer3 = "0.1"

ビルドには C/C++ コンパイラと libclang(bindgen 用) が必要です。

CLI として使う

コマンドラインツールだけ欲しい場合はこちら。

cargo install primer3-tool

比較として、primer3-py と C 本体のインストールも並べておきます。

# primer3-py
pip install primer3-py          # or: conda install -c bioconda primer3-py

# C 本体
conda install -c bioconda primer3   # primer3_core などが入る

1. Tm値を計算する

いちばん単純な例から。塩基配列を渡して Tm を返すだけの関数です。

primer3-rs

use primer3::calc_tm;

fn main() {
    let tm = calc_tm("GTAAAACGACGGCCAGT").unwrap();
    println!("Tm = {tm:.1} °C");
}

(output)

Tm = 54.7 °C

primer3-tool (Rust CLI)

$ primer3-tool tm GTAAAACGACGGCCAGT
GTAAAACGACGGCCAGT       54.70

primer3-py

import primer3

tm = primer3.calc_tm("GTAAAACGACGGCCAGT")
print(f"Tm = {tm:.1f} °C")

(output)

Tm = 54.7 °C

C 本体(CLI)

$ oligotm GTAAAACGACGGCCAGT
54.695495

パラメータ(塩分濃度や Tm 計算法)を変えたい場合、primer3-rs には calc_tm_with という別関数が用意されており、TmParams を渡します。Tm 計算法は Breslauer / SantaLucia / SantaLucia 2004 が選べます。primer3-py が mv_conc= などのキーワード引数で渡すのに対し、Rust では設定用の構造体を組み立てて渡す、という違いです。

2. 二次構造(ヘアピン・ダイマー)を調べる

自己相補によるヘアピンや、プライマー同士のダイマー形成は設計時にチェックしたい代表項目です。

primer3-rs

use primer3::{calc_hairpin, calc_heterodimer};

fn main() {
    let hairpin = calc_hairpin("CCCCCATCCGATCAGGGGG").unwrap();
    println!("Hairpin: Tm = {:.1} °C, dG = {:.0} cal/mol",
             hairpin.tm(), hairpin.dg());

    let dimer = calc_heterodimer("AAAAAAAAAA", "TTTTTTTTTT").unwrap();
    println!("Heterodimer: Tm = {:.1} °C", dimer.tm());
}

(output)

Hairpin: Tm = 66.7 °C, dG = -3177 cal/mol
Heterodimer: Tm = 11.2 °C

primer3-tool (Rust CLI)

$ primer3-tool hairpin CCCCCATCCGATCAGGGGG
sequence: CCCCCATCCGATCAGGGGG
tm:       66.75 C
dg:       -3177 cal/mol
dh:       -36300 cal/mol
ds:       -106.80 cal/mol/K
$ primer3-tool heterodimer AAAAAAAAAA TTTTTTTTTT
seq1: AAAAAAAAAA
seq2: TTTTTTTTTT
tm:   11.22 C
dg:   -5186 cal/mol
dh:   -66500 cal/mol
ds:   -197.69 cal/mol/K

primer3-py

import primer3

hairpin = primer3.calc_hairpin("CCCCCATCCGATCAGGGGG")
print(f"Hairpin: Tm = {hairpin.tm:.1f} °C, dG = {hairpin.dg:.0f} cal/mol")

dimer = primer3.calc_heterodimer("AAAAAAAAAA", "TTTTTTTTTT")
print(f"Heterodimer: Tm = {dimer.tm:.1f} °C")

(output)

Hairpin: Tm = 66.7 °C, dG = -3177 cal/mol
Heterodimer: Tm = 11.2 °C

C 本体(CLI)

$ ntthal -dv 1.5 -n 0.6 -a HAIRPIN -s1 CCCCCATCCGATCAGGGGG
Calculated thermodynamical parameters for dimer:        19      dS = -106.797   dH = -36300     dG = -3176.92   t = 66.7473
SEQ     /////---------\\\\\
STR     CCCCCATCCGATCAGGGGG
$ ntthal -dv 1.5 -n 0.6 -s1 AAAAAAAAAA -s2 TTTTTTTTTT
Calculated thermodynamical parameters for dimer:        dS = -197.691   dH = -66500     dG = -5186.16   t = 11.2166
SEQ               
SEQ     AAAAAAAAAA
STR     TTTTTTTTTT
STR

結果の受け取り方が対照的です。primer3-py は結果オブジェクトの属性(.tm, .dg)に直接アクセスするのに対し、primer3-rs は ThermoResult 構造体のメソッド(.tm(), .dg())で取り出します。

同様に calc_homodimer(ホモダイマー)、calc_end_stability(3′ 末端安定性)も揃っています。C 本体では ntthal で計算できますが、二価陽イオン濃度などのデフォルト設定が他と異なります。

3. プライマーを設計する

本命のプライマー設計です。ここが 3 つのインターフェースで最も違いの出るところです。

primer3-rs ―― ビルダー(builder())で配列引数と設定を組み立て、design_primers() に渡します。

use primer3::{PrimerSettings, SequenceArgs, design_primers};

fn main() {
    let seq_args = SequenceArgs::builder()
        .sequence("CAGCAGYCAGTCAGCTAGCAGTCATCGGGGGCGCGCAGCTAGCATCAGCTAGCTAGCATCGATCGATCTGATCGATCGACAGCATCGATCGATCACTAGCATCG")
        .target(50, 20)      // start=50, length=20
        .build()
        .unwrap();

    let mut settings = PrimerSettings::builder()
        .primer_opt_tm(60.0)
        .primer_min_tm(57.0)
        .primer_max_tm(63.0)
        .product_size_range(75, 150)
        .build()
        .unwrap();

    // ビルダーでは設定できないものでも代入で設定可能なものもある
    settings.liberal_base = true;

    // 末尾 2 つの None は、ミスプライミング/ミスハイブリ用ライブラリ(省略可)
    let result = design_primers(&seq_args, &settings, None, None).unwrap();

    for pair in result.pairs() {
        let (left, right) = (pair.left(), pair.right());
        println!(
            "Left: {} (Tm={:.1}), Right: {} (Tm={:.1}), Penalty={:.2}",
            left.sequence(), left.tm(),
            right.sequence(), right.tm(),
            pair.pair_penalty(),
        );
    }
}

(output)

Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGCT (Tm=59.1), Penalty=2.13
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: GCTAGTGATCGATCGATGCTG (Tm=58.8), Penalty=2.50
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGC (Tm=57.7), Penalty=2.59
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGCTG (Tm=60.4), Penalty=2.60
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: GCTAGTGATCGATCGATGCTGT (Tm=60.4), Penalty=2.60

primer3-py ―― SEQUENCE_ 系と PRIMER_ 系の 2 つの dict を渡します。

import primer3

result = primer3.bindings.design_primers(
    seq_args={
        "SEQUENCE_ID": "example",
        "SEQUENCE_TEMPLATE": "CAGCAGYCAGTCAGCTAGCAGTCATCGGGGGCGCGCAGCTAGCATCAGCTAGCTAGCATCGATCGATCTGATCGATCGACAGCATCGATCGATCACTAGCATCG",
        "SEQUENCE_TARGET": [50, 20],
    },
    global_args={
        "PRIMER_OPT_TM": 60.0,
        "PRIMER_MIN_TM": 57.0,
        "PRIMER_MAX_TM": 63.0,
        "PRIMER_PRODUCT_SIZE_RANGE": [[75, 150]],
        "PRIMER_LIBERAL_BASE": 1,
    },
)


for i in range(result["PRIMER_PAIR_NUM_RETURNED"]):
    left = result[f"PRIMER_LEFT_{i}_SEQUENCE"]
    right = result[f"PRIMER_RIGHT_{i}_SEQUENCE"]
    left_tm = result[f"PRIMER_LEFT_{i}_TM"]
    right_tm = result[f"PRIMER_RIGHT_{i}_TM"]
    pair_penalty = result[f"PRIMER_PAIR_{i}_PENALTY"]

    print(f"Left: {left} (Tm={left_tm:.1f}), Right: {right} (Tm={right_tm:.1f}), Penalty={pair_penalty:.2f}")

(output)

Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGCT (Tm=59.1), Penalty=2.13
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: GCTAGTGATCGATCGATGCTG (Tm=58.8), Penalty=2.50
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGC (Tm=57.7), Penalty=2.59
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: TGCTAGTGATCGATCGATGCTG (Tm=60.4), Penalty=2.60
Left: GTCAGCTAGCAGTCATCGGG (Tm=60.2), Right: GCTAGTGATCGATCGATGCTGT (Tm=60.4), Penalty=2.60

C 本体 ―― Boulder IO 形式のテキストを作り、primer3_core に流し込みます。

SEQUENCE_ID=example
SEQUENCE_TEMPLATE=CAGCAGYCAGTCAGCTAGCAGTCATCGGGGGCGCGCAGCTAGCATCAGCTAGCTAGCATCGATCGATCTGATCGATCGACAGCATCGATCGATCACTAGCATCG
SEQUENCE_TARGET=50,20
PRIMER_OPT_TM=60.0
PRIMER_MIN_TM=57.0
PRIMER_MAX_TM=63.0
PRIMER_PRODUCT_SIZE_RANGE=75-150
PRIMER_LIBERAL_BASE=1
=
$ primer3_core <../input.txt
SEQUENCE_ID=example
SEQUENCE_TEMPLATE=CAGCAGYCAGTCAGCTAGCAGTCATCGGGGGCGCGCAGCTAGCATCAGCTAGCTAGCATCGATCGATCTGATCGATCGACAGCATCGATCGATCACTAGCATCG
SEQUENCE_TARGET=50,20
PRIMER_OPT_TM=60.0
PRIMER_MIN_TM=57.0
PRIMER_MAX_TM=63.0
PRIMER_PRODUCT_SIZE_RANGE=75-150
PRIMER_LIBERAL_BASE=1
PRIMER_WARNING=Unrecognized base in input sequence
PRIMER_LEFT_NUM_RETURNED=5
PRIMER_RIGHT_NUM_RETURNED=5
PRIMER_INTERNAL_NUM_RETURNED=0
PRIMER_PAIR_NUM_RETURNED=5
PRIMER_PAIR_0_PENALTY=2.131862
PRIMER_LEFT_0_PENALTY=0.249238
PRIMER_RIGHT_0_PENALTY=1.882623
PRIMER_LEFT_0_SEQUENCE=GTCAGCTAGCAGTCATCGGG
PRIMER_RIGHT_0_SEQUENCE=TGCTAGTGATCGATCGATGCT
PRIMER_LEFT_0=9,20
PRIMER_RIGHT_0=100,21
PRIMER_LEFT_0_TM=60.249
PRIMER_RIGHT_0_TM=59.117
...

3 者を比べると設計思想がよく見えます。

  • C 本体は文字列キーの世界。柔軟だがキー名のタイポはランタイムまで気づけない。
  • primer3-py は dict なので Python らしいが、キーはやはり文字列。IDE 補完も効きにくい。
  • primer3-rs はビルダーメソッド(.primer_opt_tm(60.0) など)なので、コンパイル時に名前と型がチェックされ、補完も効く。ここが Rust バインディングの地味にありがたいところです。

なお primer3-rs には Boulder IO を扱う boulder モジュールもあり、既存の Boulder IO 資産との相互運用もできるようになっています。

主要な違いまとめ

観点 C 本体 primer3-py primer3-rs
インターフェース Boulder IO テキスト dict ビルダー + 型付き構造体
パラメータの型安全性 なし(文字列キー) なし(文字列キー) あり(コンパイル時チェック)
エラー処理 終了コード / stderr 例外 Result
導入 conda 等 pip / conda cargo(要 C コンパイラ + libclang)
デフォルト値 primer3 の設定次第 独自の既定値 C ライブラリ v2 の既定値(primer3-py と一部異なる)
ライセンス GPL-2.0-or-later GPL-2.0 GPL-2.0-or-later

デフォルト値の違いは要注意ポイントです。ドキュメントには「primer3-rs は C ライブラリ v2 の p3_create_global_settings() の既定値を使っており、primer3-py の既定値とは一部異なる」とあります。実際にどう違うかは未確認ですがなるべく明示的に設定するのが安全かもしれません。

実装間で結果は一致するのか? ― 実際に回してみる

「インターフェースが違うだけで結果は同じ」なのか、実際に確かめてみます。ランダムな 600 bp テンプレート 5 本に対し、制約(プライマー長・Tm・GC・product size・塩/dNTP/DNA 濃度・返却数)を 3 実装で完全に揃えて design を回し、返ってきたプライマーペアを比較しました。環境は primer3 2.6.1、primer3-py 2.3.0、primer3-rs 0.1.0 です。

結果はきれいに揃いました。

  • primer3 C 本体・primer3-py・primer3-rs の 3 実装が完全一致。5 テンプレート × 各 5 ペアすべてで、左右プライマー配列・product size・ペナルティ値(小数第 3 位まで)が完全に一致しました。

Rust バインディング ユースケース

primer3-rs が向いている場面

  • 既存のツールチェインが Rust 製(noodles などで BAM/VCF/FASTA を扱っている等)で、そこにプライマー設計を組み込みたい
  • 依存関係の少ない単一静的バイナリとして配布したい
  • 大量配列を回すバッチで、コンパイル時の型安全性とパフォーマンスが欲しい

primer3-py で十分な場面

  • 探索的な解析、インタラクティブな試行
  • 既に Python 中心のパイプラインがある

C 本体を直接叩く場面

  • シェルパイプラインに組み込むだけで十分
  • サブプロセス起動で済ませたい

まとめ

  • primer3 の新しい Rust バインディング primer3-rs が公開された。primer3-sys / primer3 / primer3-tool の 3 クレート構成
  • API は primer3-py を踏襲しつつ、ビルダー + 型付き構造体 + Result で Rust らしく型安全
  • C ソース同梱で導入は楽だが、ビルドに C コンパイラ + libclang が必要
  • 既定値が primer3-py と一部異なるらしいので、移植時はパラメータに注意

primer3-py の資産をそのまま活かしつつ、Rust の型安全性とパフォーマンスを取り込みたい人にとって、有力な選択肢が増えたと言えそうです。

参考リンク

スキャン動画で画像解析による形質評価

良くあるハイスループットなフェノタイピングとしてベルトコンベアを用いたものや専用のレールに取り付けられたカメラを用いたもの、ドローンを用いたものなどがあると思います。ただ、どれも多少の導入コストがかかるため、1回きりの調査には使いづらい感があります。

特に個人ではなかなか手が出しづらいです。

そこで、スマホとその辺にある台車をつかって手軽にデータを取れないか試してみました。

具体的には、以下の図に示すような装置を作って、装置を手で速度を一定に保ちながら動かして、対象の動画を撮影しました。

今回は対象として徳島県産と長崎県産のニンジンを使いました。それぞれのニンジンの産地がわかるように各サンプルの左端には1辺10cmで産地名をコードしたQRを配置しました。

イメージはこのような感じです。

横から装置を見た模式図

実際に撮影された動画はこのようになりました。

撮影された動画

動画の各フレームからQRとニンジンを検出する

動画は複数の連続する画像(フレーム)からなっているので各フレームからQRとニンジンを検出していきます。opencvを用いて動画から各フレームを読み込んで検出、結果をディスプレイに表示、ファイルに書き込みという一連の処理は、以下のループで実装しました。

video_path = "input.mp4"
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
    raise IOError(f"Cannot open video file: {video_path}")


# 動画の各種情報を取得する
frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
fps = cap.get(cv2.CAP_PROP_FPS)

# 検出結果の保存するための書き込み用オブジェクトも作っておく
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter('output.mp4', fourcc, fps, (int(frame_width), int(frame_height)))

# 動画のフレームをループして1つずつ処理する
while cap.isOpened():
    # 動画から1フレーム読み込む
    success, frame = cap.read()
    pos_frames = cap.get(cv2.CAP_PROP_POS_FRAMES)

    if success:
        # frameに対してメイン処理
        # 検出結果を描画したフレームを出力ファイルに書き込む
        out.write(frame)
        # 同じ情報をディスプレイ上にも表示する
        cv2.imshow("ニンジントラッキング", frame)

        # qキーが押下されたら終了する
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break
        

QRの検出

QRについての検出はopencvのQRCodeDetectorArucoを用いました。

qr_detector = cv2.QRCodeDetectorAruco()
qr_detected, decoded_info_list, corners_list, _ = qr_detector.detectAndDecodeMulti(frame)

ニンジンの検出

ニンジンに検出にはyolo26を用いました。Ultralyticsによって開発されているライブラリに含まれています。かなりしっかりとメンテナンスされているいんしょうで、ホビー用途では無料で使えるのでありがたいです。

ニンジンの検出は少し特殊で、1フレームに複数映る可能性があるので、各個体を識別しながら検出する必要があります。 そこで今回は各検出のトラッキングを行い、同一個体と予測されたものに識別可能なIDを振る処理を行いました。

UltralyticsのYOLOを用いる場合はすごく簡単でmodel.track()でそれが可能です。

model = YOLO("yolo26n-seg.pt")
result = model.track(frame, conf=0.8, iou=0.8, persist=True)[0]

検出についてをまとめたコード全体

以下に全体的なコードを示します。各フレームで検出されたQRコードとニンジンの情報を格納し、最後にデータフレームにしています。また、検出状況はディスプレイに表示されるようにしていますが、後で見返しもできるように動画に記録もしています。

コードは以下を参考にしました。

今回はニンジンの根長と根径を検出時のバウンディングボックスの高さと幅で大まかに近似して測ることにしています。

from ultralytics import YOLO

from collections import defaultdict

import cv2
import numpy as np

from ultralytics import YOLO

from dataclasses import dataclass
import pandas as pd

@dataclass
class Carrot:
    id: int
    x: float
    y: float
    w: float
    h: float
    pos_frames: float

@dataclass
class QRCode:
    decoded_info: str
    x: float
    y: float
    side_length: float
    pos_frames: float


# YOLO26モデルをロードする(今回はニンジンをこのモデルを使って検出する)
model = YOLO("yolo26n-seg.pt")

# OpenCVのQR検出器を作成する
qr_detector = cv2.QRCodeDetectorAruco()

# 動画を開く
video_path = "input.mp4"
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
    raise IOError(f"Cannot open video file: {video_path}")

# 動画の各種情報を取得する
frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
fps = cap.get(cv2.CAP_PROP_FPS)

print(f"総フレーム数: {frame_count}, フレーム幅: {frame_width}, フレーム高さ: {frame_height}, FPS: {fps}")

# 検出結果の保存するための書き込み用オブジェクトも作っておく
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter('output.mp4', fourcc, fps, (int(frame_width), int(frame_height)))

# 検出されたQRコードとニンジンの保存のためのリスト
qr_codes = []
carrots = []

# 動画のフレームをループして1つずつ処理する
while cap.isOpened():
    # 動画から1フレーム読み込む
    success, frame = cap.read()
    pos_frames = cap.get(cv2.CAP_PROP_POS_FRAMES)

    if success:
        # YOLOを実行して対象をトラッキングする
        result = model.track(frame, conf=0.8, iou=0.8, persist=True)[0]

        # 得られたバウンディングボックスがあれば処理を開始
        if result.boxes and result.boxes.is_track:
            boxes = result.boxes.xywh.cpu()
            track_ids = result.boxes.id.int().cpu().tolist()
            classes = result.boxes.cls.int().cpu().tolist()

            # 対象検出結果をフレームに描画する
            frame = result.plot()

            # 検出された物体を1つずつ処理する
            for box, track_id, class_id,  in zip(boxes, track_ids, classes):
                if model.names[class_id] != 'carrot': # ニンジン以外はスキップ
                    continue
                x, y, w, h = box

                # ニンジン検出結果を保存
                carrots.append(Carrot(
                    id=track_id,
                    x=float(x),
                    y=float(y),
                    w=float(w),
                    h=float(h),
                    pos_frames=pos_frames
                ))
                # ニンジンの中心座標にマーカーを描画する
                cv2.drawMarker(frame, (int(x), int(y)), (255, 255, 0), 1, 50, 10)
            
        # QRコード検出を行う
        qr_detected, decoded_info_list, corners_list, _ = qr_detector.detectAndDecodeMulti(frame)
        if qr_detected:
            for decoded_info, corners in zip(decoded_info_list, corners_list):
                if decoded_info == "": # デコードされた結果が空文字列の場合はスキップ
                    continue
                # QRの中心座標を計算して描画する(コーナー座標の平均)
                x, y = corners.mean(axis=0)
                cv2.drawMarker(frame, (int(x), int(y)), (255, 255, 0), 1, 50, 10)
                # QRの辺の長さを計算する
                side_length = np.mean(np.linalg.norm(corners - np.roll(corners, shift=1, axis=0), axis=1))

                # QR検出結果を保存
                qr_codes.append(QRCode(
                    decoded_info=decoded_info,
                    x=x,
                    y=y,
                    side_length=side_length,
                    pos_frames=pos_frames
                ))
                
                # QRの検出結果を描画する
                corners = corners.astype(int)
                for i in range(len(corners)):
                    cv2.line(frame, tuple(corners[i]), tuple(corners[(i + 1) % len(corners)]), (0, 255, 0), 10)
                if decoded_info:
                    cv2.putText(frame, decoded_info, (corners[0, 0], corners[0, 1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 3, (0, 255, 0), 10)

                
        
        # 検出結果を描画したフレームを出力ファイルに書き込む
        out.write(frame)
        # 同じ情報をディスプレイ上にも表示する
        cv2.imshow("ニンジントラッキング", frame)

        # qキーが押下されたら終了する
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break
    else:
        # 動画が最後に到達したら終了する
        break

# 動画キャプチャオブジェクトを解放して結果表示ウィンドウを閉じる
cap.release()
out.release()
cv2.destroyAllWindows()

# 結果をデータフレームに保存する
df_carrots = pd.DataFrame(carrots)
df_qr_codes = pd.DataFrame(qr_codes)
総フレーム数: 600.0, フレーム幅: 2160.0, フレーム高さ: 3840.0, FPS: 60.0

検出の様子

結果は以下のGIF動画に示すようになりました。

検出結果

検出したデータから目的の形質を集計する

ニンジン検出結果は以下のようになり1000フレーム以上から結果が得られていることになました。YOLOのトラッキング機能によって各検出にはIDが振られており、同一IDイコール同一個体として識別可能になります。また、各検出の中心座標(x, y)、バウンディングボックスの幅と高さ(w, h)と検出されたフレーム位置(何番目のフレームか)(pos_frames)を今回は記録しています。

df_carrots
id x y w h pos_frames
0 1 1949.486328 1463.835205 421.027344 1487.324707 1.0
1 1 1949.034424 1463.601929 421.827026 1487.832764 2.0
2 1 1948.967529 1463.503540 421.823364 1488.549561 3.0
3 1 1948.907471 1462.683838 421.866211 1488.790039 4.0
4 1 1948.970947 1461.983032 421.774292 1488.717041 5.0
... ... ... ... ... ... ...
1001 5 687.110168 2273.889160 365.511963 2044.912231 599.0
1002 6 1145.700928 1989.584961 387.035339 1371.399048 599.0
1003 4 199.110947 2048.687012 379.045929 1717.308960 600.0
1004 5 677.186340 2278.349609 364.560333 2052.345947 600.0
1005 6 1134.652466 1989.436890 385.959473 1371.355713 600.0

1006 rows × 6 columns

QRコードについても同様に見てみると160フレーム以上から検出ができていて、同様に中心座標(x, y)やフレーム位置(pos_frames)を記録しています。バウンディングボックス情報は記録していないのとID情報はありませんが、その代わりに、デコードされた情報、辺の長さ(px単位)を記録しています。

df_qr_codes
decoded_info x y side_length pos_frames
0 tokushima 581.683594 1547.049805 859.927246 1.0
1 tokushima 581.452271 1547.780884 861.898560 2.0
2 tokushima 580.443970 1547.052734 860.664185 3.0
3 tokushima 580.452026 1547.276855 860.923462 4.0
4 tokushima 579.443665 1546.787354 860.894775 5.0
... ... ... ... ... ...
160 nagasaki 500.842163 1742.364380 862.768433 446.0
161 nagasaki 489.627319 1743.115723 862.798096 447.0
162 nagasaki 480.617737 1742.910400 865.022095 448.0
163 nagasaki 472.753601 1745.186157 864.993408 449.0
164 nagasaki 465.642639 1745.708862 864.330017 450.0

165 rows × 5 columns

各ニンジン個体がどのフレームで検出されたかをグラフにすると以下のようになります。

import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot(df_carrots.pos_frames, [f"carrot:{i}" for i in df_carrots.id], 'C1o')
plt.show()

ニンジンIDごとの検出フレーム

ここで、フレームの端っこで検出されたニンジンは端っこが切れていたり、切れていなくても歪んでいたりする可能性があるので、それらを除きたいと思います。今回はニンジンの中心のx座標が動画フレームの中心10%に入るデータだけを切り出して解析に用いたいと思います。

中央10%に入るニンジンフレームからの情報だけ切り出す

ここで濃いオレンジでプロットした部分が残ったデータになります。

index = np.logical_and(df_carrots.x > frame_width * 0.45,  df_carrots.x < frame_width * 0.55)
df_carrots_center = df_carrots[index]

fig, ax = plt.subplots()
ax.plot(df_carrots.pos_frames, [f"carrot:{i}" for i in df_carrots.id], 'C1o', alpha=.01)
ax.plot(df_carrots_center.pos_frames, [f"carrot:{i}" for i in df_carrots_center.id], 'C1o')
plt.show()

必要フレームの抽出

各ニンジンの平均情報を算出する

動画フレームごとに検出などにノイズが入ることから、検出高さと幅などの平均を取ることとします。また、この時点で検出された平均フレームも取得します。

df_carrots_mean = df_carrots.groupby('id').mean().reset_index()
df_carrots_mean
id x y w h pos_frames
0 1 1206.867914 1494.401272 498.538615 1475.519982 121.299578
1 2 1182.876424 1599.698061 450.049639 1567.755488 188.234450
2 3 1051.950533 1568.823529 472.428185 1562.927545 264.211957
3 4 1144.410894 2046.994780 384.317635 1678.992444 516.987952
4 5 1352.268076 2288.102073 348.824204 2046.767100 538.696721
5 6 1615.287771 1993.774678 387.900986 1374.018268 554.670455

QRコードが出現したフレームと並べる

QRコードが検出されたフレームの情報を並べて表示してみると順番に検出できていることがわかります。各QRコードの平均フレームを算出し、それ以降に出現したニンジンにそのQRでコードされている情報(ここでは産地ですが、系統名などでも良いでしょう)を割り振りつつ、個体IDも産地ごとの動画での出現順に振りなおしたいと思います。

index = np.logical_and(df_qr_codes.x > frame_width * 0.2, df_qr_codes.x < frame_width * 0.8)
fig, ax = plt.subplots()
ax.plot(df_qr_codes[index].pos_frames, df_qr_codes[index].decoded_info, 'C0o')
ax.plot(df_carrots_center.pos_frames, [f"carrot:{i}" for i in df_carrots_center.id], 'C1o')
plt.show()

QRとニンジンの検出フレーム

df_qr_codes_mean = df_qr_codes.groupby('decoded_info').mean().reset_index()

df = pd.concat([df_carrots_mean, df_qr_codes_mean])
df.sort_values('pos_frames', inplace=True)
df['type'] = np.where(df.decoded_info.isna(), 'carrot', 'QR')
df[['decoded_info', 'side_length']] = df[['decoded_info', 'side_length']].ffill()
df['individual_id'] = df.groupby('decoded_info').cumcount()
df = df.reset_index(drop=True)
df
id x y w h pos_frames decoded_info side_length type individual_id
0 NaN 550.764160 1546.536377 NaN NaN 26.500000 tokushima 861.869568 QR 0
1 1.0 1206.867914 1494.401272 498.538615 1475.519982 121.299578 tokushima 861.869568 carrot 1
2 2.0 1182.876424 1599.698061 450.049639 1567.755488 188.234450 tokushima 861.869568 carrot 2
3 3.0 1051.950533 1568.823529 472.428185 1562.927545 264.211957 tokushima 861.869568 carrot 3
4 NaN 1079.954102 1737.297241 NaN NaN 394.000000 nagasaki 851.076233 QR 0
5 4.0 1144.410894 2046.994780 384.317635 1678.992444 516.987952 nagasaki 851.076233 carrot 1
6 5.0 1352.268076 2288.102073 348.824204 2046.767100 538.696721 nagasaki 851.076233 carrot 2
7 6.0 1615.287771 1993.774678 387.900986 1374.018268 554.670455 nagasaki 851.076233 carrot 3

検出されたQRコードの辺の長さをリファレンスとして根長と根径をcm単位で測定

今回用いたQRコードは1辺が10cmであることがわかっているのでその情報を元に、検出されたニンジンの根長と根径をそれぞれバウンディングボックスの高さと幅で近似して測定します。

df['diameter_cm']= df['w'] / df['side_length'] * 10
df['length_cm']= df['h'] / df['side_length'] * 10
df
id x y w h pos_frames decoded_info side_length type individual_id diameter_cm length_cm
0 NaN 550.764160 1546.536377 NaN NaN 26.500000 tokushima 861.869568 QR 0 NaN NaN
1 1.0 1206.867914 1494.401272 498.538615 1475.519982 121.299578 tokushima 861.869568 carrot 1 5.784386 17.119992
2 2.0 1182.876424 1599.698061 450.049639 1567.755488 188.234450 tokushima 861.869568 carrot 2 5.221784 18.190171
3 3.0 1051.950533 1568.823529 472.428185 1562.927545 264.211957 tokushima 861.869568 carrot 3 5.481435 18.134154
4 NaN 1079.954102 1737.297241 NaN NaN 394.000000 nagasaki 851.076233 QR 0 NaN NaN
5 4.0 1144.410894 2046.994780 384.317635 1678.992444 516.987952 nagasaki 851.076233 carrot 1 4.515666 19.727874
6 5.0 1352.268076 2288.102073 348.824204 2046.767100 538.696721 nagasaki 851.076233 carrot 2 4.098625 24.049163
7 6.0 1615.287771 1993.774678 387.900986 1374.018268 554.670455 nagasaki 851.076233 carrot 3 4.557770 16.144479

手計測の値と比較する

最後に、手計測で取得された根長と根径を今回の画像解析での測定値で比較してみます。

df_manual = pd.DataFrame({
    'production_area': ['tokushima'] * 3 + ['nagasaki'] * 3,
    'individual_id': [1, 2, 3] * 2,
    'length_by_manual': [15.2, 16.0, 15.9, 18.0, 22.5, 14.8],
    'diameter_by_manual': [5.0, 4.4, 4.8, 3.6, 3.5, 3.6],
})
df_manual
production_area individual_id length_by_manual diameter_by_manual
0 tokushima 1 15.2 5.0
1 tokushima 2 16.0 4.4
2 tokushima 3 15.9 4.8
3 nagasaki 1 18.0 3.6
4 nagasaki 2 22.5 3.5
5 nagasaki 3 14.8 3.6
df = df.rename(columns={'decoded_info': 'production_area'})
df = df.set_index(['production_area', 'individual_id']).join(df_manual.set_index(['production_area', 'individual_id']))
plt.scatter(
    x=df['length_by_manual'],
    y=df['length_cm']
)
plt.axline(xy1=[df['length_by_manual'].mean()]*2, slope=1)
plt.axis('equal')
plt.show()

画像解析の根長と手計測の根長の相関

plt.scatter(
    x=df['diameter_by_manual'],
    y=df['diameter_cm']
)
plt.axline(xy1=[df['diameter_by_manual'].mean()]*2, slope=1)
plt.axis('equal')
plt.show()

画像解析の根径と手計測の根径の相関

df = df['length_cm'] - df['length_by_manual'])
production_area  individual_id
tokushima        0                     NaN
                 1                1.919992
                 2                2.190171
                 3                2.234154
nagasaki         0                     NaN
                 1                1.727874
                 2                1.549163
                 3                1.344479
dtype: float64
df['diameter_cm'] - df['diameter_by_manual']
production_area  individual_id
tokushima        0                     NaN
                 1                0.784386
                 2                0.821784
                 3                0.681435
nagasaki         0                     NaN
                 1                0.915666
                 2                0.598625
                 3                0.957770
dtype: float64

まとめ

  • スマホを移動する形式で撮影した画像からQRコード検出、計測対象物体検出は可能
  • QRをサイズリファレンスとして手計測値と相関するデータは得られそう
  • ただし今回の設定・手法だと1~2cmほど大きく見積もってしまう

今回生じた誤差に関しては以前書いた記事にある内容も関係しているかもしれません。

menseki.hatenablog.jp

参考

Grid Engine系ジョブスケジューラー上でsnakemakeを走らせる

snakemakeコマンドを実行するラッパースクリプトをqsubでGrid Engineに投げるのではなく、executorとして(Sun/Univa/Altar) Grid Engineを指定する方法を紹介します。

私の環境ではUniva Grid Engineが動いているのでそちらでテストしていますが、多くの場合はそのほかの互換性のあるGrid Engineでも動かせるはずです。以後、Grid Engine系ジョブスケジューラーのことをSGEと呼びます。

snakemakeはバージョンが変わるごとにexecutorの仕様が結構変わっており、追いかけるのが大変だったので、2026年1月時点のsnakemake 9での情報を書き留めておきます。

すべてのexecutor backendはプラグイン

昔のsnakemakeではexecutorを指定する機能もデフォルトでついてきていた記憶がありますが、現在はプラグインとして提供されています。

プラグインのドキュメントはこちらにあります。ただ、このドキュメントはあまり親切ではなく、設定が難しく感じました。いろいろ試行錯誤したので、備忘録として記します。

executor pluginのインストール

SGEそのものを対象としたプラグインは存在しないので、cluster-genericのプラグインをインストールします。

pip install snakemake-executor-plugin-cluster-generic

または

conda install -n snakemake snakemake-executor-plugin-cluster-generic

executorを指定してsnakemakeを実行

先ほどプラグインをインストールしたので、cluster-genericがexecutorオプションに指定できるようになっています。

早速、以下のように指定して実行してみます。

snakemake --executor cluster-generic

すると

The following required arguments are missing for plugin cluster-generic: --cluster-generic-submit-cmd.

と出るので、サブミットコマンド、つまりqsubを指定してもう一度実行してみます。

snakemake --executor cluster-generic --cluster-generic-submit-cmd qsub

すると、またエラーで、

Error: maximum number of parallel jobs/used nodes has to be specified for remote execution (use --jobs N with N being a number >= 1)

と言われるので、並列に実行する最大ジョブ数を適当に10と指定して、

snakemake --executor cluster-generic --cluster-generic-submit-cmd qsub --jobs 10

ここまでしてやっと動きました。

external jobidの整合性をとって、qdel可能にする

しかし、よくログを見てみると以下のような行が現れました。

Submitted job 6 with external jobid 'Your job 37472 ("snakejob.test_rule.6.sh") has been submitted'.

external jobidがYour job 37472 ("snakejob.test_rule.6.sh") has been submittedとなっているとのことです。external jobidとはSGE側で割り振られるjobidのことで、ここは整数であって欲しいところです。

ドキュメントではこの辺りの説明が乏しかったのですが、実装を見てみると、--cluster-generic-submit-cmdで指定したコマンドを実行したときに、標準出力に出力される文字列をそのままexternal jobidとして認識しているようでした。

私の環境ではオプションなしのqsubコマンドの出力は人が読みやすいメッセージ形式となっています。そのため、external jobidが文字列として認識されてしまっていると考えられます。

とはいえ、external jobidが文字列になってしまっている点以外は実行上問題ありませんでした。しかし、jobidが正確に認識されていないことを考えると、snakemake側でジョブのキャンセル等はうまくいかない気がしてきます。

また、--executorcluster-genericを指定した際は、先ほどの--cluster-generic-submit-cmdのほかに、--cluster-generic-status-cmd--cluster-generic-cancel-cmd--cluster-generic-cancel-nargs--cluster-generic-sidecar-cmdというオプションが必須ではないものの指定可能です。

そこで、試しに--cluster-generic-cancel-cmdオプションについても指定してみます。

snakemake --executor cluster-generic --cluster-generic-submit-cmd qsub --jobs 10 --cluster-generic-cancel-cmd qdel

このワークフローが実行中にCtrl+Cなどで実行を中断してみるとどうなるでしょうか?

すると、以下のようなログが出ました。

The job Your job 37493 ("snakejob.test_sleep.8.sh") has been submitted of user(s) test_user does not exist
1 out of 1 calls to --cluster-cancel failed. This is safe to ignore in most cases.

つまり、SGEにサブミットされたジョブのキャンセルが失敗したことがわかります。ジョブの内容が有限で終了するものであるのならば放っておいても構わないかもしれませんが、今回はこの問題の解決も試みてみます。

原因はおそらく、先ほどのexternal jobidの不具合と同様に、snakemakeがジョブスケジューラー側のjobidを適切に認識していないことにあります。

そこでqsubコマンドの標準出力がjobidのみというフォーマットになるようにしたいので、ちょうどよいqsubオプションを使います。それが-terseオプションで、以下のように再度snakemakeを実行してみます。

snakemake --executor cluster-generic --cluster-generic-submit-cmd 'qsub -terse' --jobs 10 --cluster-generic-cancel-cmd qdel

すると先ほど違和感のあった部分のログは以下のようになり、

Submitted job 7 with external jobid '37499'.

Ctrl+Cで終了した場合のログは以下のようになりました。

Terminating processes on user request, this might take some time.
test_user has registered the job 37499 for deletion

(test_user37499の部分は環境によって異なります)

これで、正常にジョブをキャンセルできているように見えます。

Snakefileで指定したthreadsの値をSGE予約スロット数に反映する

ここまででもだいぶ使えるようになったものの、この設定だけでは、ruleの定義などで設定したthreadsの値がqsub実行時の予約スロット数に反映できていません。

SGE系で並列ジョブを投げる場合にスロット数を指定するには並列環境と並列数をオプションで指定すればよいのですが、Snakefile内のthreadsはruleごとに異なります。そこで{}を使ったプレースホルダを指定すると動的に反映できます。

具体的には

snakemake --executor cluster-generic --cluster-generic-submit-cmd 'qsub -terse -pe threads {threads}' --jobs 10 --cluster-generic-cancel-cmd qdel

こうしておけば、ジョブスケジューラー側にも使用するスロット数を指定してジョブをサブミットできるようになり、意図せずクラスターを過負荷にすることを避けられるはずです。

そのほか、いろいろ現段階で指定しておくとよさそうなものを見ていきます。

ジョブの名前

qsub-Nオプションにプレースホルダ付きで指定できます。

snakemake --executor cluster-generic --cluster-generic-submit-cmd 'qsub -terse -pe threads {threads} -N {rulename}.{jobid}' --jobs 10 --cluster-generic-cancel-cmd qdel

ログの出力先の指定

個人的にはログファイルでカレントディレクトリがいっぱいになるのが好きではないので、すべてlogs/sge配下に格納されるようにしておきます。

snakemake --executor cluster-generic --cluster-generic-submit-cmd 'qsub -terse -pe threads {threads} -N {rulename}.{jobid} -e logs/sge/' --jobs 10 --cluster-generic-cancel-cmd qdel

これまでの設定をprofileにまとめる

以下の記載を$HOME/.config/snakemake/sge/config.yamlとして保存することで、--profile sgeとして起動できます。並列ジョブ数だけはYAMLファイルから外して実行ごとに変更しやすいようにしておきます。

executor: cluster-generic
cluster-generic-submit-cmd: 'qsub -terse -pe threads {threads} -N {rulename}.{jobid} -e logs/sge/'
cluster-generic-cancel-cmd: qdel

実行の際は以下のようにしてファイルに記載した設定を読み出して実行できます。

snakemake --profile sge --jobs 10

参考

  1. https://snakemake.github.io/snakemake-plugin-catalog/
  2. https://github.com/snakemake/snakemake-executor-plugin-cluster-generic
  3. https://snakemake.readthedocs.io/en/stable/executing/cli.html

snakemakeでEnvironment Modulesをつかう

バイオインフォマティクスの解析を実行する際、ソフトウェアやパッケージのバージョンなどのコンフリクトを避けたり、簡単に切り替えたりするためにEnvironment Modulesを利用して環境を動的に変更することがあるかと思います。

また、バイオインフォマティクスの解析パイプラインをsnakemakeで定義して実行することもあるかと思います。

snakemakeからEnvironment Modulesで管理されているソフトウェアをロードするには、単にshell:の部分にmodule load <module name>を追加すればよいですが、もう少し洗練された方法にenvmodulesディレクティブを使う方法があります。

具体的には、

rule rule_with_envmodules:
    envmodules:
        "some_module_01/0.0.0",
        "some_module_02/1.1.1",

といった感じで指定してから、snakemake --use-envmodulesというオプション付きで実行すると、当該ruleが実行される前に、指定されたモジュールが上から順にmodule loadされるようです。

利用方法はかなり簡便ですが、使っているうちにいくつか注意点があるなと思ったので備忘録的に記しておきます。

1. 再現性について

公式のドキュメントにも記載の通り、Environment Modulesの実装はプラットフォーム特異的な部分も多く、再現性という点でcondaやcontainerを指定する場合より劣る可能性があります。

2. module: command not foundエラーについて

いくつかの条件ではmodule: command not foundというエラーとともに、モジュールが適切にロードされない場合があります。私の場合は特に、zshsnakemakeコマンドを実行することでこの現象が起きました。zsh上ではmodule loadがちゃんと機能していたのにもかかわらすです。

いろいろ迷いましたが、これはおそらく、zsh環境で定義されていたmoduleコマンドがsnakemake側で各タスクの実行のために立ち上がるbash上に継承されないからだろうという結論に行きつきました。

moduleコマンドは実際にはシェル関数として定義されており、各種シェルスクリプト用に定義されたファイルが存在します。moduleコマンドを利用するためにはそのファイルを事前に読み込む必要があるのですが、多くの場合は.bash_profile.bashrc.zshrcなどのようなファイルによってログインの際に自動的に定義されるかと思います。このあたりの内容はこちらの記事が参考になります。

bashでログインし、そのままsnakemakeを起動すると、現在のシェルで定義されているmodule関数がうまいことsnakemake側で起動するbashに継承され、moduleコマンドが見つかるのだと思います。一方でzshから立ち上げるとその継承は働かず、snakemake側で起動するbashは素のbashとなり、module: command not foundエラーが発生するものと思われます。

snakemakeが起動するbashはlogin/interactive shellではないことが原因かと思います。module loadが実行される前に明示的にmoduleの初期化スクリプトsourceする(. /etc/profile.d/modules.shといったコードを実行する)方法も考えましたが、それを実現する手段は見つかりませんでした。

簡単な解決策はsnakemakeでuse-envmodulesを利用するときはbashを使うということかと思います。(ちなみに、同じようなワークフロー言語のnextflowでも似た事象にあったことがあるのですがこちらはまた別記事で書こうと思います。)

参考

  1. https://modules.readthedocs.io/en/latest/
  2. https://snakemake.github.io/
  3. https://snakemake.readthedocs.io/en/stable/snakefiles/deployment.html#using-environment-modules
  4. https://modules.readthedocs.io/en/latest/INSTALL.html#enable-modules-in-shells
  5. https://github.com/snakemake/snakemake-executor-plugin-slurm/issues/63 (module loadが実行される前に初期化スクリプト読み込みコマンドを挿入できるような機能追加をする動きに関するGithub Issues)

nextflowを-bgなしでバックグラウンド実行

個人的備忘録です。

背景と生じた問題点

nextflowをバックグラウンド実行するときは多くの場合、-bgオプションをつけて実行するかと思います。

ただ、先日シェルスクリプトで複数回nextflowを実行したいというケースにあたり、以下のようなシェルスクリプトを用意してバックグラウンド実行したところ、パイプラインがうまく走りませんでした。

スクリプトの内容

#!/bin/bash

nextflow run main.nf -params-file params1.json >log1
nextflow run main.nf -params-file params2.json >log2

実行コマンド

./run.sh &

こうするとバックグラウンドプロセス./run.shはStoppedの状態になって永久に動きません。

ここで、&をつけずにシェルスクリプト自体はフォアグラウンドで実行するとなぜか正常に作動しました。(バックグラウンドで動かしたいのでこれでは意味ない。)

解決策

詳細はよくわからなかったのですが、こちらの議論によれば、-ansi-log falseまたは-quietというオプションつきで実行すれば解決するとのことで、実際、以下のようにスクリプトを変更すると動きました。

#!/bin/bash

nextflow run main.nf -params-file params1.json -ansi-log false >log1
nextflow run main.nf -params-file params2.json -ansi-log false >log2

先述のリンク先の議論では-ansi-log falseまたは-quietを指定しないと入力を待つという仕様とのことのようでしたが、なぜそういう仕様なのかはよくわかりませんでした。

とりあえず問題が解決したのでよしとします。

参考

  1. https://groups.google.com/g/nextflow/c/Ok0K0gYTS_k

AlphaSimR使ってみた

AlphaSimRとは

こちらの論文で紹介されている、植物および動物の育種プログラムをシミュレートするためのRパッケージです。Faux et al., 2016で紹介された’AlphaSim’の後継にあたり、Rの関数を実行することで育種をシミュレートします。遺伝的な集団構造の設定、交配や表現型の取得などが関数になっていてそれらを組み合わせて一連の育種業務をシミュレートすることができるようです。

今回は初めてこのパッケージを使ってみようと思っているのでこちらにある”Introduction to AlphaSimR”を読みながら時々立ち止まりつつ試してみたいと思います。

パッケージのロード

まず、下準備としてパッケージのロードと、乱数シードの設定を行います。

library(AlphaSimR)
##> Loading required package: R6
library(ggplot2)
set.seed(utf8ToInt("🐶"))

先述したIndroductionのページでは、AlphaSimRで育種プログラムのシミュレートをする上での一般的な手順として、以下の4ステップがあげられています。今回の記事でもこの手順で進めつつ、各ステップで登場する関数とオブジェクトについて深掘りしながら見ていきたいと思います。

  1. Creating Founder Haplotypes
  2. Setting simulation
  3. Modeling the Breeding Program
  4. Examining the Results

(https://cran.r-project.org/web/packages/AlphaSimR/vignettes/intro.html より引用)

1. 創始者集団のハプロタイプを構築する (Create Founder Haplotypes)

最初に登場する関数はquickHaplo()です。こちらは創始者集団のハプロタイプを定義し、MapPopというオブジェクトとして返します。この関数は完全にランダムにハプロタイプを割り当てるので、より現実的なハプロタイプを得るにはrunMacs()newMapPop()関数の利用が推奨されます3

以下は創始者ハプロタイプを構築するコードになります。

# 10染色体を持ち、各染色体に1000カ所の均等な分離するサイト(locus)を持つ仮想的な集団1000個体を生成
founderPop <- quickHaplo(nInd = 1000, nChr = 10, segSites = 1000)
class(founderPop) # オプジェクトのクラス名を見る
##> [1] "MapPop"
##> attr(,"package")
##> [1] "AlphaSimR"
sloop::otype(founderPop) # オブジェクトのタイプを見る
##> [1] "S4"
print(founderPop)
##> An object of class "MapPop" 
##> Ploidy: 2 
##> Individuals: 1000 
##> Chromosomes: 10 
##> Loci: 10000

上記の例ではオプションに、染色体数として nChr = 10 、1染色体あたりの分離するサイト(座)数として segSites = 1000 を指定していました。そのため、printの出力では Loci: 10000 (= 10 * 1000) となっています。

MapPopオブジェクトに含まれる情報の中で重要なものの1つが遺伝地図情報かと思います。遺伝地図の情報を見てみるには founderPop@genMap とするか、getGenMap()関数を用います。以下では後者を利用して遺伝地図情報をデータフレームとして得た結果です。

head(getGenMap(founderPop))
##>    id chr         pos
##> 1 1_1   1 0.000000000
##> 2 1_2   1 0.001001001
##> 3 1_3   1 0.002002002
##> 4 1_4   1 0.003003003
##> 5 1_5   1 0.004004004
##> 6 1_6   1 0.005005005

表で見てもイメージしづらいので、getGenMap()で得られる情報から遺伝地図を簡易的にプロットしてみたいと思います。ただし、10000 locusもあると図が見づらくなってしまうので、遺伝地図情報に関係するオプションを適宜変えながら、図を見やすくするついでに、オプションを変えたときの結果として得られる遺伝地図の変化も見ていきます。

simple_genmap_plot <- function(mappop) {
  getGenMap(mappop) |> 
    ggplot(aes(chr, pos)) + 
    geom_line() +
    geom_point() +
    ggrepel::geom_text_repel(aes(label = id)) +
    scale_y_reverse()
}
# 染色体数を3、染色体毎サイト数を3に下げる
simple_genmap_plot(quickHaplo(nInd = 1, nChr = 3, segSites = 3))

染色体数3、染色体毎のサイト数3のgenMap

# 染色体数を3、サイト数はベクトルで指定することで染色体ごとに変えられる
simple_genmap_plot(quickHaplo(nInd = 1, nChr = 3, segSites = c(3, 5, 7)))

染色体ごとのサイト数を変更したgenMap

# genLenオプションで染色体の長さを変えられる(segSites同様ベクトルでの指定も可)
simple_genmap_plot(quickHaplo(nInd = 1, nChr = 3, segSites = 10, genLen = 2.0))

染色体の長さを変更したgenMap

# runMacsを使うと現実に即したより複雑な遺伝地図が構築される
simple_genmap_plot(runMacs(nInd = 1, nChr = 3, segSites = 10, species = "WHEAT"))

runMacsを利用したより現実に即したgenMap

ここで、genMappos列の単位について個人的に少し気になったのですが、マニュアルにその言及がある箇所を見つけられませんでした。おそらくはM(モルガン)だろうなと思いますが、あとで組換え価を計算することによって実験的にこの予想の間接的な証拠を見てみたいと思います。

2. シミュレーションの設定 (Setting Simulation Parameters)

シミュレーションを実行していく上で、形質値がどんなQTLで定義されるのか、性別はセットするのかなど、最初に設定しておくべき項目を定義することができます。そのためにはまずSimParam$new()関数でSimParamオブジェクトを生成します。ここで、変数名をSP(大文字)としておくことには大きな理由があります。AlphaSimRで使われる多くの関数は内部でグローバル変数SPという名前で検索し利用します。Introductionでも”The name SP should almost always be used, “と書いてあるので、ここは従います3

ここで注意なのが、SPという名前の変数が存在する中で、SPを上書き変更しようと思って間違えて、たとえばspという小文字の別の変数に格納した場合は以降の関数にその変更の影響を与えることができなくなってしまうことです。しかし、エラーは起きないのでこのバグに気づきづらいという状態になります。個人的にはこういう状況があるのはプログラミング上よくないような気がしますが、1スクリプトで1シミュレーションにとどめるなどしたら良いのかもしれません。

SP <- SimParam$new(founderPop)
class(SP)
##> [1] "SimParam" "R6"
sloop::otype(SP)
##> [1] "R6"

SimParamオブジェクトの実体はR6オブジェクトであり、Pythonといった他の多くのプログラミング言語に採用されているオブジェクト指向プログラミングの仕組みと似たものになっています。つまり、SimParamオブジェクトにはメソッドが実装されており、そのメソッドを呼び出すことで設定を実行していきます。

以下では1染色体につき、1000個の相加効果を持つQTLを設定します。また、性別については、シミュレーションの過程で集団に追加されていく順番に、システマチックに性別を割り当てるように設定しています。

SP$addTraitA(nQtlPerChr=1000)
SP$setSexes("yes_sys")

SimParamクラスに実装されているメソッドは ?SimParam で見ることができます。非常に多くのメソッドがあり、さまざまな設定が可能であることがわかります。

3. 育種業務をモデル化する (Modeling the Breeding Program)

今回の設定はこれで全て終わったので、ここで具体的な育種の過程をコードで表現していく段階になります。まずは創始者ハプロタイプと設定したパラメータから、初期育成集団を作成することになります。newPop()関数を使うことで表現します。ここで、先ほど作成したSPがコードには全く出てきませんが、裏でSPという名前の変数を読み込んでいるため、先ほどの設定はちゃんと読み込まれていることに注意です。

pop <- newPop(founderPop)
class(pop)
##> [1] "Pop"
##> attr(,"package")
##> [1] "AlphaSimR"
print(pop)
##> An object of class "Pop" 
##> Ploidy: 2 
##> Individuals: 1000 
##> Chromosomes: 10 
##> Loci: 10000 
##> Traits: 1

newPop()関数で得られるオブジェクトはPopクラスです。シミュレーションの過程ではこのクラスのオブジェクトに対して各種操作(選抜や交配)を適用していくことになります。print(pop)の出力を見てみると、先ほどのMapPopオブジェクトの出力で見られていた情報に加え、Traitsという項目が増えていることがわかり、 先ほどのSP$addTraitA(nQtlPerChr=1000) というコードがちゃんと機能していることがわかります。

実際にこのTraitsに関するGenetic valueをgv()関数により取得し分布を見ると以下のようになります。

firstPopGv <- gv(pop)
hist(firstPopGv, probability = TRUE)
# 正規分布の密度関数も一緒にプロット
curve(dnorm(x), from = min(firstPopGv), to = max(firstPopGv), add = TRUE)

Trait1の分布

SP$addTraitA()はデフォルトで平均0分散1の正規分布に従うような相加効果を与えるらしいです。 ちなみに、Additive effectによるGenetic valueがどのように計算されているかをあえて手動で計算すると以下のようになるかと思います。遺伝子型はpullQtlGeno()で、各QTLの効果はSimParamオブジェクトのtraitsプロパティからアクセスできます。

firstPopQtlGeno <- pullQtlGeno(pop)
unique(as.vector(firstPopQtlGeno))
##> [1] 0 1 2
summary(SP$traits[[1]]@addEff)
##>       Min.    1st Qu.     Median       Mean    3rd Qu.       Max. 
##> -5.272e-02 -9.382e-03 -2.498e-05 -2.858e-05  9.293e-03  4.918e-02
SP$traits[[1]]@intercept
##> [1] 0.02822522
# Additive EffectによるGenetic valueを遺伝子型と書くlocusの効果から手動で計算
firstPopGvManual <- (firstPopQtlGeno - 1) %*% SP$traits[[1]]@addEff + SP$traits[[1]]@intercept
# gv()関数の結果と一致することを確認
all.equal(firstPopGv, firstPopGvManual, check.attributes = FALSE)
##> [1] TRUE

これをみると遺伝子型は0, 1, 2でコードされており、0のときはその遺伝子座の効果を引き、1のときは何もせず(中間の効果)、そして2のときは効果を足し合わせているようなイメージになっているかと思います。

それでは、実際に育種プログラム全体をコードで表現してみようと思います。以下のコードでは、狭義の遺伝率h2 = 0.2として表現型をとり(setPheno()関数)、20回の世代更新を行なっています。各世代では表現型値で上位のメス500個体とオス25個体を選抜してランダムに1000組合せの交配を行い(selectCross()関数)、後代を得ています。また、各世代での平均表現型値を取得して(meanP関数)、phenoMean変数に記録しています。

pop <- setPheno(pop, h2 = 0.2)
phenoMean <- meanP(pop)
for(generation in 1:20){
  pop <- setPheno(pop, h2 = 0.2)
  pop <- selectCross(pop=pop, nFemale=500, nMale=25, use="pheno", nCrosses=1000)
  pop <- setPheno(pop, h2 = 0.2)
  phenoMean <- c(phenoMean, meanP(pop))
}

4. 結果を評価する (Examining the Results)

最後に先ほどの20世代の育種プログラムの結果を、世代を通しての平均表現型値の変遷の折れ線グラフとしてプロットしてみます。

plot(0:20, phenoMean, xlab="Generation", ylab="Mean Phenotype Value", type="l")

平均表現型値の変遷

各世代で順調に平均表現型値が上がっていて育種の効果があることを示しています。実際の育種現場ではさらに何個もの形質を評価しながら進めていかなければならないのでこんな単純なケースはそうそう無いとは思いますが、ひとまず、単純な育種プロセスをシミュレートすることができました。

Breeder’s equation

Breeder’s equationをシミュレーションから見てみたいと思います。1世代経たときの親集団と子集団の形質平均値の差をR(引用記事中ではΔZ)、選択された集団と元の集団の形質平均値の差S(selection differential)、狭義の遺伝率をh2とするとBreeder’s equationは以下の様になります。

R = h2 * S

h2setPheno()関数から設定可能なので、いくつかのh2についてシミュレートしてみたいと思います。

親集団1000個体から形質値で上位10%を選抜して、ランダムに交配し後代についても1000個体得るということを5回試行し、結果を表示してみます。結果のプロットではh2をx軸にとり、R/Sの値をy軸にとって散布図をかきました。Breeder’s equationから、このプロットはy=xの直線上付近に見られると予想されます。

examine_breeders_equation <- function(h2) {
    pop <- newPop(founderPop)
    pop <- setPheno(pop, h2 = h2)
    n <- nInd(pop)
    popSel <- selectInd(pop, nInd = n %/% 10, use = "pheno")
    pop2 <- randCross(popSel, nCrosses = n)
    pop2 <- setPheno(pop2, h2 = h2)
 
    c(
      R = unname(meanP(pop2) - meanP(pop)),
      S = unname(meanP(popSel) - meanP(pop)),
      h2 = h2
    )   
}

breeders_equation_result <- 
  purrr::map(
    seq(0.1, 1.0, 0.2),
    ~as.data.frame(t(replicate(5, examine_breeders_equation(.x))))
  ) |>
  dplyr::bind_rows()

breeders_equation_result |>
  ggplot(aes(h2, R/S)) +
  geom_point() +
  geom_abline(slope = 1, intercept = 0) +
  coord_equal()

Breeder's equationにおけるh2とR/Sの関係

実際y=x付近にプロットされました。

世代を超えたIBDの追跡(組換えの可視化)

AlphaSimRではSP$setTrackRec(TRUE)とすることで組換えイベントを保持してシミュレーションしてくれます。これを行うと、pullIbdHaplo()関数で集団のハプロタイプIBD(Identidy by descent)ベースで見ることができます。これは創始者集団の染色体断片がどのように後代に遺伝するかを見ることができるということです。そこで、簡単なグフラフィカルジェノタイプ的なプロットをする関数を作って実際に集団のいくつかの個体についてのIBDハプロタイプを見てみたいと思います。

描画を簡単にするため創始者集団は2個体の二倍体で、1染色体のみを持つとします。すると創始者集団にはハプロタイプとして合計4種類を含むことになり、これらを4色で色分けしてプロットします。また、データ数が多くなるとグラフが見にくくなるので、各世代10個体のみを得ることにしました。

simple_ibd_haplotype_plot <- function(pop, map) {
  pullIbdHaplo(pop) |>
  as.data.frame() |>
  tibble::rownames_to_column("haplo_id") |>
  tidyr::pivot_longer(-haplo_id, values_to = "haplotype") |>
  dplyr::mutate(
    haplotype = factor(haplotype)
  ) |>
  dplyr::full_join(map, by = c("name" = "id")) |>
  ggplot(aes(pos, haplo_id)) +
  geom_raster(aes(fill = haplotype)) +
  theme(axis.text.x = element_blank())

}
founderPop <- quickHaplo(2, nChr = 1, segSites = 100)
map <- getGenMap(founderPop)
SP <- SimParam$new(founderPop)
SP$setTrackRec(TRUE)
pop <- newPop(founderPop)
simple_ibd_haplotype_plot(pop, map) # 最初の集団

創始者集団のハプロタイプの色分け

それでは、上記4つに色分けされたハプロタイプ(染色体)がどう後代に遺伝するかを追ってみます。

S1世代

組換えが起こり、色分けにスイッチングしている部分が見られます。二倍体が10個体なので、合計20本分のデータがプロットに見られます。

pop <- makeCross(pop, crossPlan = matrix(c(1, 2), ncol = 2), nProgeny = 10)
simple_ibd_haplotype_plot(pop, map) # 第1世代

S1集団のハプロタイプの色分け

S2世代

pop <- randCross(pop, nCrosses = 10)
simple_ibd_haplotype_plot(pop, map) # 第2世代

S2集団のハプロタイプの色分け

S10世代

世代を経るごとに連鎖ブロックが途切れ途切れになっている様子が見えるかと思います。また、集団サイズを10と小さく取っているからか2番目のハプロタイプがかなり除かれていることが見て取れます。

for (i in 1:8) {
  pop <- randCross(pop, nCrosses = 10)
}  
simple_ibd_haplotype_plot(pop, map) # 第10世代

S10集団のハプロタイプの色分け

遺伝距離と組換え価の関係

先ほど、getGenMap()で得られるpos列の単位について、おそらくM(モルガン)であろうという話をしましたが、それを間接的に裏付けるデータを取ってみようと思います。具体的には親を1個体を用意して、その自殖第1世代(S1)1000個体を得るということをシミュレーションしてみます。このとき、親、およびS1のハプロタイプpullSegSiteHaplo()で完全なものが得られるため、簡単に組換え価が計算できます。具体的な計算はコードを見ていただくとして、実際に組換え価とposの値の差の関係をプロットしてみます。

founderPop <- runMacs(nInd = 1, nChr = 1, segSites = 20, species = "MAIZE")
pos <- getGenMap(founderPop)$pos
SP <- SimParam$new(founderPop)
# indexが1の個体を親として新しいPopオブジェクト作成
parent <- newPop(founderPop[1])
haplo_p <- pullSegSiteHaplo(parent)
haplo_p_sub <- haplo_p[, haplo_p[1, ] != haplo_p[2, ]] # 後代で分離するサイトのみ保持
# 親個体を自殖して1000個体を得る
s1 <- self(parent, nProgeny = 1000)
haplo_s1_sub <- pullSegSiteHaplo(s1)[, haplo_p[1, ] != haplo_p[2, ]]

# 各サイト間の遺伝距離(pos列の差分)と組換え価を計算する
n <- ncol(haplo_p_sub)
recomb_rate <- NULL
pos_diff <- NULL
for (i in 1:(n-1)) {
  for (j in (i+1):n) {
    match_to_haplo1 <- apply(haplo_s1_sub[, c(i, j)], 1, function(x) all(x == haplo_p_sub[1, c(i, j)]))
    match_to_haplo2 <- apply(haplo_s1_sub[, c(i, j)], 1, function(x) all(x == haplo_p_sub[2, c(i, j)]))
    recomb_rate <- c(recomb_rate, mean(!(match_to_haplo1 | match_to_haplo2)))
    pos_diff <- c(pos_diff, abs(pos[j] - pos[i]))
  }
}
plot(pos_diff, recomb_rate)
curve((1-exp(-2 * x))/2, from = min(pos_diff), to = max(pos_diff), add = TRUE, col = "blue")
curve(tanh(2 * x)/2, from = min(pos_diff), to = max(pos_diff), add = TRUE, col = "red")
legend("bottomright", c("Haldane", "Kosambi"), col = c("blue", "red"), lty =c(1, 1))

遺伝距離と組換え価の関係

するとこれらの関係は綺麗にKosambi mapping function上に乗ってきます。このことから、おそらくposはM単位の値であると考えられるかと思います。

あえてシミュレーションパラメータにSPという名前をつけない例

最後に、AlphaSimRでは独特の作法でSimParamオブジェクトにはSPという変数名をつけるということが推奨されています。ただ、これは一般的なプログラミングにおいては普通ではないので、なんとなく違和感を感じるという方のために、SPという変数名を使わない場合のコードを以下に置いておきます。

具体的には各関数にあるsimParam引数にSimParamオブジェクトを格納した変数を与えてやることで可能になります。以下の例では大文字のSP(推奨の変数名)の代わりに小文字のspという変数名を用いてみています。ちなみにこちらの例をもとにしています。

rm(list = ls()) # 先ほど定義したSPとその他もろもろをを消しておく
founderPop <- quickHaplo(nInd=1000, nChr=10, segSites=1000)
sp <- SimParam$new(founderPop)
sp$addTraitA(nQtlPerChr=1000)
sp$setSexes("yes_sys")
#pop <- newPop(founderPop) # SPという変数がない場合、これではerrorが発生
pop <- newPop(founderPop, simParam = sp)
genMean <- meanG(pop)
for(generation in 1:20){
  pop = selectCross(pop=pop, nFemale=500, nMale=25, use="gv", nCrosses=1000, simParam = sp)
  genMean <- c(genMean, meanG(pop))
}

# Examining the Results
plot(0:20, genMean, xlab="Generation", ylab="Mean Genetic Value", type="l")

http://cran.r-project.org/web/packages/AlphaSimR/vignettes/intro.htmlにある例と同等の図

参考文献

  1. Gaynor, R. Chris, Gregor Gorjanc, and John M. Hickey. 2021. AlphaSimR: an R package for breeding program simulations. G3 Gene|Genomes|Genetics 11(2):jkaa017. https://doi.org/10.1093/g3journal/jkaa017.
  2. Faux, A.-M., Gorjanc, G., Gaynor, R.C., Battagin, M., Edwards, S.M., Wilson, D.L., Hearne, S.J., Gonen, S. and Hickey, J.M. (2016), AlphaSim: Software for Breeding Program Simulation. The Plant Genome, 9: plantgenome2016.02.0013. https://doi.org/10.3835/plantgenome2016.02.0013
  3. http://cran.r-project.org/web/packages/AlphaSimR/vignettes/intro.html
  4. https://www.nature.com/scitable/knowledge/library/the-breeder-s-equation-24204828/
  5. https://en.wikipedia.org/wiki/Genetic_map_function

ランダムになんかロゴみたいなもの作る

ここ最近、輪郭形状解析の話をブログにまとめている中で、ランダムな形状の生成を試行していておもしろい模様生成の方法をたまたま見つけたのでその内容をメモがてら記事に残しておこうと思います。 具体的なコードと生成された模様は以下。ことの顛末としては、楕円フーリエ解析における係数からの輪郭再構築の過程を模してランダムな形状生成を試行錯誤していたのですが、そのとき、係数 a_n d_nを0にすると左右対称のパターンが生成され、なんかロゴマークみたいでおもしろいと思って記事にしました。関数のorder引数で重ね合わせる波の周波数の数を設定します。係数 b_n c_nを生成する乱数を正規分布ではなくて別の分布を使えばまた違ったパターンが見えるかもしれません。以下ではnumpy.random.normalnumpy.random.choiceを利用した場合を記載しています。

numpy.random.normal利用

import numpy as np
import matplotlib.pyplot as plt

def generate_random_contour(order=5):
    t = np.linspace(0, 1, num=1000)
    phi = (2 * np.pi * t) * np.arange(1, order+1).reshape((order, 1))
    
    b = np.random.normal(size=order)
    c = np.random.normal(size=order)
    
    x = np.dot(b, np.sin(phi))
    y = np.dot(c, np.cos(phi))

    return x, y

for order in [2, 3, 4, 5, 10, 20]:
    fig, axes = plt.subplots(10, 10, figsize=(15, 15))
    for ax in axes.ravel():
        x, y = generate_random_contour(order)
        ax.plot(x, y)
        ax.set_aspect(1)
        ax.axis('off')
    fig.suptitle(f"order={order}")
    plt.show()

normal, order=2

normal, order=3

normal, order=4

normal, order=5

normal, order=10

normal, order=20

numpy.random.choice利用

import numpy as np
import matplotlib.pyplot as plt

def generate_random_contour(order=5):
    t = np.linspace(0, 1, num=1000)
    phi = (2 * np.pi * t) * np.arange(1, order+1).reshape((order, 1))
    
    b = np.random.choice([0, 0.1, 0.2, 1], size=order, p=[0.4, 0.1, 0.2, 0.3])
    c = np.random.choice([0, 0.1, 0.2, 1], size=order, p=[0.4, 0.1, 0.2, 0.3])
    
    x = np.dot(b, np.sin(phi))
    y = np.dot(c, np.cos(phi))

    return x, y

for order in [2, 3, 4, 5, 10, 20]:
    fig, axes = plt.subplots(10, 10, figsize=(15, 15))
    for ax in axes.ravel():
        x, y = generate_random_contour(order)
        ax.plot(x, y)
        ax.set_aspect(1)
        ax.axis('off')
    fig.suptitle(f"order={order}")
    plt.show()

choice, order=2

choice, order=3

choice, order=4

choice, order=5

choice, order=10

choice, order=20