Python 字串匹配教學:捨棄整體初篩,改用字根比對判斷 Class Name ; tail_pattern = re.compile(r'(ProcessStep(?:_\d+)?|Process|Step(?:_\d+)?)$’)

加入好友
加入社群
Python 字串匹配教學:捨棄整體初篩,改用字根比對判斷 Class Name ; tail_pattern = re.compile(r'(ProcessStep(?:_d+)?|Process|Step(?:_d+)?)$') - 儲蓄保險王

在做 Class Name 的 OCR 模糊匹配時,很多人一開始都會用一個很直覺的方法:

  1. 先看整個字串誰最像
  2. 從幾個最像的候選中挑最佳答案

這種方法很自然,也很常見。
但當你實際處理一批有固定命名規則的類別名稱後,通常會慢慢發現一個問題:

整體字串相似度,常常會被共同長尾綴欺騙。

例如這類名稱:

  • PSmbiosCheckProcessStep
  • XgmiCheckProcessStep
  • MemoryCheckProcessStep

它們都有很長、很像的尾巴:

  • ProcessStep

所以如果你用完整字串去做初篩,演算法很可能先被尾巴吸引,而不是先看真正有辨識度的主體名稱。

這篇文章要講的核心觀念就是:

一旦知道 Class Name 存在長尾欺騙效應,就不該再用整體字串做初篩;應該直接改用字根(root)作為第一個核心判斷依據。


一、問題的本質:Class Name 並不是一般字串

如果字串是隨機的,直接比整串相似度通常沒什麼大問題。

Class Name 常常不是這樣。它通常長得像:

主體名稱 + 固定尾綴

例如:

PSmbiosCheck + ProcessStep
XgmiCheck + ProcessStep
MemoryCheck + ProcessStep

這時候真正有資訊量的部分,其實是前面的主體:

  • PSmbiosCheck
  • XgmiCheck
  • MemoryCheck

而後面的尾綴:

  • ProcessStep

往往只是型別、流程、步驟之類的命名慣例。

也就是說:

尾綴常常很像,但不代表它們是同一個 Class。


二、為什麼整體初篩是直覺,但也是粗淺做法?

一開始最直覺的流程通常是這樣:

長度粗篩
-> 從候選裡找整體最像的幾個
-> 再做更細的比對

這個想法的出發點很合理:

先用便宜的方法縮小範圍,再精查。

問題在於,這個「便宜的方法」如果用的是完整字串相似度,那它看到的是:

  • 主體名稱
  • 共同尾綴
  • OCR 錯字
  • 所有字符混在一起的總效果

而在 Class Name 命名高度規律的情況下,尾綴往往佔了太大比例。

所以整體初篩很容易出現這種情況:

  • 主體錯了
  • 但尾巴很像
  • 於是還是拿到高分

這就是所謂的:

被長尾巴欺騙


三、發現長尾欺騙後,正確方向不是補強第二步,而是移除第二步

這是一個很關鍵的設計轉折。

很多人發現整體比對不夠準之後,會這樣補:

長度粗篩
-> 整體初篩
-> 再加一層 root_score
-> 再比 overall_score

這樣看起來好像比較完整,但其實有一個根本問題:

如果第二步本身就可能把正確答案刷掉,那後面再強也沒用。

也就是說,root_score 不應該只是用來「補強」整體初篩,
而應該是:

直接取代整體初篩,成為第一個真正有判斷力的關卡。

所以更合理的流程應該改成:

長度粗篩
-> root_score
-> overall_score

這樣才不會在演算法一開始,就被共同尾綴帶偏。


四、什麼是 root?為什麼它才是核心?

這裡的 root,就是把常見尾綴剝掉後,剩下來真正有辨識度的主體名稱。

例如:

PSmbiosCheckProcessStep -> PSmbiosCheck
XgmiCheckProcessStep    -> XgmiCheck
MemoryCheckStep         -> MemoryCheck

這些 root 才是你真正想拿來判斷「是不是同一類東西」的部分。

所以我們的策略變成:

  1. 先把候選名稱尾綴拿掉
  2. OCR 字串也嘗試拿掉尾綴
  3. 先比較 root 像不像
  4. root 不像,直接淘汰
  5. root 像,才進一步比完整字串

這樣的邏輯非常符合資料本身的結構。


五、反覆剝除尾綴:核心工具函式

這段程式是整個方法的基礎:

def strip_tail(name):
    curr = name
    while tail_pattern.search(curr):
        curr = tail_pattern.sub('', curr)
    return curr

它的設計很簡單:

  • 只要字串尾端符合某些尾綴規則,就剝掉
  • 剝掉之後再檢查一次
  • 一直做到尾端不再符合規則為止

六、先定義可剝除的尾綴規則

下面是一個簡單示例,假設我們把這些當成可剝除尾綴:

  • ProcessStep
  • Process
  • Step
  • Step_數字
  • ProcessStep_數字

可以這樣寫:

import re
import difflib

tail_pattern = re.compile(r'(ProcessStep(?:_\d+)?|Process|Step(?:_\d+)?)$')
"""希望 strip_tail() 對大小寫更寬容: 
tail_pattern = re.compile(
    r'(ProcessStep(?:_\d+)?|Process|Step(?:_\d+)?)$',
    re.I
)
"""

搭配剛剛的函式:

def strip_tail(name):
    curr = name
    while tail_pattern.search(curr):
        curr = tail_pattern.sub('', curr)
    return curr
Python 字串匹配教學:捨棄整體初篩,改用字根比對判斷 Class Name ; tail_pattern = re.compile(r'(ProcessStep(?:_d+)?|Process|Step(?:_d+)?)$') - 儲蓄保險王

七、為什麼 OCR 錯字時,還需要 fallback?

假設 OCR 結果是:

PSmbiosCheckProcesStep

正確應該是:

PSmbiosCheckProcessStep

少了一個 s,導致尾綴 ProcesStep 不符合 regex 規則。
這時:

strip_tail("PSmbiosCheckProcesStep")

不會成功剝尾,結果還是原字串。

如果演算法只會「正常剝尾後再比 root」,那這種 OCR 錯字就會卡住。

所以我們需要一個 fallback 邏輯:

  • 如果 OCR 尾巴能正常剝掉,就直接拿 OCR 的 root 去比
  • 如果剝不掉,表示尾巴可能辨識壞了
  • 這時改拿 OCR 字串前面等長的前綴,去對比 candidate 的 root

因為在很多情況下,OCR 雖然把尾巴辨識錯,但前面的主體名稱仍然是對的。


八、最終流程:長度粗篩 → root_score → overall_score

這是我認為最適合這類問題的版本:

先做長度粗篩

對每個候選
    先剝除尾綴取得 c_root
    再取得 OCR  root  fallback prefix
    計算 root_score

    如果 root_score 不夠高
        直接淘汰

    如果 root_score 過關
        再計算完整字串的 overall_score

最後從過關者中選 overall_score 最高者

這裡的重點不是「完全不看完整字串」,而是:

完整字串只能作為第二層排序依據,不能再當第一層初篩依據。


九、完整 Python 範例

下面是一版結構清楚、適合教學與實作的程式碼:

import re
import difflib

# 定義可剝除的尾綴
tail_pattern = re.compile(r'(ProcessStep(?:_\d+)?|Process|Step(?:_\d+)?)$')


def strip_tail(name):
    """
    反覆剝除尾綴,直到尾端不再符合規則
    例如
        PSmbiosCheckProcessStep -> PSmbiosCheck
        MemoryCheckStep_2       -> MemoryCheck
    """
    curr = name
    while tail_pattern.search(curr):
        curr = tail_pattern.sub('', curr)
    return curr


def similarity(a, b):
    """
    回傳兩字串的相似度,範圍 0 ~ 1
    """
    return difflib.SequenceMatcher(None, a.lower(), b.lower()).ratio()


def find_best_class_name(ocr_name, candidates, max_len_diff=4):
    """
    從 candidates 中找出最可能對應 ocr_name 的 Class Name

    設計重點
    1. 不再做整體字串初篩
    2. 直接以 root_score 作為第一道核心判斷
    3. 只有 root 過關者才進一步比較完整字串
    """
    # Step 1: 長度粗
    valid_candidates = [
        c for c in candidates
        if abs(len(c) - len(ocr_name)) <= max_len_diff
    ]

    if not valid_candidates:
        return None

    best_match = None
    best_overall_score = 0

    # OCR 先做一次尾綴處理避免重複計算
    ocr_root_clean = strip_tail(ocr_name)

    for candidate in valid_candidates:
        # Step 2: 取得 candidate  root
        c_root = strip_tail(candidate)

        # Step 3: 決定 root 比對目標
        if ocr_root_clean != ocr_name:
            # OCR 尾巴正常可剝除直接拿 root 
            compare_target = ocr_root_clean
        else:
            # OCR 尾巴可能辨識錯改拿前綴做 fallback
            compare_target = ocr_name[:len(c_root)]

        # Step 4: 第一關先比 root
        root_score = similarity(c_root, compare_target)

        # root 短時門檻稍微放寬
        root_threshold = 0.8 if len(c_root) <= 5 else 0.85

        if root_score < root_threshold:
            continue

        # Step 5: 第二關root 過關才比完整字串
        overall_score = similarity(ocr_name, candidate)

        if overall_score > best_overall_score:
            best_overall_score = overall_score
            best_match = candidate

    return best_match

十、實際例子

假設 OCR 輸入:

ocr_name = "PSmbiosCheckProcesStep"

候選清單:

candidates = [
    "PSmbiosCheckProcessStep",
    "XgmiCheckProcessStep",
    "MemoryCheckProcessStep",
    "PSmbiosCheckStep",
]

第一步:長度粗篩

先排除長度差太多的候選,避免浪費計算。


第二步:對每個候選比 root

候選 1

PSmbiosCheckProcessStep

剝尾後:

c_root = "PSmbiosCheck"

OCR 因為 ProcesStep 拼錯,尾巴剝不掉,所以 fallback:

compare_target = ocr_name[:len("PSmbiosCheck")]
               = "PSmbiosCheck"

比對結果幾乎完全一致,root_score 很高,過關。


候選 2

XgmiCheckProcessStep

剝尾後:

c_root = "XgmiCheck"

fallback 前綴會接近:

PSmbiosC...

XgmiCheck 完全不是同個主體,因此 root_score 很低,直接淘汰。


第三步:只在 root 過關者中比 overall

這時完整字串比對才有意義,因為你已經先確認它們的主體是同一類了。


十一、這個版本比「整體初篩版」好在哪裡?

1. 不會被共同尾綴誤導

它不再讓完整字串相似度站在流程最前面。

2. 不會提早誤殺正解

正確候選不會因為整體初篩失準,在 root 比對前就被刷掉。

3. 更符合資料結構

因為你的 Class Name 本來就是:

root + suffix

那演算法就應該順著這個結構設計。

4. 對 OCR 尾巴錯字更有韌性

尾巴剝不掉時,還能用前綴 fallback 補救。


十二、這篇文章真正想傳達的設計觀念

這不只是「改一個函式」而已,背後其實是一個很重要的演算法思維:

不要讓低資訊量、且已知會誤導的特徵,站在核心判斷之前。

在這個問題裡:

  • 長度差:只是粗篩條件
  • 完整字串相似度:容易被共同尾綴干擾
  • root 相似度:才是真正高資訊量特徵

所以最合理的順序就是:

長度粗篩 -> root_score -> overall_score

而不是:

長度粗篩 -> 整體初篩 -> root_score -> overall_score

十三、結語

一開始用整體字串做初篩,是很自然的第一版做法。
但當你已經觀察到 Class Name 的命名規則具有共同長尾綴,而且這些尾綴會系統性影響相似度判斷時,就不應該再把整體初篩保留在流程前面。

更穩健、也更符合問題本質的方式是:

直接剔除整體初篩,改由字根比對作為第一個核心關卡。

而這個方法的關鍵,就是這段反覆剝除尾綴的函式:

def strip_tail(name):
    curr = name
    while tail_pattern.search(curr):
        curr = tail_pattern.sub('', curr)
    return curr

它讓你可以先抽出真正有辨識度的主體名稱,再用更合理的方式做模糊匹配。

推薦hahow線上學習python: https://igrape.net/30afN

加入好友
加入社群
Python 字串匹配教學:捨棄整體初篩,改用字根比對判斷 Class Name ; tail_pattern = re.compile(r'(ProcessStep(?:_d+)?|Process|Step(?:_d+)?)$') - 儲蓄保險王

儲蓄保險王

儲蓄險是板主最喜愛的儲蓄工具,最喜愛的投資理財工具則是ETF,最喜愛的省錢工具則是信用卡

You may also like...

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *