FMIndexで高速に塩基配列を検索してみた

今回は以下のリポジトリにあるCライブラリを使用してみた記録です。

https://github.com/TravisWheelerLab/AvxWindowFmIndex

ライブラリのビルド

まずは以下の要領でライブラリのビルド〜インストールを行いました。パス等は各環境で適切な値に変えます。

(MacOSの場合)

$ git clone https://github.com/TravisWheelerLab/AvxWindowFmIndex.git
$ cd AvxWindowFmIndex/
$ git submodule init
$ git submodule update
$ brew install gcc
$ # /path/to/gcc, /prefix/path は適宜変える
$ cmake -DCMAKE_C_COMPILER=/path/to/gcc -DCMAKE_INSTALL_PREFIX=/prefix/path .
$ make
$ make install

プログラムの作成

READMEを参考にFMIndexによる配列検索を行うプログラムをCで書きました。以下、コード全体です。main.cとして保存しています。

fastas/dnaSequence.fasta というファイルから配列を読み込み、コマンドライン引数に与えられた配列を検索し、ヒットしたポジションをプリントします。

#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <string.h>
#include <AwFmIndex.h>

int main(int argc, char **argv) {
    char *indexFileSrc = "indexFiles/index.awfmi";
    char *fastaInputFileSrc = "fastas/dnaSequence.fasta";
    struct AwFmIndex *index;
    struct AwFmIndexConfiguration config = {
        .suffixArrayCompressionRatio = 8,
        .kmerLengthInSeedTable       = 12,
        .alphabetType                = AwFmAlphabetNucleotide,
        .keepSuffixArrayInMemory     = false,
        .storeOriginalSequence       = true
    };
    
    //enum AwFmReturnCode returnCode = awFmCreateIndexFromFasta(&index, &config, sequence, sequenceLength, indexFileSrc, true); origin
    enum AwFmReturnCode returnCode = awFmCreateIndexFromFasta(
        &index, &config, fastaInputFileSrc, indexFileSrc);
    if (awFmReturnCodeIsFailure(returnCode)) {
        printf("create index failed with return code %u\n", returnCode);
        exit(1);
    }
    uint64_t numQueries = argc-1; //first argument is the name of the executable, so skip it.
    struct AwFmKmerSearchList *searchList = awFmCreateKmerSearchList(numQueries);
    if (searchList == NULL) {
        printf("could not allocate memory for search list\n");
        exit(2);
    }
    
    searchList->count = numQueries;
    //initialize the queries in the search list
    for (size_t i = 0; i < numQueries; i++) {
        searchList->kmerSearchData[i].kmerLength = strlen(argv[i+1]);
        searchList->kmerSearchData[i].kmerString = argv[i+1];
    }
    
    //search for the queries
    int numThreads = 3;
    returnCode = awFmParallelSearchLocate(index, searchList, numThreads);
    if (awFmReturnCodeIsFailure(returnCode)) {
        printf("parallel search failed with return code %u\n", returnCode);
        exit(3);
    }
    
    //print all the hits.
    for (size_t kmerIndex = 0; kmerIndex < searchList->count; kmerIndex++) {
        const uint32_t numHitPositions = searchList->kmerSearchData[kmerIndex].count;
        uint64_t *positionList = searchList->kmerSearchData[kmerIndex].positionList;
      
        for (size_t i = 0; i < numHitPositions; i++) {
            printf("kmer at index %zu found at database position %zu.\n", kmerIndex, positionList[i]);
        }
    }
    
    //code cleanup
    awFmDeallocKmerSearchList(searchList);
    awFmDeallocIndex(index);
    
    exit(0);
}

結果の比較用にRustによる自前のFMIndex配列検索プログラムも書きました。以下にコード全体を示します。

#!/usr/bin/env rust-script
//!
//! ```cargo
//! [dependencies]
//! bio = "*"
//! 
//! ```
use std::path::Path;
use bio::io::fasta::{self, FastaRead};
use bio::alphabets;
use bio::data_structures::bwt::{bwt, less, Occ};
use bio::data_structures::fmindex::{BackwardSearchResult, FMIndex, FMIndexable};
use bio::data_structures::suffix_array::{suffix_array};
use std::collections::HashSet;

fn exact_search<P: AsRef<Path> + std::fmt::Debug>(ref_: P, que: P) {
    
    // read reference sequences and concatenate them
    let mut reader = fasta::Reader::from_file(ref_)
        .expect("Failed to open the reference fasta file.");
    let mut record = fasta::Record::new();

    let mut ref_seq = Vec::new();
    let mut ref_len = Vec::new();
    let mut ref_ids = HashSet::new();
    reader.read(&mut record).expect("Failed to parse the reference fasta record.");
    while !record.is_empty() {
        if record.check().is_err() {
            panic!("Rubbish record in the reference fasta");
        }
        ref_seq.extend_from_slice(record.seq());
        ref_seq.push(b'$');
        if ref_ids.contains(record.id()) {
            panic!("Record IDs are duplicated");
        }
        ref_ids.insert(record.id().to_string());
        ref_len.push((record.id().to_string(), record.seq().len()));
        reader.read(&mut record).expect("Failed to parse the reference fasta record.");
    }
    ref_seq.make_ascii_uppercase();
    //ref_seq.push(b'$');

    //  fmindex construction
    let alphabet = alphabets::Alphabet::new(&ref_seq);
    let sa = suffix_array(&ref_seq);
    let bwt = bwt(&ref_seq, &sa);
    let less = less(&bwt, &alphabet);
    let occ = Occ::new(&bwt, 128, &alphabet);
    let fm = FMIndex::new(&bwt, &less, &occ);

    // query fasta read
    let mut reader = fasta::Reader::from_file(que)
        .expect("Failed to open the query fasta file.");
    let mut record = fasta::Record::new();
    reader.read(&mut record).expect("Failed to parse the query fasta record.");
    while !record.is_empty() {
        if record.check().is_err() {
            panic!("Rubbish record in the query fasta");
        }
        let mut seq = record.seq().to_vec();
        seq.make_ascii_uppercase();
        if alphabet.is_word(&seq) {
            let interval = fm.backward_search(seq.iter());
            let mut positions = match interval {
                BackwardSearchResult::Complete(saint) => saint.occ(&sa),
                _ => Vec::new()
            };
            positions.sort();
            for i in positions {
                println!("{} found ad database position {}", record.id(), i);
            }
        }
        reader.read(&mut record)
            .expect("Failed to parse the query fasta record.");
    }

}

fn main() {
    exact_search("fastas/dnaSequence.fasta", "fastas/query.fasta");
}

実行結果

結局コンパイルオプションの適切な与え方はわかりませんでしたがコンパイルできたので適当なFASTAに対して 適当なクエリを検索してみました。以下、実行結果になります。ポジションは正確に出力できているかと思います。

ひとまず動いたので今回はここで終了です。SIMDあたりはもう少し勉強してみたいと思います。

$ # 入力ファイルのサイズ
$ seqkit stat fastas/dnaSequence.fasta
file                      format  type  num_seqs  sum_len  min_len  avg_len  max_len
fastas/dnaSequence.fasta  FASTA   DNA          5   50,000   10,000   10,000   10,000
$ # コンパイル(SIMDの有効化がよくわからなかった)
$ gcc main.c -I prefix/path/include -I AvxWindowFmIndex/lib/FastaVector/src -L prefix/path/lib/ -lawfmindex  -o main
$ export DYLD_LIBRARY_PATH=prefix/path/lib
$ # 実行結果
$ time ./main GCTCGCTGTTCTCACACATGAAACCTGACTGAATACGAT TATTGGAACATTGCACAACCTATAGACTGCGCGAAGGGCA GTGTCAACTTTAAGTTAGCCCATC
kmer at index 0 found at database position 18829.
kmer at index 1 found at database position 28108.
kmer at index 2 found at database position 49980.
./main GCTCGCTGTTCTCACACATGAAACCTGACTGAATACGAT  GTGTCAACTTTAAGTTAGCCCATC  2.90s user 0.19s system 99% cpu 3.110 total
$ # 実行結果(Rust版比較用プログラム。Indexファイル出力がないので早いのは当たり前。検索結果はちゃんと一致している。)
$ time ./main.rs
query1 found ad database position 18829
query2 found ad database position 28108
query3 found ad database position 49980
./main.rs  0.08s user 0.03s system 46% cpu 0.230 total

Rustで編集距離k以内の塩基配列を全列挙する

編集距離k以内の塩基配列("ATGC"のみ許可)を深さ優先探索で列挙するRustコードを書いたので備忘録として残しておきます。もうちょっとコードをキレイにしたかったのとテストコードは宿題にします。

use bio::alphabets;

#[derive(Clone, Copy)]
enum Operations {
    Match,
    Subst(u8),
    Del,
    Ins(u8),
}

struct NeighborSeq {
    origin_seq: Vec<u8>,
    stack: Vec<StateNS>,
    curr: Vec<Operations>,
    alphabet: alphabets::Alphabet,
    allow_outside_insersion: bool,
}

struct StateNS {
    is_preorder: bool,  // preorder(true) or postorder(false)
    is_root: bool, // length
    n: usize, // n used character
    k: usize,  // rest of distance
    ops: Operations,
}

impl NeighborSeq {
    fn new(origin_seq: &[u8], distance: usize, allow_outside_insersion: bool) -> Self {
        let stack = vec![StateNS{
            is_preorder: true,
            is_root: true,
            n: origin_seq.len(),
            k: distance,
            ops: Operations::Del, // ops is dummy
        }];
        NeighborSeq { 
            origin_seq: origin_seq.to_vec(),
            stack: stack,
            curr: Vec::new(),
            alphabet: alphabets::Alphabet::new(b"ATGC"),
            allow_outside_insersion: allow_outside_insersion,
        }
    }

    fn push_state(&mut self, n: usize, k: usize, ops: Operations) {
        let mut push = |nn, kk, ops| {
            self.stack.push(StateNS {
                is_preorder: false, is_root: false, n: nn, k: kk, ops: ops
            });
            self.stack.push(StateNS {
                is_preorder: true, is_root: false, n: nn, k: kk, ops: ops
            });
        };
        match ops {
            Operations::Match => {
                if n > 0 { push(n-1, k, ops) }
            },
            Operations::Subst(c) => {
                let l = self.origin_seq.len();
                let seq = &self.origin_seq;
                if n > 0 && k > 0 && c != seq[l - n] { push(n-1, k-1, ops) }
            }
            Operations::Del => {
                if n > 0 && k > 0 { push(n-1, k-1, ops) }
            },
            Operations::Ins(_) => {
                if k > 0 { push(n, k-1, ops) }
            },
        };
    }

    fn apply_ops(&self) -> Vec<u8> {
        let mut seq = Vec::new();
        let mut i = 0;
        for &ops in self.curr.iter() {
            match ops {
                Operations::Match => {
                    seq.push(self.origin_seq[i]);
                    i += 1;
                },
                Operations::Subst(c) => {
                    seq.push(c);
                    i += 1;
                },
                Operations::Del => {
                    i += 1;
                },
                Operations::Ins(c) => {
                    seq.push(c);
                }
            }
        }
        seq
    }
}

impl Iterator for NeighborSeq {
    type Item = Vec<u8>;

    fn next(&mut self) -> Option<Self::Item> {
        while !self.stack.is_empty() {
            let state = self.stack.pop().unwrap();
            if !state.is_root {
                if state.is_preorder {
                    self.curr.push(state.ops);
                } else {
                    self.curr.pop();
                    continue;
                }
            }
            // match
            self.push_state(state.n, state.k, Operations::Match);
            // del
            self.push_state(state.n, state.k, Operations::Del);

            let symbols = self.alphabet.symbols.clone();

            // subst
            for c in symbols.iter() {
                let c = c as u8;
                self.push_state(state.n, state.k, Operations::Subst(c));
            }
            // ins
            let is_outside = state.is_root || state.n == 0;
            if self.allow_outside_insersion || !is_outside {
                for c in symbols.iter() {
                    let c = c as u8;
                    self.push_state(state.n, state.k, Operations::Ins(c));
                }
            }

            if state.n == 0 {
                break;
            }
        }

        if self.stack.is_empty() {
            None
        } else {
            Some(self.apply_ops())
        }
    }
}

fn main() {
    for (i, x) in NeighborSeq::new(b"AA", 2, true).enumerate() {
        println!("{}: {}", i, std::str::from_utf8(&x).unwrap());
    }
    println!("---");
    for (i, x) in NeighborSeq::new(b"AA", 2, false).enumerate() {
        println!("{}: {}", i, std::str::from_utf8(&x).unwrap());
    }
}

簡単な輪郭(形)の解析をしてみました

輪郭(形)というと一見定量的に評価するのが難しそうな対象ですが、実は意外とできるということで簡単に実験してみました。

今回の方法の基盤となるのはフーリエ記述子という概念です。

解析対象は最近スーパーでニンジンを大量に買ったのでそれを使いました。

以下に解析手順の全体像を示します。

必要なライブラリのインポート

import numpy as np
import cv2
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
from pathlib import Path
from scipy.cluster.hierarchy import linkage, dendrogram
from sklearn.decomposition import PCA

import torchvision.transforms.functional as F

from torchvision.utils import make_grid
from torchvision.io import read_image
from torchvision.models.detection import maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights
from torchvision.utils import draw_segmentation_masks

解析対象の画像を読み込んで表示

輪郭をデータとして解析する上では画像から解析するのがおそらく最も便利です。 そこで、今回買ったニンジンをスマホで3パターン撮りました。 (ニンジンはこの後スタッフが美味しくいただきました。)

filenames = [
    str(Path('assets') / 'carrot1.jpeg'),
    str(Path('assets') / 'carrot2.jpeg'),
    str(Path('assets') / 'carrot3.jpeg'),
]
n_files = len(filenames)
images = [read_image(filename) for filename in filenames]

plt.imshow(F.to_pil_image(make_grid(images)))
plt.axis('off')
plt.show()

解析対象のマスク画像を取得

最初にやるべきことは解析対象のマスク画像(ここでは個体を識別しつつ、ニンジンが写っているピクセルで1、背景が写っているピクセルで0をとるような二値画像)の取得です。

今回はMask R-CNN(https://arxiv.org/abs/1703.06870)を用いました。PyTorchには学習済みの重みが存在するのでそちらを使うことにします。このモデルは様々な物体の検出に用いることができ、その中にはニンジンも含まれているので使っています。

結果を描画してみるとうまくニンジンと背景を個体別に識別できていることがわかります。

weights = MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT
transforms = weights.transforms()

transformed_images = [transforms(image) for image in images]

model = maskrcnn_resnet50_fpn_v2(weights=weights, progress=False)
model = model.eval()

# 予測
outputs = model(transformed_images)
masks = [output['masks'] for output in outputs]

proba_threshold = .7
score_threshold = .85

boolean_masks = [
    output['masks'][output['scores'] > score_threshold] > proba_threshold
    for output in outputs
]

# 描画
img_with_masks = [
    draw_segmentation_masks(image, boolean_mask.squeeze(1))
    for image, boolean_mask in zip(images, boolean_masks)
]
plt.imshow(F.to_pil_image(make_grid(img_with_masks)))
plt.axis('off')
plt.show()

輪郭情報の抽出

マスク画像ができたら輪郭の抽出は単純でOpenCVfindContours関数をかけるだけです。

ここで輪郭のデータ構造について軽く説明してみます。輪郭は数学的にはxy平面において媒介変数tを用いて

 x=f(t) \\ y=g(t)

といった形式で表現できます。(例えば円周であれば、f,gがそれぞれcos,sinになります。)

一方で画像解析で扱う輪郭を式で表現するのは難しいので、tについていい感じに標本化して得られる座標の集合として表します。

つまり、輪郭をなぞったときのピクセルの位置を順番に集めたものとして扱われます。

contours_list = []
for boolean_mask in boolean_masks:
    contours = []
    for mask in boolean_mask:
        mask_np = mask.squeeze(0).numpy().astype(np.uint8)
        con, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
        contours.append(con[0])
    contours_list.append(contours)

# 描画
plt.imshow(cv2.hconcat([
    cv2.drawContours(img.permute(1, 2, 0).numpy().copy(), contours, -1, (0, 255, 255), 30)
    for img, contours in zip(images, contours_list)
]))
plt.axis('off')
plt.show()

輪郭を重ね合わせるための情報を計算する

先ほど示した輪郭の数学的な表現形式を見てみるとtについて周期関数と見ることができ(一周すると元に戻ってくるので)、フーリエ級数展開が適用できます。

実際には離散データなので離散フーリエ変換を行うことになりますがこの時に得られる数列のことをフーリエ記述子と呼び、これが輪郭の情報をより規格的に示していることになります。

これにより、様々に複雑な形を示す輪郭を数値的に評価・比較できるようになるということです。

ここではものすごく端折って説明したので多少不正確なことがあるかもしれません。フーリエ記述子については他にもたくさんの記事が出ているので参考にしてみてください。

2つの輪郭から得られたこの数列の差を最小にするような変換(平行移動・回転・拡大縮小)を計算することで輪郭を重ねることができるようになります。 今回は写真に様々な角度でニンジンが写っているので、写っているニンジンの1つを参照としてそれに重ねるのではなく、ニンジンの形を模した逆三角形に重なるような変換を計算しています。

fit = cv2.ximgproc.createContourFitting()
transformed_contours_list = []
transformed_contours_without_scale_list = []
indv_imgs_list = []

# ニンジンの形を模した逆三角形に輪郭をフィッティングすることで整列された輪郭を得る
carrot_shape_model = np.array([[[0, 0]], [[100, 650]], [[200, 0]]], dtype=np.int32)
for img, contours in zip(images, contours_list):
    img_np = img.permute(1, 2, 0).numpy()
    n_contours = len(contours)

    transformed_contours = []
    transformed_contours_without_scale = []
    indv_imgs = []
    for i in range(n_contours):
        # 重ね合わせに必要な変換の計算
        alphaPhiST, dist = fit.estimateTransformation(contours[i], carrot_shape_model)
        dst = cv2.ximgproc.transformFD(contours[i], alphaPhiST, fdContour=False)
        alphaPhiST2 = alphaPhiST.copy()

        # 拡大縮小しない版も計算しておく
        alphaPhiST2[0, 2] = 1.0
        dst2 = cv2.ximgproc.transformFD(contours[i], alphaPhiST2, fdContour=False)
        transformed_contours.append(dst)
        transformed_contours_without_scale.append(dst2)

        # 個体ごとのサムネイル的なものを作成
        center = contours[i].mean(axis=0).squeeze(0)
        phi_degree = -alphaPhiST[0, 1] * 180 / np.pi
        s = alphaPhiST[0, 2]
        M = cv2.getRotationMatrix2D(center, phi_degree, s)
        M[:, 2] += alphaPhiST[0, 3:] +40
        img_conv = cv2.warpAffine(img_np, M, (280, 750))
        cv2.drawContours(img_conv, [(dst+40).astype(np.int32)], 0, (0, 255, 100), 5)
        indv_imgs.append(img_conv)
        
    transformed_contours_list.append(transformed_contours)
    transformed_contours_without_scale_list.append(transformed_contours_without_scale)
    indv_imgs_list.append(indv_imgs)

輪郭を重ね合わせて描画する

輪郭を重ね合わせるのに必要な変換が計算できたので実際に変換された輪郭を描画してみます。

こうすることで各個体の形の違いの程度といったものが見えてくるかと思います。

def contour_plot(contours, ax):

    colors = [[int(c*255) for c in col] for col in mpl.colormaps['Dark2'].colors]

    # 全ての輪郭含むようなBounding Boxを計算
    xmin, ymin = np.array([cnt.min(axis=(0, 1)) for cnt in contours]).min(axis=0)
    xmax, ymax = np.array([cnt.max(axis=(0, 1)) for cnt in contours]).max(axis=0)
    w = xmax - xmin
    h = ymax - ymin
    
    # Bounding boxの幅が200pxになり、余白20pxを含むようにする
    ratio = 200 / w 
    mergin = 20
    nw, nh = int(w*ratio+20), int(h*ratio+20)
    canvas = np.zeros((nh, nw, 3), dtype=np.uint8)
    canvas.fill(255)

    for j, cnt in enumerate(contours):
        # Bounding box 似合わせスケーリングされたcanvasに合うように輪郭もスケーリングしてから描画
        resized_cnt = (cnt - np.array([xmin, ymin])) * ratio + mergin 
        cv2.drawContours(canvas, [resized_cnt.astype(np.int32)], 0, colors[j%len(colors)], 2)
    ax.imshow(canvas)
    ax.axis('off')

fig, axs = plt.subplots(ncols=n_files, squeeze=False)
for i, contours in enumerate(transformed_contours_list):
    contour_plot(contours, axs[0, i])
    axs[0, i].set_ylim(800, 0)
    axs[0, i].set_title(filenames[i])

plt.show() 

ちなみに変換のうち、平行移動と回転だけ許し、拡大縮小をしないで重ね合わせると以下のようになります。 ニンジンでは横幅よりも長さにより違いがみられるのがわかるかと思います。

fig, axs = plt.subplots(ncols=n_files, squeeze=False)
for i, contours in enumerate(transformed_contours_without_scale_list):
    contour_plot(contours, axs[0, i])
    axs[0, i].set_ylim(800, 0)
    axs[0, i].set_title(filenames[i])

plt.show() 

輪郭の階層クラスタリング

輪郭を重ね合わせるときフーリエ記述子の差が最小になるようにしました。それでも、輪郭が異なればその差は0にはならず、この値の大きさがつまり形の違い度合いと考えることができます。

そこで、この値を用いて階層クラスタリングをしてみました。今回の3枚の写真に写っているニンジン全てを対象に階層クラスタリングをした結果を以下に示します。

オレンジ色の枝、緑色の枝、赤色の枝にはそれぞれ、短小、細長、中くらいのニンジンがクラスタリングされているように思います。 赤色の枝のクラスタはさらに円筒形または円錐形といった違いでクラスタができているように見えます。

クラスタリングができたからといって嬉しいことは特に思いつきませんが個人的にはおもしろいと思います。

contours = [cnt for _contours in contours_list for cnt in _contours]
indv_imgs = [indv for _indv_imgs in indv_imgs_list for indv in _indv_imgs]

# 輪郭間の距離(どれだけ似てないか、大きい方が似てない)を計算
dist_array = []
n_contours = len(contours)
for i in range(n_contours-1):
    for j in range(i+1, n_contours):
        _, dist = fit.estimateTransformation(contours[j], contours[i])
        dist_array.append(dist)

    
fig = plt.figure()
gs = gridspec.GridSpec(2, n_contours, wspace=0.1, height_ratios=[3, 1])
ax = fig.add_subplot(gs[0, :])
result = linkage(dist_array, method='ward')
d = dendrogram(result, ax=ax)
for i, l in enumerate(d['leaves']):
    ax = fig.add_subplot(gs[1, i])
    ax.imshow(indv_imgs[l])
    ax.axis('off')
    if i == 0:
        ax.tick_params(axis='x', rotation=55)
fig.align_labels()  
plt.show()

フーリエ記述子の主成分分析

フーリエ記述子そのままの値では輪郭の各周波数の成分の強度を表しているとは言えますがあまり解釈しやすい値とは言えないと思います。

そこでよく行われるのが主成分分析です。ここでも主成分分析を行い、第一主成分と第二主成分についてのプロットを書いてみました。

結果は第一主成分で69.5%、第二主成分で15.7%の形のバリエーションを説明できるようです。 また、第一主成分が小さいと細長型、大きいと短小型になるように見えます。 さらに、第二主成分は大きいと円筒型、小さいと円錐型になるように見えます。

fourier_descriptors = np.array([
    cv2.ximgproc.fourierDescriptor(cv2.ximgproc.contourSampling(tcnt, 1024))[1:].ravel()
    for _contours in transformed_contours_list for tcnt in _contours
])
offset_images = [
    OffsetImage(img, zoom=.05)
    for _indv_imgs in indv_imgs_list for img in _indv_imgs
]

pca = PCA(n_components=2)
result = pca.fit_transform(fourier_descriptors)
print(pca.explained_variance_ratio_)

fig, ax = plt.subplots()
artists = []
x, y = result[:, 0], result[:, 1]
for x0, y0, im in zip(x, y, offset_images):
    ab = AnnotationBbox(im, (x0, y0), xycoords='data', frameon=False)
    artists.append(ax.add_artist(ab))
ax.set_xlabel('PC1')
ax.set_ylabel('PC2')
ax.update_datalim(np.column_stack([x, y]))
ax.autoscale()
plt.show()
[0.69501555 0.15736549]

輪郭(形)の数値化はなんとなく難しそうなイメージがあったのですが、データ自体は意外と簡単に得られることがわかりました。

参考

葉面積測るアプリ作りました

これまでの記事で扱ってきた葉面積算出手順を簡単なWebアプリにしました。

https://simple-leaf-area.herokuapp.com

上記リンクにアクセスすると下図の右のようなページが開きます。

最初の画面

"Drag and Drop" と書いてあるエリアに葉面積を測りたい対象が写った写真をドラッグ&ドロップします。

ドラッグ&ドロップするところ

しばらく待つと葉っぱとして検出されたエリアが色付きで表示され、マウスオーバーするとその面積がピクセル数で表示されます。ついでにeccentricityも表示されます。

結果が表示されたところ

作ったはいいもののデプロイ先(HerokuのDyno)が今月28日から有料化するそうなので以降の対応は未定です。

bcftoolsのフィルタリングにおける条件式について

(本記事の内容はすべてをテストして調べたわけではないので、正しいことを保証できません。最終的には公式のマニュアルを参照されることをおすすめします。)

bcftoolsの多くのサブコマンドで使うことのできるフィルタリング用のオプションとして、 -i, --include-e, --exclude があります。

これらのオプションは1つの引数をとり、その引数は EXPRESSION です。 EXPRESSION とはプログラミング用語で「式」のことです。式の詳細な説明はWikipediaなどにありますが、ここでは「決まった法則に従い、VCFの各レコードに対しTRUEまたはFALSEを返す条件式」という理解で十分かと思います。より厳密には各レコードに1つのTRUE/FALSEの返り値に加え、各サンプルに対してもTRUE/FALSEが返されます

 

もしTRUEが返されたレコードについては、 -i, --include を指定したときはそのレコードが出力され、-e, --exclude の場合はその逆となります。

文法

EXPRESSION にはあらかじめ決められた文法に従った式を記述する必要があります。この文法は多くのプログラミング言語の文法と大差がないので普段プログラミングする方にとっては簡単ですが、そうでない場合はややこしいかもしれません。

定数の書き方

定数とは条件式を評価する上でレコードに関わらず常に一定の値を示す具体的な値で、おもに閾値や検索対象文字列などを表現するために使われる部分です。以下に表記例を示します。

  • 数字: 1, 1.0, 1e-4
  • 文字列: "String"
  • ファイル名: @file_name

算術演算子

算術演算子としては +,*,-,/ の4つが使用可能です。意味は左からそれぞれ、加算、乗算、減算、除算です。

比較演算子

比較演算子としては、 == (= でもよい), >, >=, <=, <, != が使えます。意味は左からそれぞれ、イコール、大なり、大なりイコール、小なりイコール、小なり、ノットイコールです。多くのプログラミング言語では = は代入を表しますが、bcftoolsではイコールとして扱われます。

正規表現

正規表現も使うことができ、~正規表現にマッチしたらTRUEを返す式を表現できます。逆は !~ です。デフォルトではCase sensitiveなので、例えば、 CHROM ~ "Chr1"Chr1 にはヒットしますが、CHR1 にはヒットしません。 Case insensitiveにするにはCHROM ~ "Chr1/i" とします。

かっこ

()が使えます。マニュアルには詳細がありませんでしたが、おそらくは計算順序を変えるために使用します。各演算子には優先順位があり、それを変えたい時に使うものだと思います。(演算子の優先順位についても特にマニュアルに言及はなさそうですが。)

論理演算子

論理演算子には &&, &, ||, | の4つがあります。&& 及び & は「かつ」、 || 及び | は「または」を表現します。

2つと1つの違いは片方または両方の被演算子がサンプルごとの結果を含むときに発生します。例えば FORMAT/DP>=10SMPL_MAX(FORMAT/AD)>=10 などです。逆に QUAL>=50MAX(INFO/AD)>10 などは1サイトにつき1つの結果のみを含み、サンプルに対する結果を含みません。要は FORMAT タグがらみかどうかです。

以下に簡単に各論理演算子の挙動をまとめました。siteにはそのサイト(レコード)がパスするかどうか(TRUE/FALSE)がスカラーの形で入っており、sampleにはサンプルごとのTRUE/FALSEがベクトル形式で入っているイメージで疑似コード的にまとめています。例えば QUAL>=50 の結果は site に、FORMAT/DP>=10 の結果は sample に入っているイメージです。(かなりややこしいのであまり自信はありません。)

site & sample

  • site = site and any(sample)
  • sample[i] = site and sample[i]

例:QUAL>=100 & FORMAT/DP>=30

  • QUALが100以上でかつ少なくとも1サンプルでDPが30以上の行でTRUE
  • QUALが100以上でかつ少なくともDPが30以上のサンプルでTRUE

site && sample

site & sample と同じ

sample & sample

  • site = any(sample and sample) ← 同一サンプルでTRUEであることが必要
  • sample[i] = sample[i] and sample[i] ← 同一サンプルでTRUEであることが必要

例:FORMAT/GQ>=50 & FORMAT/DP>=30

  • GQが50以上でかつDPが30以上のサンプルを少なくとも1つ含む行でTRUE
  • GQが50以上でかつDPが30以上のサンプルでTRUE

sample && sample

  • site = any(sample) and any(sample) ← いずれかのサンプルでTRUEであればOK
  • sample[i] = (any(sample) and any(sample)) and (sample[i] or sample[i]) ← そのサイトがパスする上で、サンプルについてはOR条件

個人的にはかなりややこしいので filter-S, --set-GTs と使わない方がいいと思っています。

例:FORMAT/GQ>=50 && FORMAT/DP>=30

  • GQが50以上のサンプルが1以上、かつ、DPが30以上のサンプルが1つ以上の行でTRUE
  • 上記条件が満たされれば、GQが50以上またはDPが30以上のサンプルはTRUE、満たさない場合は全サンプルでFALSE

site | sample

  • site = site or any(sample)
  • sample[i] = sample[i] ← もとのsiteがTRUE/FALSEに関係なくもとのsampleのコピー

例:QUAL>=100 | FORMAT/DP>=30

  • QUALが100以上または少なくとも1サンプルでDPが30以上の行でTRUE
  • DPが30以上のサンプルでTRUE

site || sample

  • site = site or any(sample)
  • sample[i] = site or sample[i] ← もとのサイトがTRUEなら全サンプルTRUE

例:QUAL>=100 || FORMAT/DP>=30

  • QUALが100以上または少なくとも1サンプルでDPが30以上の行でTRUE
  • QUALが100以上なら全サンプル、そうでないときはDPが30以上のサンプルでTRUE

sample | sample

  • site = any(sample) or any(sample)
  • sample[i] = sample[i] or sample[i] ← 同一サンプルに対してOR条件

例:FORMAT/GQ>=50 | FORMAT/DP>=30

  • GQが50以上のサンプルが1つ以上、または、DPが30以上のサンプルが1つ以上の行でTRUE
  • GQが50以上またはDPが30以上のサンプルでTRUE

sample || sample

  • site = any(sample) or any(sample)
  • sample[i] = any(sample[i]) or any(sample[i]) ← いずれかのサンプルでTRUEならそれがサンプル全体に波及

例:FORMAT/GQ>=50 || FORMAT/DP>=30

  • GQが50以上のサンプルが1つ以上、または、DPが30以上のサンプルが1つ以上の行でTRUE
  • GQが50以上のサンプルが1つ以上、または、DPが30以上のサンプルが1つ以上の行ならば全サンプルでTRUE、そうでなければ全サンプルでFALSE

条件を適用できる項目

CHROMなどの列、INFOタグ、FORMATタグについて条件を適用することができます。これらは文字列ではないのでダブルクオーテーションで囲む必要がありません。 INFO/FORMAT/ (FMT/) の部分は指している対象が明瞭であれば省略することができますが、個人的には省略しない方が後で意味を理解しやすいのでおすすめです。

INFO/DP or DP                                # INFO列のDPタグ
FORMAT/DV, FMT/DV, or DV                     # FORMAT列(サンプル列)のDVタグ(サンプル数分の要素を持つベクトルのイメージ)
FILTER, QUAL, ID, CHROM, POS, REF, ALT[0]   # ALT[0] はALT列のコンマ区切りで1番目に記載されているアレル

Flag型のデータに対する条件式

Flag型のデータはそのタグが存在するかどうかしかありません。このタイプのタグでよく見るのは INFO/INDELだと思います。このタグが存在することを条件にするなら、 INFO/INDEL=1 、存在しないことを条件とするならINFO/INDEL=0 といった具合に 0,1で表現します。

欠損値の表現

欠損値は文字列 "." で表現されます。VCFの中身を眺めてみてもたまに値が . になっているときがあると思います。一番よく見るのはID列とALT列だと思います。 (例:-e 'ALT="."' でALT列が欠損の行を除く)

また、INFO/DP>=20 といったような条件式において、INFO/DP の実際の値が欠損値 . だった場合、結果は必ずFALSEになる模様です。(つまり、-i 'INFO/DP>=20 とした場合、結果には INFO列が DP=.; となっている行は含まれず、 -e INFO/DP>=20 とした場合は DP=.; となっている行は除かれません。)

ただし遺伝子型( FORMAT/GT )については欠損値は1種類ではない(マニュアルでは ".|.", "./.", ".", "0|." の4つが挙げられています。)ので、これらすべてにマッチする簡便な方法として GT="mis" とすることで欠損値にマッチさせることができます。そのほかの方法としては正規表現を用いて GT~"\." とする方法があります。ここで \ が入っているのは .正規表現上特別な意味を持つのでそれをエスケープするためです。

遺伝子型に対する条件の便利な記法

遺伝子型(FORMAT/GT)については単なる文字列マッチや正規表現だけでなく、特別な意味を持つ文字列を使った条件式の立て方があり、以下に列記します。

GT="ref"  # REF型の半数体または二倍体(おそらく、0/0,0|0,0にマッチ)
GT="alt"  # alternate(おそらくGT="REF"でマッチしないかつ欠損でないものにマッチ。ヘテロにもマッチする。)
GT="mis"  # 欠損値にマッチ
GT="hom"  # ホモ接合体
GT="het"  # ヘテロ接合体
GT="hap"  # 半数体
GT="RR"   # REF-REFのホモ
GT="AA"   # ALT-ALTのホモ
GT="RA" or GT="AR"  # REF-ALTのヘテロ
GT="Aa" or GT="aA"  # ALT-ALTのヘテロ(おそらくALTが2つ以上のときに有効)
GT="R"    # REFの半数体
GT="A"    # ALTの半数体

TYPE変数

VCFのタグや列名として明示的に記されている訳ではないが使用できる変数として、 TYPEがあります。これはレコードのREFとALTを元にその変異がSNPなのかInDelなのかといった情報が格納されています。有効な値はindel,snp,mnp,ref,bnd,other,overlapの 7つのようです。refはおそらくREFのみでALTのデータがないレコードにマッチします。また、bndはこれもおそらくですが、リアレンジメントのレコードにマッチすると思います。

以下はリアレンジメントのレコード例

#CHROM  POS ID  REF ALT QUAL    FILTER  INFO
2   321681  .   G   G]17:198982]    .   .   .
17  198982  .   A   A]17:321681]    .   .   .

