2026年8月31日 星期一

Python一下:用NumPy讀懂智慧養殖感測資料的數學基礎

《Python一下:從風土資料到智慧生活》第 31 篇

用NumPy讀懂智慧養殖感測資料的數學基礎

把一串水溫、pH與溶氧讀值,轉成可計算、可比較、也知道限制的資料判讀。

CH14 數學相關NumPy智慧養殖
學習目標
完成本篇後,你能建立與檢查NumPy陣列、使用索引與布林遮罩、理解向量化與broadcasting、沿正確axis計算統計量、處理NaN、計算移動平均與變化率、建立異常候選遮罩,並對相關係數與標準化結果作負責任解讀。

一、為什麼智慧養殖需要NumPy?

Python串列可以保存數值,但NumPy的 ndarray 能以一致資料型別組成一維或多維陣列,並進行向量化運算。它是pandas、SciPy、影像處理與許多機器學習工具的數值基礎。

資料問題NumPy工具仍需人工確認
每小時水溫的平均與波動mean、std、min、max探頭是否校正、取樣是否完整
設備離線造成缺值NaN與nan系列函式缺值原因與補傳紀錄
短期趨勢太雜亂移動平均、diff平滑是否掩蓋突發事件
某筆數值特別不同IQR、z-score候選設備錯誤還是真實場域變化
本篇資料為教學示範:數值與門檻不代表特定魚種、蝦種、池型或季節的正式養殖標準。任何巡查或控制策略,都應由場域養殖者與專業人員共同確認。

二、安裝與確認NumPy

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "NumPy"],
    check=True,
)

請在虛擬環境安裝,並記錄課程驗證版本。程式匯入時慣例使用 import numpy as np

三、第一個水溫陣列

import numpy as np


temperature = np.array(
    [27.2, 27.5, 28.0, 28.4, 29.1, 29.4],
    dtype=np.float64,
)

print("資料:", temperature)
print("形狀:", temperature.shape)
print("維度:", temperature.ndim)
print("筆數:", temperature.size)
print("型別:", temperature.dtype)

shape=(6,)代表包含6個元素的一維陣列。感測資料通常使用浮點型別;若需要缺值NaN,也必須使用可表示NaN的型別。

四、索引與切片:查看特定時段

print("第一筆:", temperature[0])
print("最後一筆:", temperature[-1])
print("第2到第4筆:", temperature[1:4])

morning = temperature[:3].copy()
morning[0] = 99.0

print("複製後修改:", morning)
print("原始資料不變:", temperature)

基本切片常得到原陣列的view,修改時可能影響原資料。當你需要獨立資料時明確使用 .copy()

五、向量化:一次換算全部感測值

temperature_f = temperature * 9 / 5 + 32
change_from_first = temperature - temperature[0]

print("華氏:", np.round(temperature_f, 1))
print("相對第一筆變化:", change_from_first)

不必逐筆寫for迴圈,算術運算會套用到每個元素。這種向量化表達通常更簡潔,也能利用NumPy底層實作。

六、Broadcasting:每個感測器套用不同校正值

raw = np.array([
    [27.2, 7.3, 5.8],
    [27.5, 7.4, 5.6],
    [28.0, 7.2, 5.4],
    [28.4, 7.1, 5.2],
])

# 三欄依序是水溫、pH、溶氧
offset = np.array([0.2, -0.1, 0.0])
calibrated = raw + offset

print(calibrated)

形狀為 (4,3) 的資料可和形狀 (3,) 的校正值broadcasting:每一列都套用三個欄位校正。若形狀無法相容,NumPy會報錯;不能靠猜測哪個方向被套用。

七、理解axis:沿時間還是沿指標?

column_mean = calibrated.mean(axis=0)
row_mean = calibrated.mean(axis=1)

print("各指標跨時間平均:", column_mean)
print("每個時間點跨欄平均:", row_mean)

axis=0壓縮列,得到每一欄跨時間的平均;axis=1壓縮欄。但水溫、pH、溶氧單位不同,直接算跨欄平均通常沒有合理物理意義。程式算得出來,不代表應該這樣算。

八、基本統計:中心與離散程度

summary = {
    "count": temperature.size,
    "mean": float(np.mean(temperature)),
    "median": float(np.median(temperature)),
    "minimum": float(np.min(temperature)),
    "maximum": float(np.max(temperature)),
    "std_population": float(np.std(temperature, ddof=0)),
    "std_sample": float(np.std(temperature, ddof=1)),
}

