はじめに
TOF-SIMS(飛行時間型二次イオン質量分析)のデータ解析では、複数サンプルのスペクトル比較や、主成分分析(PCA)による試料グループの傾向把握が頻繁に発生します。
市販ソフトウェアは高機能ですが、「複数ファイルを一括でPCAしたい」「差分スペクトルをインタラクティブに操作したい」といった用途では、自前でスクリプトを書いたほうが速いことがあります。
この記事では、PHI社製TOF-SIMSが出力するASCファイルを読み込み、ブラウザで解析できるStreamlitアプリの実装を解説します。
- PHI ASCファイルの構造とパーサー実装
- m/z軸アライメントのビン化アルゴリズム
- TOF-SIMSに適した規格化・スケーリング(ポアソンスケーリング)
- StreamlitアプリへのpytestとGitHub Actions CIの後付け
コードはMITライセンスで公開済みです。サイドバーのチェックボックス1つ(またはURLに ?demo=1)で合成デモデータを読み込めるので、実測データがなくてもすぐ試せます。
GitHub: https://github.com/yharada520/tof-sims-analyzer
対象読者
- TOF-SIMSやXPS・AESなどの表面分析装置を使っており、データ処理を自動化したい方
- Pythonを少し書いたことがある、またはこれから始めたい方
- Streamlit製の解析アプリの実装パターンに興味がある方
PythonとPandasの基礎説明は省略します。
技術スタック
| 役割 | ライブラリ |
|---|---|
| Web UI | Streamlit 1.35+ |
| データ処理 | pandas 2.0+ / numpy 1.24+ |
| 統計解析 | scikit-learn 1.3+ / scipy 1.11+ |
| 可視化 | plotly 5.18+ / matplotlib 3.7+ |
Python 3.10〜3.12でCIテスト済みです。
1. PHI ASCファイルの構造
ULVAC-PHI製TOF-SIMSが出力するASCファイルのデータ本体は、次の3カラム形式です。
510, 0.7225411, 6
1022, 0.6806757, 2
1534, 0.6400596, 4
| カラム | 内容 |
|---|---|
| 1列目 | m/z インデックス(整数ビン番号) |
| 2列目 | 規格化強度(0〜1) |
| 3列目 | 生イオンカウント数 |
実装上の落とし穴が2つあります。
-
カンマが「区切り文字」ではなく各数値の末尾に付いている(
510,)。split()の結果をそのままfloat()に渡すとValueErrorになる - ヘッダ行数が装置・測定モードによって異なる。データ開始行を動的に検出する必要がある
実は開発中、1つ目の落とし穴を自分でも踏んでいました。ユニットテストを書いた際に float(parts[0]) が実ファイルで失敗することが発覚し、rstrip(",") を追加して修正しています。パーサーはテストを書いて初めて信用できる、という教訓です。
2. パーサーの実装
def detect_data_start(lines: list[str]) -> int:
"""数値データが始まる行インデックスを動的に検出する"""
for i, line in enumerate(lines):
parts = line.strip().split()
if len(parts) >= 2:
try:
float(parts[0].rstrip(",")) # 末尾カンマを除去してから変換
float(parts[1].rstrip(","))
return i
except ValueError:
continue
return 0
def parse_phi_asc(file_bytes: bytes, filename: str) -> Optional[pd.Series]:
text = file_bytes.decode("utf-8", errors="replace")
lines = text.splitlines()
start = detect_data_start(lines)
rows = []
for line in lines[start:]:
parts = line.strip().split()
if len(parts) < 2:
continue
try:
mz = float(parts[0].rstrip(","))
counts = float(parts[1].rstrip(","))
rows.append((mz, counts))
except ValueError:
continue
if not rows:
return None
df = pd.DataFrame(rows, columns=["mz", "counts"])
df = df.groupby("mz", as_index=False)["counts"].sum() # 重複m/z統合
series = df.set_index("mz")["counts"]
series.name = filename
return series
StreamlitのUploadedFileは .read() でバイト列を返すので、そのまま渡せます。
for f in uploaded_files:
series = parse_phi_asc(f.read(), f.name)
3. m/z軸のアライメント(ビン化)
複数ファイル比較の最大の障壁は、サンプル間でm/z軸が一致しないことです。測定条件や測定日が違うと、m/zの刻みがわずかにずれます。numpy.digitize によるビン化で共通軸に揃えます。
def bin_spectra(series_list: list[pd.Series], bin_width: float) -> pd.DataFrame:
# 全サンプルのm/z範囲を統合して共通グリッドを作成
all_mz = np.concatenate([s.index.values for s in series_list])
mz_min = np.floor(all_mz.min() / bin_width) * bin_width
mz_max = np.ceil(all_mz.max() / bin_width) * bin_width
common_mz = np.round(np.arange(mz_min, mz_max + bin_width, bin_width), 6)
binned = {}
for s in series_list:
bins = np.arange(mz_min - bin_width / 2, mz_max + bin_width, bin_width)
bin_idx = np.clip(np.digitize(s.index.values, bins) - 1,
0, len(common_mz) - 1)
agg = pd.Series(0.0, index=range(len(common_mz)))
for bi, cnt in zip(bin_idx, s.values):
agg[bi] += cnt
agg.index = common_mz
binned[s.name] = agg
return pd.DataFrame(binned).T.fillna(0) # 行=サンプル, 列=m/z
bin_width はUIから 0.1 / 0.5 / 1.0 Da を選択できます。高分解能データには 0.1、粗い比較には 1.0 という使い分けです。
これで行=サンプル、列=m/zの数値行列が得られ、以降のPCAとクラスタリングの入力になります。
4. 規格化とスケーリング
TOF-SIMSのスペクトル比較では、測定条件差による絶対強度の違いを除去する前処理が重要です。規格化とスケーリングを分離して実装しています。
TIC規格化(サンプル間の絶対値差を補正)
def apply_normalization(df: pd.DataFrame, method: str) -> pd.DataFrame:
if method == "TIC規格化":
row_sum = df.sum(axis=1).replace(0, np.nan)
return df.div(row_sum, axis=0).fillna(0)
return df.copy()
replace(0, np.nan) → fillna(0) はゼロ除算対策の定石です。全ゼロ行があってもNaNが伝播しません。
スケーリング(PCA前処理)
def apply_scaling(df: pd.DataFrame, method: str) -> pd.DataFrame:
if method == "平均中心化":
return df - df.mean(axis=0)
elif method == "オートスケーリング":
centered = df - df.mean(axis=0)
std = df.std(axis=0).replace(0, np.nan)
return centered.div(std, axis=1).fillna(0)
elif method == "ポアソンスケーリング":
# TOF-SIMSのショットノイズ(カウント統計)に対応
sqrt_mean = df.mean(axis=0).apply(np.sqrt).replace(0, np.nan)
centered = df - df.mean(axis=0)
return centered.div(sqrt_mean, axis=1).fillna(0)
return df.copy()
ポアソンスケーリングはTOF-SIMS特有の選択肢です。イオンカウントデータはポアソン分布に従うため、標準偏差の代わりに平均の平方根 sqrt(μ) で割ることで、低カウントm/zのノイズ過大評価を防ぎます。XPSやRamanのスペクトル前処理とは異なる点です。
| 目的 | 推奨の組み合わせ |
|---|---|
| サンプル間の組成比較 | TIC規格化 + 平均中心化 |
| 全m/zを等重みでPCA | TIC規格化 + オートスケーリング |
| カウント統計を考慮したPCA | TIC規格化 + ポアソンスケーリング |
5. PCAと差分スペクトル
Streamlitはウィジェット操作のたびにスクリプト全体を再実行するため、コールバック登録なしでインタラクティブなPCAが書けます。
X = df_processed.values
pca = PCA(n_components=min(n_comp, X.shape[0], X.shape[1]))
scores = pca.fit_transform(X)
loadings = pca.components_
explained = pca.explained_variance_ratio_ * 100
デモデータ(Si表面を模擬したグループAと有機汚染を模擬したグループB、各3サンプル)では、PC1(寄与率97.7%)で2グループが明確に分離します。
差分スペクトルは規格化後の行列の引き算だけです。
diff = df_norm.loc[sample_b].values - df_norm.loc[sample_a].values
Plotlyの棒グラフで正の差分(B > A)と負の差分(A > B)を色分けし、pd.Series(diff).abs().nlargest(20) で注目すべきm/zを自動抽出しています。良品・不良品の2群比較がそのまま実務で使えます。
下図はデモデータのグループA(Si表面)とグループB(有機汚染あり)の差分です。m/z 73(PDMS汚染マーカー)とm/z 15(CH3+)がB側で増加し、m/z 28(Si+)がA側で増加している——という表面汚染解析の典型的なパターンが差分と上位ピーク表から読み取れます。
6. StreamlitアプリにpytestとCIを後付けする
StreamlitアプリはモジュールレベルでUIコードが実行されるため、素直に import app するとテストが書けません。2段構えで対処しました。
conftest.pyでStreamlitをスタブに差し替え
# tests/conftest.py
import sys
from unittest.mock import MagicMock
def _ctx():
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=cm)
cm.__exit__ = MagicMock(return_value=False)
return cm
st = MagicMock()
st.sidebar = _ctx()
st.file_uploader.return_value = [] # 空 → st.stop()が呼ばれる
st.tabs.return_value = tuple(_ctx() for _ in range(4)) # withブロック対応
sys.modules["streamlit"] = st
st.sidebar や st.tabs() は with 文で使われるため、__enter__/__exit__ を持つモックにする必要があります。
execで関数定義だけを抽出
# tests/test_basic.py
import sys, types
from pathlib import Path
_app_module = types.ModuleType("app")
sys.modules["app"] = _app_module # 先に登録しておくのがポイント
try:
exec(compile(Path("app.py").read_text(encoding="utf-8"), "app.py", "exec"),
_app_module.__dict__)
except Exception:
pass # UI部分の実行エラーは無視(関数定義は取得済み)
app = _app_module
app.py を1行も書き換えずにテストを追加できるので、既存Streamlitアプリへの後付けに向いています。この方法でパーサー・ビン化・規格化・スケーリングを対象に22テストを実装し、GitHub Actions(Python 3.10/3.11/3.12マトリクス)で全件パスしています。
7. 公開リポジトリの構成
tof-sims-analyzer/
├── app.py # メインStreamlitアプリ
├── requirements.txt / pyproject.toml
├── .gitignore # 実測データ・生バイナリを完全除外
├── examples/
│ ├── generate_sample_data.py # 合成ダミーデータ生成
│ └── sample_*.csv # 正/負イオン・深さプロファイル等
├── legacy/ # Streamlit以前のデスクトップGUI版
├── tests/ # 22ユニットテスト
└── .github/workflows/ci.yml # CI (3.10/3.11/3.12)
実測データは .gitignore のパターン(日付フォルダ・.raw・.tdc など装置固有拡張子)で除外し、リポジトリには numpy で生成した合成データのみを含めています。分析装置系のコードを公開する際は、測定データの混入チェックを.gitignoreとファイル一覧の目視の両方で行うことをおすすめします。
まとめ
| 実装済み | 今後の予定 |
|---|---|
| ASCパーサー(ヘッダ自動検出) | 深さ方向プロファイル可視化 |
| m/zビン化アライメント | 2Dイオンイメージ表示 |
| TIC規格化・ポアソンスケーリング | ピーク自動同定 |
| PCA・HCA・差分スペクトル | Docker対応 |
| 22テスト + GitHub Actions CI | 英語UI |
TOF-SIMSのデータ処理をPythonで自動化したい方の参考になれば幸いです。MITライセンスなので自由に改変してください。フィードバック・IssueはGitHubからどうぞ。
GitHub: https://github.com/yharada520/tof-sims-analyzer
インストール手順や「Pythonなしでどう使うか」については、一般向けのNote記事も書いています。
Note: https://note.com/tukkidney/n/n6eeb4efc58b9?app_launch=false