また、レコードによってはSNPかつInDelという場合もあります。そのため、例えば TYPE~"snp" とした場合は少なくともSNPであればマッチし、 TYPE="snp" とした場合はどのALTをみてもSNPでありそれ以外ではない場合にマッチします。

配列状のデータの要素へのアクセス

アレル頻度(INFO/AF)などタグによってはその中に複数のデータを内包している場合があります。(アレル頻度の場合は各レコードにアレルの数分のデータが含まれます。)また、FORMATタグにはサンプル分のデータが含まれます。さらにサンプルごとのアレル深度(FORMAT/AD)となるとサンプル x アレル数分のデータが含まれます。これらのデータについては何番目の要素に条件式を適用するかを明示的に指定することができます。 0-based なインデックスを[]内に入れることで表現できます。* を指定することですべての要素をとりだし、また、-を用いて範囲指定ができます。 :を使うことで何サンプル目の何番目のデータといったところまで表現できます。

また、複数のデータに対して条件式を適用すると、1レコードにつき複数のTRUEまたはFALSEが返されることになりますが、その場合は1つでもTRUEがあればそのレコードに対する返り値はTRUEになります。これはRに慣れている方であれば、any() 関数が暗黙のうちに呼ばれていると考えると理解しやすいかもしれません。(例:-i 'INFO/AF>0.1' とした場合は、INFO/AF内の複数データのうちいずれかが>0.1を満たしていればTRUE)