for name, value in summary.items():
    print(name, value)

ddof=0計算這批資料本身的母體標準差;ddof=1常用於由樣本估計母體變異。選哪一個取決於問題,不是固定答案。

九、NaN:未知不等於0

temperature_with_missing = np.array(
    [27.2, 27.5, np.nan, 28.4, 29.1, 29.4],
    dtype=float,
)

missing_mask = np.isnan(temperature_with_missing)
valid_count = np.count_nonzero(~missing_mask)

print("缺值位置:", missing_mask)
print("有效筆數:", valid_count)
print("忽略NaN平均:", np.nanmean(temperature_with_missing))
print("忽略NaN標準差:", np.nanstd(
    temperature_with_missing, ddof=1
))

np.mean()遇到NaN通常會得到NaN;np.nanmean()忽略NaN,但不能假裝缺值不存在。報告平均時要同時呈現有效筆數、缺值率與時間範圍。

十、資料值與品質標記分開

values = np.array([27.2, 27.5, 99.0, np.nan, 28.4])
quality = np.array([
    "valid", "valid", "invalid", "unknown", "valid"
])

usable_mask = (quality == "valid") & np.isfinite(values)
usable_values = values[usable_mask]

print("可用遮罩:", usable_mask)
print("可納入一般統計:", usable_values)
print("平均:", usable_values.mean())

invalid保留原始數值供追查,但不納入一般統計;unknown通常搭配NaN。只靠NaN無法表達「為何不可用」,品質標記仍要保留。

十一、布林遮罩:找出教材門檻外讀值

low, high = 26.0, 30.0
valid = np.isfinite(temperature_with_missing)
outside = valid & (
    (temperature_with_missing < low)
    | (temperature_with_missing > high)
)

print("門檻外位置:", np.flatnonzero(outside))
print("門檻外數值:", temperature_with_missing[outside])

26~30°C只是程式示範。門檻必須附上物種、成長階段、場域、季節、資料來源、核定者與版本日期,不能把網路上找到的一個數字直接寫進控制系統。

十二、差分:觀察相鄰時間的變化

change = np.diff(temperature)
rapid_change = np.abs(change) >= 0.6

print("相鄰變化:", change)
print("快速變化區段:", np.flatnonzero(rapid_change))

np.diff()的結果比原陣列少一筆,第0個差分代表原始第0筆到第1筆。若取樣間隔不固定,還要除以實際時間差,不能直接比較。

十三、移動平均:平滑短期波動

from numpy.lib.stride_tricks import sliding_window_view


def moving_average(values, window):
    values = np.asarray(values, dtype=float)
    if window < 1 or window > values.size:
        raise ValueError("window超出資料範圍")
    windows = sliding_window_view(values, window_shape=window)
    return np.nanmean(windows, axis=-1)


smoothed = moving_average(temperature_with_missing, 3)
print(smoothed)

3筆移動平均輸出會少2筆,時間應對齊視窗尾端或中心並清楚說明。平滑可以看趨勢,也可能遮住短暫但重要的急變;原始資料不可被覆蓋。

十四、IQR:找統計上的異常候選

candidate_data = np.array(
    [27.1, 27.2, 27.3, 27.4, 27.5, 31.8],
    dtype=float,
)

q1, q3 = np.quantile(candidate_data, [0.25, 0.75])
iqr = q3 - q1
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr
candidate_mask = (
    (candidate_data < lower) | (candidate_data > upper)
)

print("IQR範圍:", lower, upper)
print("異常候選:", candidate_data[candidate_mask])

IQR規則只是探索方法。資料有日夜週期、季節變化、餵食或換水事件時,整批混算可能把正常情境標成異常。

十五、z-score:先檢查樣本量與標準差

def z_scores(values):
    values = np.asarray(values, dtype=float)
    valid = values[np.isfinite(values)]
    if valid.size < 3:
        raise ValueError("有效資料不足")
    mean = valid.mean()
    std = valid.std(ddof=1)
    if np.isclose(std, 0.0):
        raise ValueError("標準差接近0,無法計算z-score")
    return (values - mean) / std


scores = z_scores(candidate_data)
print(np.round(scores, 2))

z-score適合近似穩定分布的探索,不應機械式把 |z|>3當成通用場域告警。樣本小、分布偏斜或有時間趨勢時尤其要小心。

十六、標準化:不同量尺可比較,但單位意義消失

sensor_matrix = np.array([
    [27.2, 7.3, 5.8],
    [27.5, 7.4, 5.6],
    [28.0, 7.2, 5.4],
    [28.4, 7.1, 5.2],
])

mean = sensor_matrix.mean(axis=0, keepdims=True)
std = sensor_matrix.std(axis=0, ddof=1, keepdims=True)
if np.any(np.isclose(std, 0.0)):
    raise ValueError("至少一欄沒有變化,無法標準化")

standardized = (sensor_matrix - mean) / std
print(np.round(standardized, 2))

keepdims=True保留形狀為 (1,3),可安全broadcast回原矩陣。標準化結果沒有°C、pH或mg/L的原單位,不能拿來直接對照養殖門檻。

十七、相關係數:相關不代表因果

temperature_pair = np.array(
    [27.2, 27.5, 28.0, 28.4, 29.1, np.nan]
)
dissolved_oxygen = np.array(
    [5.8, 5.6, 5.4, 5.2, 4.9, 5.0]
)

pair_mask = (
    np.isfinite(temperature_pair)
    & np.isfinite(dissolved_oxygen)
)
if np.count_nonzero(pair_mask) < 3:
    raise ValueError("成對有效資料不足")

correlation = np.corrcoef(
    temperature_pair[pair_mask],
    dissolved_oxygen[pair_mask],
)[0, 1]
print("Pearson相關係數:", correlation)

高度正相關或負相關都不證明因果。共同時間趨勢、日夜循環、曝氣、餵食與感測器位置都可能影響結果;要回到實驗設計及場域紀錄。

十八、綜合函式:產生可追溯摘要

def sensor_summary(values, quality):
    values = np.asarray(values, dtype=float)
    quality = np.asarray(quality)
    if values.shape != quality.shape:
        raise ValueError("values與quality形狀不同")

    usable = (quality == "valid") & np.isfinite(values)
    selected = values[usable]
    if selected.size == 0:
        return {
            "total": int(values.size),
            "valid": 0,
            "missing_or_invalid": int(values.size),
            "mean": None,
            "minimum": None,
            "maximum": None,
        }

    return {
        "total": int(values.size),
        "valid": int(selected.size),
        "missing_or_invalid": int(values.size - selected.size),
        "mean": float(selected.mean()),
        "minimum": float(selected.min()),
        "maximum": float(selected.max()),
    }


print(sensor_summary(values, quality))

轉成Python的int與float,較方便輸出JSON。摘要仍應附感測器代碼、單位、起訖時間、取樣間隔、品質規則版本及產生時間。

十九、不要犯的五個錯誤

錯誤後果改善
把缺值補成0平均與告警失真保留NaN及quality
混合不同單位直接平均得到沒有物理意義的數字依欄位與單位分別統計
忘記axis方向跨時間與跨指標算反每步印shape並用小資料驗證
平滑後覆蓋原始值失去稽核與急變資訊原始值與衍生值分開保存
把統計候選當成場域異常錯誤巡查或控制結合校正、事件及專業判讀

二十、課堂挑戰

挑戰A|基礎:建立24筆每小時水溫資料,計算平均、中位數、最大值、最小值、有效筆數與缺值率。
挑戰B|進階:比較3筆與5筆移動平均,標示輸出對應的時間點,說明哪一種保留急變較多。
挑戰C|USR場域:與養殖者共同挑選一次真實事件,將感測值、品質標記、設備校正、天候與人工巡查紀錄排在同一時間軸,解釋統計方法在哪裡可能誤判。

二十一、用AI協助檢查數學,而不是代替場域判斷

請擔任NumPy與智慧養殖資料分析助教。
資料包含水溫、pH、溶氧、時間與quality標記。
請先確認每個陣列的shape、dtype、axis、單位、缺值與取樣間隔,
再檢查平均、標準差、移動平均、IQR與相關係數程式。
不得把NaN自動補成0,也不得把統計異常直接稱為養殖異常。
請為每個結果列出:數學意義、資料前提、場域限制、
最小測試資料,以及需要養殖者確認的問題。

二十二、延伸閱讀

沒有留言:

張貼留言