近紅外光譜,看穿食品成分

用真實的 Tecator 肉品光譜資料,學會兩個化學計量學核心方法:
PCA 探索結構、PLS 定量預測。

240肉品樣本
100波長 (850–1050 nm)
98.9%前 3 個 PC 變異
R²=0.96PLS 預測脂肪
為什麼是近紅外光

幾秒鐘、不破壞,就看穿成分

近紅外光(Near-Infrared, NIR,約 780–2500 nm)會被食品中 O–H、C–H、N–H 等化學鍵吸收。 量一條光譜,等於同時「問」了樣本裡的水分、脂肪、蛋白質——而且不需要試劑、不破壞樣本。

一次掃描幾秒鐘,可裝在生產線上即時量測。

非破壞

光照進去、反射或穿透出來,樣本完好如初。

但是…

一條光譜是上百個高度重疊的數字,必須靠化學計量學才能解讀。
本課兩步走: 先用 PCA(非監督)看資料裡有什麼結構,再用 PLS(監督)建立能預測含量的模型。
資料集

Tecator 肉品 NIR(公開資料)

Tecator Infratec 食品分析儀量測的 240 個肉品樣本,每個樣本有 100 個波長(850–1050 nm)的吸光值, 並以濕化學測得水分、脂肪、蛋白質的真值。屬公開資料(StatLib / OpenML id 505)。

Tecator 原始 NIR 光譜
240 條原始光譜,依脂肪含量上色。每條線是一個樣本、100 個數字——人眼無法直接判讀。
成分範圍 (%)平均本課用途
水分 moisture32.8 – 76.662.9備用目標
脂肪 fat0.9 – 58.518.5PLS 預測目標
蛋白質 protein8.8 – 23.217.8備用目標

前處理:SNV 散射校正

不同樣本的顆粒、量測距離會讓基線整條飄移。標準常態變量轉換(SNV)把每條光譜各自校正,凸顯真正的化學差異。

SNV 校正後的光譜
SNV 後基線對齊、脂肪造成的差異更清楚。
拿到資料

下載 Tecator,讀進 Python(.csv / .arff / .json)

本 repo 的 data/tecator.csv 已是整理好的乾淨版,pd.read_csv 即可用。 但你在公開資料庫遇到的原始檔,常是 .arff(Weka/OpenML 原生格式)或 .json。 這一節把「從哪裡下載、三種格式怎麼讀」一次講清楚。

① 本 repo(最快)

data/tecator.csv:240 列 × 100 波長欄 + moisture/fat/protein,直接 pd.read_csv 就能用。

② OpenML(線上)

資料 id 505、原生 .arff 格式、預設目標 fat。可用 sklearn 一行抓,或從網站下載。

③ StatLib(原始)

原始出處 lib.stat.cmu.edu/datasets/tecator,純文字檔,需自行切欄位,最費工。

下載:一行抓下來(sklearn → OpenML)

這正是 python/nir_utils.py 取資料的方式——第一次連網下載、快取成 CSV,之後離線可用。

from sklearn.datasets import fetch_openml

# OpenML 上的 Tecator(data id 505),回傳 pandas DataFrame
ds = fetch_openml("tecator", version=1, as_frame=True, parser="auto")
df = ds.frame
df.to_csv("tecator.csv", index=False)         # 存一份離線備份

# 欄位:absorbance_1..absorbance_100(吸光值)
#       principal_component_1..22(OpenML 附的 PCA 特徵,本課用不到)
#       moisture / fat / protein(濕化學真值,% w/w)
abs_cols = [c for c in df.columns if c.startswith("absorbance_")]
X = df[abs_cols].to_numpy(float)              # (240, 100) 光譜矩陣
y = df[["moisture", "fat", "protein"]].astype(float)
print(X.shape, list(y.columns))               # (240, 100) ['moisture','fat','protein']

想直接拿原始檔?OpenML 的 .arff 載點(也可在瀏覽器開 openml.org/d/505 點 Download):

curl -L -o tecator.arff https://www.openml.org/data/v1/download/52617/tecator.arff

讀取 .arff 檔(Weka/OpenML 原生格式)

ARFF(Attribute-Relation File Format)是純文字:開頭用 @relation@attribute 宣告每個欄位, @data 之後才是逗號分隔的數值列。Python 有兩種常見讀法:

法一 — SciPy(scipy 內建,免額外安裝)

from scipy.io import arff
import pandas as pd

data, meta = arff.loadarff("tecator.arff")
df = pd.DataFrame(data)
print(meta)                                   # 印出每個 @attribute 的型別

X = df[[c for c in df.columns if c.startswith("absorbance_")]].to_numpy(float)
y = df[["moisture", "fat", "protein"]].astype(float)