関数

関数として MAX, MIN, AVG, MEAN, MEDIAN, STDEV, SUM, STRLEN, ABS, COUNT が使えます。これらは、FORMATタグやINFOタグにおける複数データを含むものに対して適用することでそれらのデータの要約を取ることができます。また SMPL_MAX, SMPL_MIN, SMPL_AVG, SMPL_MEAN, SMPL_MEDIAN, SMPL_STDEV, SMPL_SUM という関数もあり、これらはサンプルごとに要約します。(結果サンプル数分の返り値を持ちます。)

binom test

binom() を使うことで二項検定ができるようです。おそらくですが、例えば'binom(FORMAT/AD)'はGTを参照して対応するアレルのADを引っ張ってきて、 p=0.5、x=AD_allele1、n=(AD_allele1+AD_allele2) の二項検定のp値を返すのだと思います。(この場合ホモの場合は必ずp値は1になると思います。)

必要に応じて計算される量

フィルタリングでよく使う統計量はVCF中に存在しなくても適宜計算されて利用することができます。利用できる変数は以下の通りです。

N_ALT, N_SAMPLES, AC, MAC, AF, MAF, AN, N_MISSING, F_MISSING, ILEN

N_PASSとF_PASS

これらは関数として用い、引数に EXPRESSION を指定することでそれぞれ条件式を満たすサンプル数、サンプル割合を計算してくれます。例えば N_PASS(FORMAT/GT="hom") はホモの数を返します。

その他注意点

マニュアルには

String comparisons and regular expressions are case-insensitive

とありますが、使ってみている感じcase-sensitiveであるように感じます。

文字列に,を含むときはそれらで分割される文字列に対してOR演算子で結合した形で一致判定が行われます。

EXPRESSION に含める文字は往々にしてShellに対しても特別な意味を持つので、 Shellに解釈されないように常にシングルクオーテーションで囲むことが推奨されます。

使用例

# QUALが100以上の行を抽出
bcftools view -i 'QUAL>=100' input.vcf
# FORMAT/DPが30以上かつFORMAT/GQが50以上を残し、条件を満たさないサンプルを欠損とする
bcftools filter -S. -i 'FORMAT/DP>=30 & FORMAT/GQ>=50' input.vcf
# ホモを示すサンプルが一つもない行を抽出
bcftools view -i 'N_PASS(GT="hom")=0' input.vcf
# MAFが0.05以上かつ欠損率が0.1未満の行を抽出
bcftools view -i 'MAF>=0.05 && F_MISSING<0.1' input.vcf
# MAFが0.05未満の行に対しFILTER列に"lowmaf"と記入
bcftools filter -s lowmaf -e 'MAF<0.05' input.vcf
# 1番目と2番目のサンプルがホモでかつ異なる遺伝子型を示す行を抽出
bcftools view -i '(GT[0]="RR" && GT[1]="AA") || GT[0]="AA" && GT[1]="RR")' input.vcf
# biallelicなSNPの行を抽出
bcftools view -i 'N_ALT=1 && TYPE="snp"' input.vcf
# PLの最小が0より大きいサンプルが1つでも存在する行を除く
bcftools view -e 'SMPL_MIN(FORMAT/PL)>0' input.vcf

(蛇足)

EXPRESSION を使わないフィルタリング

# biallelicなSNPの行を抽出
bcftools view -m2 -M2 -v snp input.vcf
# 近傍1kbにInDelを含まない行を抽出
bcftools filter -g 1000 input.vcf
# 指定した領域に含まれる行を抽出
bcftools view -t 1:1-10000 input.vcf
# Minor Allele Frequencyが0.05以上の行を抽出
bcftools view -q 0.05:minor input.vcf
# 全サンプルでコールされていない行を除く
bcftools view -U input.vcf

参考

被写体までの距離と画像から算出される面積の関係

画像上での被写体のサイズはカメラ-被写体間の距離に依存します。例えば被写体がカメラの近くに存在するときは大きく写ります。このことは、植物の非破壊によるフェノタイピングを行うため、例えば植物を上から取った画像から葉面積を算出するといった際に大きな影響を及ぼしうると思います。そこで、今回はカメラと被写体の距離が算出される面積にどう影響するかを知るためにごく簡単な実験をしてみました。

前提技術

画像から植物個体を検出する(セグメンテーションする)方法については例えば、https://axa.biopapyrus.jp/ia/opencv/threshold.htmlhttps://www.ncbi.nlm.nih.gov/pmc/articles/PMC5422159/ などがあります。また、画像中にサイズ既知の物体を配置して現実の単位(例えばcm2)で長さや面積等を算出する方法としては例えば https://pyimagesearch.com/2016/03/28/measuring-size-of-objects-in-an-image-with-opencv/ があります。

方法

植物の画像を撮るとき、個体毎に基準面(スケールを置く面)とカメラの位置を変えるのは面倒で固定されることがよくあると思うので今回もその条件で画像を取得しました。図にすると以下のような形になります。

画像取得の模式図

カメラの高さは大体ですが55cmくらいのところに設置しています。

被写体は簡単のため、竹ひごと画用紙、針金で作ったもの(写真)を使いました。葉の裏には針金を通して曲げられるようにしました。今回は写真のように完全に平らな状態と葉を上に30度ほど曲げた場合を検証しました。

被写体植物モデル

竹ひごの長さを5cm, 10cm, 15cm, 20cmと変えて被写体となる植物が個体ごとに葉を展開している高さが異なることを表現しました。このモデルの測定対象となる部分の実際の面積はスキャナで測り、約154cm2であることを確認しました。

先の模式図に記載した数式から分かる通り、実際に置いたスケールで計算される長さ(半径)はカメラ-被写体間の距離(d)に反比例しており、面積はその2乗に反比例することが予想されます。

サイズ既知の物体としてはQRコードを画面内に置くことにしました。このQRコードは1辺がちょうど5cmだったのでそれを利用します。

結果

早速結果です。

import cv2
import glob
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import re