法二 — liac-arff(pip install liac-arff,保留完整 metadata)

import arff                                   # 套件名 liac-arff,import 名為 arff
import pandas as pd

with open("tecator.arff", encoding="utf-8") as f:
    dataset = arff.load(f)
cols = [name for name, attr_type in dataset["attributes"]]   # 欄位名稱清單
df = pd.DataFrame(dataset["data"], columns=cols)
print(df.shape)                               # (240, 125):100 吸光 + 22 PC + 3 目標
小提醒: 用 SciPy 讀 ARFF 時,數值欄位直接是 float,但名目/字串(nominal)欄位會讀成 b'...'(bytes), 需要 df[col] = df[col].str.decode("utf-8") 轉回字串。Tecator 全為連續數值,可略過此步。

讀取 .json 檔

有時資料以 JSON 交換(API 回傳,或自己轉存)。最通用的是「每列一筆物件」的 records 格式:

import pandas as pd, json

# 先把資料存成 JSON(orient="records":list of dict,最通用)
df.to_json("tecator.json", orient="records", indent=2, force_ascii=False)

# 讀回來:pandas 一行搞定
df = pd.read_json("tecator.json", orient="records")

# 或用標準函式庫 json,手動轉 DataFrame(適合巢狀/自訂結構)
with open("tecator.json", encoding="utf-8") as f:
    rows = json.load(f)                       # -> list[dict]
df = pd.DataFrame(rows)

X = df[[c for c in df.columns if c.startswith("absorbance_")]].to_numpy(float)
y = df[["moisture", "fat", "protein"]].astype(float)
▶ 進階:用 OpenML 的 JSON API 取得 metadata 與載點
import requests

# OpenML 的資料描述本身就是 JSON(含格式、預設目標、.arff 載點)
meta = requests.get("https://www.openml.org/api/v1/json/data/505").json()
d = meta["data_set_description"]
print(d["name"], d["format"], d["default_target_attribute"])   # Tecator ARFF fat
print(d["url"])           # -> .arff 下載連結,再用上面的 scipy / liac-arff 讀取
統一出口: 無論 CSV/ARFF/JSON,載入後都整理成兩塊——X(吸光值矩陣,240×100)與 y(moisture/fat/protein)。 接著就能直接餵給下面的 PCA/PLS 程式
方法一 · 非監督

PCA:把上百個波長壓成幾個方向

主成分分析(Principal Component Analysis)在資料最分散的方向上建立新座標軸: PC1 是變異最大的方向、PC2 與它垂直且次大……少數幾個主成分,就能描述幾乎全部的資訊。

🎬 教學影片(一)PCA · 約 4.4 分 · 在 YouTube 開啟
$X \approx T\,P^{\top}$  —  分數 $T$ 是樣本位置、負荷量 $P$ 是波長如何組成新方向

要保留幾個主成分?

PCA 陡坡圖
陡坡圖:PC1 佔 68.9%、前 2 個 97.3%、前 3 個 98.9%。100 個波長幾乎可濃縮成 3 個數字。

分數圖:樣本自己排好了

PCA 分數圖
每個點是一個肉品樣本,顏色為脂肪含量。PCA 從沒看過脂肪值,樣本卻沿 PC1 依脂肪排列;虛線橢圓為 95% 信賴範圍,圈外即可疑樣本。
核心觀念: PCA 是非監督的——它只看光譜、不用任何標準答案,卻能讓結構自己浮現。它擅長探索、分群、找異常,但不會直接告訴你「脂肪幾趴」。

負荷量回連化學

PCA 負荷量
PC1、PC2 的負荷量在 ~930 nm 有明顯起伏,正是脂肪 C–H 鍵的吸收帶——PCA 自己「重新發現」了脂肪訊號。
方法二 · 監督

PLS:從光譜「預測」脂肪含量

偏最小二乘回歸(Partial Least Squares)找出的方向,同時解釋光譜的變異、又與目標脂肪相關。 這就是它和 PCA 的關鍵差異:PCA 只看 X,PLS 一手抓 X、一手抓 y。

🎬 教學影片(二)PLS · 約 4.6 分 · 在 YouTube 開啟
$\max\ \operatorname{cov}(Xw,\ y)$  —  每個潛在變量(LV)最大化光譜與脂肪的共變異

該用幾個潛在變量?用交叉驗證

RMSECV vs 潛在變量
誤差在第 4 個潛在變量後幾乎不再下降;再加只是背雜訊(過度擬合)。故選在轉折點。

成果:預測 vs 真實

PLS 預測 vs 真實
校正集與測試集都緊貼 1:1 線。模型沒看過那 60 個測試樣本,仍 R²=0.964

R² = 0.96

解釋了測試集 96% 的變異。

RMSEP ≈ 2.8%

平均預測誤差(脂肪百分點)。
RPD 5.2
>3 堪用、>5 屬優秀。

模型在「看」哪裡?

PLS 回歸係數
回歸係數最大尖峰落在 ~928 nm(脂肪 C–H 帶)——模型盯著化學訊號預測,可解釋才可信
限制: 校正模型只在它見過的範圍內可靠。遇到新品種、新製程,必須重新驗證,不能直接外推。
怎麼算的

Python 程式(scikit-learn)

整套分析用 numpy / scikit-learn / scipy 完成,可重現。完整檔案見 python/ 目錄。

前處理 — SNV 散射校正(python/nir_utils.py

def snv(X):
    """Standard Normal Variate:逐樣本(逐列)散射校正。"""
    mu = X.mean(axis=1, keepdims=True)
    sd = X.std(axis=1, keepdims=True)
    return (X - mu) / sd

PCA — 主成分分析(python/02_pca.py

from sklearn.decomposition import PCA

Xp = snv(X)                          # 散射校正
pca = PCA(n_components=10).fit(Xp)
scores = pca.transform(Xp)           # 樣本在新軸上的位置 (T)
evr = pca.explained_variance_ratio_ * 100
# PC1 = 68.9% | 前2個 = 97.3% | 前3個 = 98.9%

PLS — 偏最小二乘回歸,預測脂肪(python/03_pls.py

from sklearn.cross_decomposition import PLSRegression
from sklearn.model_selection import train_test_split, cross_val_predict, KFold

Xp = savgol_d(snv(X), window=15, poly=2, deriv=1)        # SNV + 一階導數
Xtr, Xte, ytr, yte = train_test_split(Xp, y_fat,
                                       test_size=0.25, random_state=42)
# 用 10-fold 交叉驗證 + 轉折點規則挑潛在變量 -> 4 個
model = PLSRegression(n_components=4).fit(Xtr, ytr)
yte_p = model.predict(Xte).ravel()
# 測試集 R² = 0.964 | RMSEP = 2.79% | RPD = 5.2
▶ 如何在本機重跑
pip install numpy pandas scipy scikit-learn matplotlib
cd python
python 01_data_prep.py    # 抓資料 + 原始/SNV 光譜圖
python 02_pca.py          # 陡坡 / 分數 / 負荷量
python 03_pls.py          # 選 LV / 預測 vs 真實 / 係數
# 所有圖輸出到 python/figures/
不寫程式的版本

用 Orange Data Mining 親手拉一遍

課堂可用 Orange 的拖拉式 widget,不寫一行程式就重現上面的 PCA 與 PLS。 開啟 orange/nir_tecator_workflow.ows,把 File 指向 data/tecator.csv、設 fat 為 target 即可。

🎬 教學影片:用 Orange 視覺化程式設計做 PCA(五種糖 NIR)· 改編自 Yeh, T.-S. J. Chem. Educ. 2025, 102, 1428–1435 · 在 YouTube 開啟
          ┌─ Data Table        (檢視原始數字)
          │
 File ────┼─ PCA ──Transformed Data──▶ Scatter Plot    ← PCA 分支(探索;Color = fat)
(tecator) │
          ├─ PLS ──Learner──▶ Test & Score             ← PLS 分支(R² / RMSE)
          │     └─ Model ──▶ Predictions ──▶ Scatter Plot(預測 vs 真實)
          └────────────────(Data 同時接 Test&Score / Predictions)
對應概念Python 圖Orange widget
原始光譜fig01File → Data Table
主成分數量fig03PCA 視窗內變異曲線
分數圖(依脂肪上色)fig04PCA → Scatter Plot(Color=fat)
選 LV / 過度擬合fig06調 PLS Components + Test&Score
預測 vs 真實fig07Predictions → Scatter Plot
回歸係數 → 化學fig08PLS「Coefficients and Loadings」
總結

PCA 與 PLS,一張表看懂

面向PCAPLS
學習方式非監督(只看 X)監督(用 X 與 y)
目的探索結構、分群、找異常定量預測(如脂肪 %)
找方向的準則最大化 X 的變異最大化 X 與 y 的共變異
本課結果前 3 個 PC 抓 98.9% 變異測試集 R²=0.96、RPD 5.2
關鍵圖陡坡圖、分數圖、負荷量RMSECV、預測 vs 真實、係數
何時用先用它「看」資料確定要預測時用它建模
帶走兩句話: PCA 把上百個波長壓縮成看得懂的方向;PLS 一手抓光譜、一手抓答案,把幾秒的掃描變成可信的數字。