detector = cv2.QRCodeDetector()
real_qr_edge_length = 5 # cm
records = []
images = []
for file_path in sorted(glob.glob("data/distance/IMG_1*")):
    img = cv2.imread(file_path)

    if img is None:
        print(f"cannot read {file_path}")
        continue

    # detect green rigion
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2Lab)
    _, a, _ = cv2.split(lab)
    a = cv2.GaussianBlur(a, (5, 5), 10)
    _, mask = cv2.threshold(a, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    
    
    leaf_area = cv2.countNonZero(mask)
    
    rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    
    # contours for detected region
    contours, hierarchy = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    cv2.drawContours(rgb, contours, -1, (255, 0,0), 20)

    ## QR code detection and scale calculation
    has_detected, decoded_info, points, straight_qrcode = detector.detectAndDecodeMulti(img)
    assert(len(decoded_info) == 1)
    decoded_info = decoded_info[0]
    points = points[0]
    

    if has_detected:
        qr_edge_lengths = []
        for i in range(len(points)):
            pt1 = points[i]
            pt2 = points[(i + 1) % len(points)]
            cv2.line(rgb, 
                     np.round(pt1).astype(int), 
                     np.round(pt2).astype(int),
                     color=(255, 0, 0), thickness=20)
            qr_edge_lengths.append(np.linalg.norm(pt1 - pt2))
    else:
        raise RuntimeError("Cannot detect QR code")
        
    cm_per_px = real_qr_edge_length / np.mean(qr_edge_lengths)
    height = int(re.match('\d+', decoded_info).group(0))
    bent = "bent" in decoded_info
    records.append({
        "plant height": height,  # 葉が展開されている高さ
        "bent": bent,            # 葉を曲げたかどうか
        "area(px)": leaf_area,   # 面積(単位 px)
        "area(cm^2)": leaf_area * cm_per_px * cm_per_px, # 面積(単位 cm^2)
        "QR size(px)": np.mean(qr_edge_lengths),         # QRコードの1辺の長さ(単位 px)
    })
    images.append((decoded_info, rgb))
fig, ax = plt.subplots(2, 4, figsize=(8, 6))
for ax_i, (info, img) in zip(ax.ravel(), images):
    ax_i.imshow(img)
    ax_i.set_title(info)
    ax_i.axis("off")
plt.show()

検出の様子

df = pd.DataFrame().from_records(records)  
df
plant height bent area(px) area(cm^2) QR size(px)
0 5 True 584740 178.169114 286.441010
1 10 True 739358 224.879806 286.696350
2 15 True 958015 291.909991 286.438690
3 20 True 1330709 405.963094 286.265015
4 5 False 584216 178.256865 286.242157
5 10 False 726138 221.730918 286.132019
6 15 False 938969 285.376689 286.804779
7 20 False 1244625 377.349463 287.155670
height_camera = 55
d = height_camera-df['plant height']
fig, ax = plt.subplots(figsize=(8, 6))
ax.grid()
scatter = ax.scatter(d, df['area(cm^2)'], c=df['bent'], s=100)
ax.legend(handles=scatter.legend_elements()[0], labels=['flat', 'bent'], fontsize=15)
ax.set_xticks([35, 40, 45, 50])
ax.set_xlabel("distance[d](cm)", fontsize=15)
ax.set_yticks([200, 300, 400])
ax.set_ylabel("area(cm^2)", fontsize=15)
ax.tick_params(labelsize=15)

plt.show()

カメラから被写体までの距離と算出される面積の関係

考察

なんとなくですが面積は被写体の距離の2乗に反比例していそうです。また、葉を曲げた場合とそうでない場合の差は被写体との距離が近いほど大きくなります。以上から、植物の高さや、葉の展開角度等の影響を最小限にとどめて葉面積を算出するためにはカメラと被写体の間の距離を十分保つことだ重要だと言えそうです。今回はカメラの位置を55cmとかなり低めに設定していますが、これをより高い位置にすることで、カメラと被写体との距離が十分に確保でき、植物の個体差による計測のブレを小さくすることができるかと思いました。

とはいえ被写体がロゼット型の植物だったりして植物体の高さに個体差があまりない場合はそこまで深刻に考えなくてもいいのかもしれません。

理想は画像取得毎に被写体までの距離を測って補正するのがいいと思っています。これは最近のiPhoneとかであれば、LiDAR搭載されていたりするので割と現実的だと思っているのですが如何せんiOSアプリを開発したことがないので誰か作ってくれないかなあと思っているところです。

参考

samtools faidxについて考えてみた

faidxと言えばsamtoolsに実装されている機能の1つで、FASTAやFASTQファイルのインデックスを作ったり部分配列を取り出したりするときに便利なプログラムです。今回はこれについて理解を深めるため簡単な実験をしてみました。

以下のようなテストデータを用意しました。

>a
AGGAC
CAGGG
GGGGG
GG
>abcd
AGGGCC
AGGGCA
NNAGGA

上記テストデータについて、samtools faidxを実行すると以下の内容を記録したfaidxファイルが生成されます。

a    17  3   5   6
abcd    18  30  6   7

これはタブ区切りの表となっていて各列については以下の通りです。(https://www.htslib.org/doc/faidx.htmlより引用) +

NAME 配列の名前 LENGTH 塩基単位で数えた配列の長さ
OFFSET FASTA/FASTQファイルにおける配列の最初の塩基のオフセット
LINEBASES 1行に記載されている塩基数
LINEWIDTH 改行コードを含む1行に記載されるバイト数
QUALOFFSET FASTQファイルにおいて最初のクオリティー値のオフセット

NAMEとLENGTHはわかりやすいかと思います。OFFSETというのはその配列がファイルの先頭から何バイト目から始まるかを示しており、先の例では配列aは0-indexで3バイト目から始まっていることがわかります。

実際0-indexで3バイト目から5バイト分のデータ、30バイト目から5バイト分をodコマンドで出力してみると以下のようになります。

$ od -c -j 3 -N 5 x.fa
0000003    A   G   G   A   C                                            
0000010
$ od -c -j 30 -N 5 x.fa
0000036    A   G   G   G   C
0000043

これらFAIDXファイルに記載される値が事前に与えられると配列の任意の部分配列がバイトで数えてどの位置にあるかがわかります。


(\textrm{start byte index}) = (\textrm{offset}) + (\textrm{line width}) \cdot \lfloor (\textrm{start seq index}) / (\textrm{line bases}) \rfloor + \{{(\textrm{start seq index}) \mod (\textrm{line bases})\}} 

(\textrm{end byte index}) = (\textrm{offset}) + (\textrm{line width})\cdot \lfloor (\textrm{end seq index}) / (\textrm{line bases}) \rfloor + \{{(\textrm{end seq index}) \mod (\textrm{line bases})\}}

それでは上記式に基づいてa:3-8abcd:14-18を得てみましょう。

samtoolsの場合は以下のようになります。

$ samtools faidx x.fa a:3-8
>a:3-8
GACCAG
$ samtools faidx x.fa abcd:14-18
>abcd:14-18
NAGGA

上記式で計算されるインデックスはそれぞれ(start, end) = (6, 12), (46, 51)でありこれは1-indexedなのでodコマンドのオプション引数はそれぞれ-j 5 -N 7-j 45 -N 6となり、以下のような結果となります。

$ od -c -j 5 -N 7 x.fa
0000005    G   A   C  \n   C   A   G                                    
0000014
$ od -c -j 45 -N 6 x.fa
0000055    N   A   G   G   A  \n                                        
0000063

ちゃんと欲しい部分配列が取れていると思います。LINEWIDTHが必要な理由としては上記結果でも\nとして見えていますが、改行コード等の見えない文字を加味してバイトインデックスを計算するためです。

そして部分配列のバイトインデックスがわかると何がいいかというと部分配列の抽出が高速にできるということです。

このことを実感するためにまず、インデックスを使用しない場合の部分配列の抽出法を考えてみます。おそらく最もナイーブな方法はファイルに記録された全塩基配列を文字列として読み込み各種プログラミング言語で提供されている部分文字列へのアクセス機能を利用することです。例えばC言語では以下のようになると思います。

void subseq_without_faidx(char filename[], size_t start[], size_t end[], size_t n, int debug) {
    char* seq2;
    size_t seq_size = 10000000000;
    seq2 = malloc(seq_size);
    if (seq2 == NULL) {
        fprintf(stderr, "Out of memmory\n");
        exit(8);
    }
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        fprintf(stderr, "Unable to open the file.\n");
        exit(8);
    }
    int ch;
    char line[1001];
    char seqid[1001];
    int j = 0;
    while (fgets(line, sizeof(line), fp) != NULL) {
        if (line[0] == '>') {
            for (int i = 1; i < strlen(line)-1; ++i) {
                seqid[i-1] = line[i];
            }
            seqid[strlen(line)-2] = '\0';
            continue;
        }
        for (size_t i = 0; i < sizeof(line); ++i) {
            if (line[i] == '\n') {
                continue;
            } else if (line[i] == '\0') {
                break;
            } else {
                seq2[j] = line[i];
                j++;
                if (j+1 > seq_size) {
                    fprintf(stderr, "Out of memmory\n");
                    fclose(fp);
                    free(seq2);
                    exit(8);
                }
            }
        }
    }
    seq2[j] = '\0';
    fclose(fp);
    char region[30];
    char seq[100000];
    for (int i = 0; i < n; ++i) {
        sprintf(region, "%s:%zu-%zu", seqid, start[i], end[i]);
        sprintf(seq, "%.*s", (int)(end[i]-start[i]+1), seq2+start[i]-1);
        
        if (debug) {
            printf("%s\n", region);
            printf("%s\n", seq);
        }
    }
    free(seq2);
}

上記はあくまで実装の1例で、配列数や配列長に暗黙の制限があるなど、いろいろとツッコミどころはあると思いますが、これでFASTAファイル名とスタートおよびエンドのインデックスを配列で与えればn個の部分配列を得ることができます。

ただ、この実装では一度ファイル全体を読み込んでから処理を行なっています。コンピュータによる計算において外部記憶装置(HDDやSSD)からメインメモリにデータを読み込むという作業はかなりコストがかかるので時間がかかりそうです。

一方でFAIDXを用いて部分配列を得る場合は以下のような実装になると思います。ここではhtslibを用いています。

void subseq_with_faidx(char filename[], size_t start[], size_t end[], size_t n, int debug) {
    faidx_t *fai = fai_load(filename);
    hts_pos_t seq_len;
    char region[30];
    for (int i = 0; i < n; ++i) {
        sprintf(region, "random:%zu-%zu", start[i], end[i]);
        char *seq = fai_fetch64(fai, region, &seq_len);
        if (debug) {
            printf("%s\n", region);
            printf("%s\n", seq);
        }
    }
    fai_destroy(fai);
}

上記関数を実行するために以下のようなmain関数を実装しました。

int main(int argc, char* argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Please specify fasta file.\n");
        exit(8);
    }

    if (argc == 2 || argv[2][0] == '0') {
        // debug mode
        size_t start[] = {1, 10, 100}, end[] = {10, 40, 200};

        clock_t start_clock, end_clock;

        printf("with faidx\n");
        subseq_with_faidx(argv[1], start, end, 3, 1);

        printf("without faidx\n");
        subseq_without_faidx(argv[1], start, end, 3, 1);
    } else {
        // ランダムにn回部分配列を取り出し時間を計測
        int n = atoi(argv[2]);
        srand((unsigned int)time(NULL));
        size_t start[n], end[n];
        for (int i = 0; i < n; ++i) {
            // 文字列長,部分文字列配列要素数は1000000000,100000なのでそれを超えないようにする
            start[i] = rand() % (1000000000 - 200000) + 1;
            end[i] = rand() % (99000) + start[i];
        }

        clock_t start_clock, end_clock;

        start_clock = clock();
        subseq_with_faidx(argv[1], start, end, n, 0);
        end_clock = clock();
        printf("clock (with faidx): %f\n", (double)(end_clock - start_clock) / CLOCKS_PER_SEC);

        start_clock = clock();
        subseq_without_faidx(argv[1], start, end, n, 0);
        end_clock = clock();
        printf("clock (without faidx): %f\n", (double)(end_clock - start_clock) / CLOCKS_PER_SEC);
    }
    return 0;
}

この関数を第3引数なしで実行してみると以下の出力が得られます。

$ ./a.out test.fa
with faidx
random:1-10
CTTGAAGGGC
random:10-40
CTCCTTTCGAACGTTAGGATATACGTCAATA
random:100-200
AATGATCCTATGCCGGACCCCCCGGGTTGCATCTATGTGACTCCCACAATCGTAGGTAGATGATCGGTGAAGCCCGCATGCCCATTACGATGCGAGTTCAG
without faidx
random:1-10
CTTGAAGGGC
random:10-40
CTCCTTTCGAACGTTAGGATATACGTCAATA
random:100-200
AATGATCCTATGCCGGACCCCCCGGGTTGCATCTATGTGACTCCCACAATCGTAGGTAGATGATCGGTGAAGCCCGCATGCCCATTACGATGCGAGTTCAG

無事FAIDX使用バージョンと不使用バージョンで結果が一致しているので、実際にどのくらいの時間がかかるのかを測ってみたいと思います。テスト用のファイルとして1Gbのランダム配列1つが格納されたFASTAファイルtest.faを用意しました。1000回、ランダムな位置についての部分配列を取得する操作を行なったときにかかる時間を測ってみます。

$ ./a.out test.fa 1000
clock (with faidx): 2.560794
clock (without faidx): 1.334881
$ ./a.out test.fa 1000
clock (with faidx): 0.155571
clock (without faidx): 1.341478

プログラムを2回連続して実行しています。面白いことに1回目はFAIDXを使用した方が遅いのに、2回目では格段にはやくなっています。これは1回目では.faiファイルが作成されていないので、 FAIDX使用バージョンもFASTAファイル全体を一旦読み込んでインデックスを作成する必要があるためです。2回目以降は作成済みのインデックスをもとに部分配列のみを対象にメモリにロードするので、ファイル全体をロードする場合より高速に処理ができています。

それでは先ほど1000回の部分配列抽出の速度を比較しましたが、100000回の場合も見てみましょう。今回はtest.fa.faiファイルが存在する状態で実行します。

$ ./a.out test.fa 100000
clock (with faidx): 13.303650
clock (without faidx): 1.738566

すると今度はインデックス存在下でもFAIDX使用バージョンの方が遅くなりました。これは100000と部分配列アクセス回数が増えたことで、ファイル全体を読み込む以上の量のデータ読み込みが発生したためと考えられます。このように大量の部分配列にアクセスする場合はいっそファイル全体をメインメモリに読み込んでメインメモリ上で何回もアクセスする方がコストが低くなります。

最後に使用したCプログラムの全体を載せて締めたいと思います。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <htslib/faidx.h>


void subseq_with_faidx(char filename[], size_t start[], size_t end[], size_t n, int debug) {
    faidx_t *fai = fai_load(filename);
    hts_pos_t seq_len;
    char region[30];
    for (int i = 0; i < n; ++i) {
        sprintf(region, "random:%zu-%zu", start[i], end[i]);
        char *seq = fai_fetch64(fai, region, &seq_len);
        if (debug) {
            printf("%s\n", region);
            printf("%s\n", seq);
        }
    }
    fai_destroy(fai);
}

void subseq_without_faidx(char filename[], size_t start[], size_t end[], size_t n, int debug) {
    char* seq2;
    size_t seq_size = 10000000000;
    seq2 = malloc(seq_size);
    if (seq2 == NULL) {
        fprintf(stderr, "Out of memmory\n");
        exit(8);
    }
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        fprintf(stderr, "Unable to open the file.\n");
        exit(8);
    }
    int ch;
    char line[1001];
    char seqid[1001];
    int j = 0;
    while (fgets(line, sizeof(line), fp) != NULL) {
        if (line[0] == '>') {
            for (int i = 1; i < strlen(line)-1; ++i) {
                seqid[i-1] = line[i];
            }
            seqid[strlen(line)-2] = '\0';
            continue;
        }
        for (size_t i = 0; i < sizeof(line); ++i) {
            if (line[i] == '\n') {
                continue;
            } else if (line[i] == '\0') {
                break;
            } else {
                seq2[j] = line[i];
                j++;
                if (j+1 > seq_size) {
                    fprintf(stderr, "Out of memmory\n");
                    fclose(fp);
                    free(seq2);
                    exit(8);
                }
            }
        }
    }
    seq2[j] = '\0';
    fclose(fp);
    char region[30];
    char seq[100000];
    for (int i = 0; i < n; ++i) {
        sprintf(region, "%s:%zu-%zu", seqid, start[i], end[i]);
        sprintf(seq, "%.*s", (int)(end[i]-start[i]+1), seq2+start[i]-1);
        
        if (debug) {
            printf("%s\n", region);
            printf("%s\n", seq);
        }
    }
    free(seq2);
}


int main(int argc, char* argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Please specify fasta file.\n");
        exit(8);
    }

    if (argc == 2 || argv[2][0] == '0') {
        // debug mode
        size_t start[] = {1, 10, 100}, end[] = {10, 40, 200};

        clock_t start_clock, end_clock;

        printf("with faidx\n");
        subseq_with_faidx(argv[1], start, end, 3, 1);

        printf("without faidx\n");
        subseq_without_faidx(argv[1], start, end, 3, 1);
    } else {
        // ランダムにn回部分配列を取り出し時間を計測
        int n = atoi(argv[2]);
        srand((unsigned int)time(NULL));
        size_t start[n], end[n];
        for (int i = 0; i < n; ++i) {
            // 文字列長,部分文字列配列要素数は1000000000,100000なのでそれを超えないようにする
            start[i] = rand() % (1000000000 - 200000) + 1;
            end[i] = rand() % (99000) + start[i];
        }

        clock_t start_clock, end_clock;

        start_clock = clock();
        subseq_with_faidx(argv[1], start, end, n, 0);
        end_clock = clock();
        printf("clock (with faidx): %f\n", (double)(end_clock - start_clock) / CLOCKS_PER_SEC);

        start_clock = clock();
        subseq_without_faidx(argv[1], start, end, n, 0);
        end_clock = clock();
        printf("clock (without faidx): %f\n", (double)(end_clock - start_clock) / CLOCKS_PER_SEC);
    }
    return 0;
}