200字
畢業專題小札記04:顧客價值具象化!RFM、CAI 指標與 Insyra 中的實作
2026-01-22
2026-02-03

這是整個專題最核心的部分。小小商家一點靈利用 RFM 與 CAI 模型,幫助商家將顧客分群,方便進行顧客管理與個人化行銷。這部份的計算,是使用我自己開發的資料分析函式庫——Insyra 來實作。

關於 Insyra 函式庫的更多資訊,可以參考我之前寫的這篇:別再 Pandas 啦🙄,用 Insyra 來做資料分析吧!

RFM

RFM 顧名思義就是 R 跟 F 跟 M,分別代表 RecencyFrequencyMonetary

  • R (Recency):最近一次消費

    • 意義:顧客最近一次購買距離現在有多久。

    • 代表:活躍度。越近代表活躍度越高。

  • F (Frequency):消費頻率

    • 意義:在一定期間內,顧客的購買次數。

    • 代表:忠誠度。次數越多,表示越忠誠。

  • M (Monetary):消費金額

    • 意義:在一定期間內,顧客的總消費金額。

    • 代表:貢獻度/價值。金額越高,貢獻越大。 

這三項指標的單位都不一樣,要把他們綜合起來衡量顧客價值,我們可以依照 RFM 為每一位顧客評分,將顧客分群,評分的方法很多種:

均分法

Insyra 的 mkt 套件目前採用的算法。

將顧客分為 N 群。分別將 R 由小排到大、F 和 M 由大排到小,每個指標各自分成幾個等級(例如 1-5 分),分數越高代表該指標表現越好。

例如分為五群時,前 20%(第 80 百分位數) 的顧客獲得 5 分、次 20% 獲得 4 分,以此類推。

常見的使用方式是將分數加總,比較顧客價值高低。分數越高代表價值越高。

我們也可以把三個指標的分數放在一起(如 111、323),當作三維空間中的座標,來將顧客依價值分群。針對不同群體的客戶提供更有針對性的服務和行銷訊息,提高客戶的回購率和忠誠度。

優點

  • 計算方式簡單,不用複雜統計。

缺點

  • 切越多份,每一群內的顧客異質性越低,但人數越少,樣本代表性越低。要切成幾等分,可能需要深入研究。

Bob Stone 給分法

這是一個比較複雜的給分方式,針對 R、F、M 每個指標的計分方式都不一樣。

構面

給分方式

權重

R

  • 最近 3 個月:24 分

  • 最近 3~6 個月:12 分

  • 最近 6~9 個月:6 分

  • 最近 9~12 個月:3 分

  • 最近 12 個月以上:0 分

F

購買次數 * 4

M

購買金額 * 10%(無條件進位,超過 9 分以 9 分計)

Bob Stone 給分機制最初是為銀行信用卡業務設計,因此最重視刷卡頻率,最不重視金額高低。

CAI

CAI 的全稱是 Customer Activity Index,顧客活躍性指標,聽說是由北商校長任立中發明。

它是一個根據購買期間的算術平均數(MLE)加權平均數(WMLE)計算的顧客價值指標,解決 RFM 沒考慮到顧客購買行為變化趨勢的限制。

CAI = (MLE - WMLE) / MLE * 100%
  • CAI < 0:顧客每次交易的時間間隔越來越長,購買行為逐漸沉寂

  • CAI = 0:顧客每次交易的時間間隔都差不多,購買行為穩定

  • CAI > 0:顧客每次交易的時間間隔越來越短,購買行為越來越活躍

MLE 的計算

MLE 好像是什麼「最大概似估計值(Maximum Likelihood Estimator)」的縮寫,但我看不懂那是啥,反正他推導出來就是購買期間的算術平均數。

MLE(購買期間的算術平均數)的計算非常簡單,是將每次交易間隔的時間加總,除以間隔數。

例如顧客 A 共進行 4 次交易(3 個間隔),第一次和第二次間隔 2 天、第二次和第三次間隔 10 天、第三次和第四次間隔 18 天,則:

MLE = (2 + 10 + 18) / 3 = 10

顧客 A 的 MLE 為 10。

WMLE 的計算

WMLE 是將每次間隔的時間依據發生時間遠近加權。第一個間隔權重為 1,第二個間隔權重為 2,第三個間隔權重為 3,以此類推。

以顧客 A 的例子來說:

WMLE = (2 * (1 / (1 + 2 + 3))) + (10 * (2 / (1 + 2 + 3))) + (18 * (3 / (1 + 2 + 3)))
     = 2 * 1/6 + 10 * 2/6 + 18 * 3/6
     = 2/6 + 20/6 + 54/6
     = 76/6
     ≈ 12.67

顧客 A 的 WMLE 約等於 12.67。


Insyra 的 mkt 套件包實作

我把 RFM 和 CAI 的計算邏輯寫成函數放在 Insyra 裡,整理成 mkt 行銷分析套件包。後端 Go 程式再引入 Insyra 來使用。這麼做有幾個好處:

  1. 在實作畢業專題的同時,也擴充 Insyra 資料分析函式庫的功能。

  2. 將行銷分析的計算方式做成可重用的函數,以後任何人只要引入 Insyra,就可以很方便地在別的程式裡呼叫使用。

當我完成畢業專題時,不但達到畢業門檻,也為社會做出貢獻,達到一舉數得的效果。

RFM 函數

接下來分享 Insyra 內部如何實作 RFM 分析的邏輯。

函數簽名

func RFM(dt insyra.IDataTable, rfmConfig RFMConfig) insyra.IDataTable

計算 RFM 的函數就叫做 RFM,非常直覺。

這個函數接收兩個參數:

  • dt:包含交易紀錄的資料表(DataTable)。

  • rfmConfig:RFM 分析的設定。

算完之後會回傳一個新的資料表,包含每個顧客的 RFM 分數。

設定結構(RFMConfig)

type RFMConfig struct {
    CustomerIDColIndex string    // 顧客ID欄位索引(A, B, C, ...)
    CustomerIDColName  string    // 顧客ID欄位名稱
    TradingDayColIndex string    // 交易日期欄位索引
    TradingDayColName  string    // 交易日期欄位名稱
    AmountColIndex     string    // 金額欄位索引
    AmountColName      string    // 金額欄位名稱
    NumGroups          uint      // 要分成幾群
    DateFormat         string    // 日期格式(例如 "YYYY-MM-DD")
    TimeScale          TimeScale // 時間尺度(hourly, daily, weekly, monthly, yearly)
}

這個設定結構讓函數更有彈性,可以適應不同格式的資料表。欄位可以用索引(A, B, C)或名稱來指定,如果兩者都提供則優先使用索引

計算邏輯拆解

RFM 函數的計算可以分成幾個步驟:

步驟一:資料整理

首先要從交易紀錄中,整理出每個顧客的關鍵資訊。

遍歷交易紀錄,蒐集每個顧客的資料

我們需要準備幾個 map 來儲存每個顧客的資料:

customerLastTradingDayMap := make(map[string]int64)           // 記錄每個顧客的最後交易日
customerTradingFrequencyMap := make(map[string]uint)          // 記錄每個顧客的交易頻率
customerTotalAmountMap := make(map[string]float64)            // 記錄每個顧客的總消費金額
customerTradingPeriodsMap := make(map[string]map[string]bool) // 記錄每個顧客在哪些時間段有交易

dt.AtomicDo(func(dt *insyra.DataTable) {
    numRows, _ := dt.Size()
    for i := range numRows {
        // 從資料表中取出這筆交易的資料
        dateValue := dt.GetElement(i, tradingDayColIndex)
        lastTradingDayStr := utils.ConvertToDateString(dateValue, goDateFormat)
        customerID := conv.ToString(dt.GetElement(i, customerIDColIndex))
        amount := conv.ParseF64(dt.GetElement(i, amountColIndex))
        
        // 跳過無效的資料
        if lastTradingDayStr == "" || customerID == "" {
            continue
        }
        
        // 解析日期字串,並轉換為 UTC 以避免時區問題
        lastTradingDay, err := time.Parse(goDateFormat, lastTradingDayStr)
        if err != nil {
            continue
        }
        lastTradingDayUTC := lastTradingDay.UTC()
        lastTradingDayUnix := lastTradingDayUTC.Unix()
        
        // 根據 TimeScale 格式化交易時間,用於計算不重複的交易頻率
        formattedPeriod := formatTradingPeriod(lastTradingDayUTC, timeScale)
        
        // 初始化該客戶的交易時間記錄
        if _, exists := customerTradingPeriodsMap[customerID]; !exists {
            customerTradingPeriodsMap[customerID] = make(map[string]bool)
        }
        
        // 如果該交易時間段尚未記錄,才累加交易頻率(F)
        if !customerTradingPeriodsMap[customerID][formattedPeriod] {
            customerTradingPeriodsMap[customerID][formattedPeriod] = true
            if _, exists := customerTradingFrequencyMap[customerID]; !exists {
                customerTradingFrequencyMap[customerID] = 0
            }
            customerTradingFrequencyMap[customerID]++
        }
        
        // 計算最後交易日(R)
        if existingLastTradingDay, exists := customerLastTradingDayMap[customerID]; !exists || lastTradingDayUnix > existingLastTradingDay {
            customerLastTradingDayMap[customerID] = lastTradingDayUnix
        }
        
        // 計算總交易金額(M)
        if _, exists := customerTotalAmountMap[customerID]; !exists {
            customerTotalAmountMap[customerID] = 0.0
        }
        customerTotalAmountMap[customerID] += amount
    }
})

這段程式碼逐列讀取交易紀錄,對每一筆交易取出顧客 ID、交易日期和金額,然後分別計算用於 R 的最後交易日,以及 F、M 兩個指標。

計算 R(Recency)

對每個顧客,取出他最後一次交易的日期,然後計算從他最後一次交易到現在過了多久。這裡要注意的是,時間差異的計算要根據使用者設定的時間尺度(例如是用「天」還是「小時」來計算)。

// 計算當前時間
now := time.Now().UTC()

// 對每個顧客計算距離最後交易日的時間差
for customerID, lastTradingDayUnix := range customerLastTradingDayMap {
    lastTradingDay := time.Unix(lastTradingDayUnix, 0).UTC()
    rValue := calculateTimeDifference(now, lastTradingDay, timeScale)
    customerRMap[customerID] = rValue
}

計算 F(Frequency)

計算每個顧客在分析期間內的交易次數。如果時間尺度設定為「天」,那同一天的多筆交易只能算一次;如果設定為「小時」,那同一小時的多筆交易算一次。

// 根據時間尺度格式化交易時間
formattedPeriod := formatTradingPeriod(lastTradingDayUTC, timeScale)

// 如果該時間段尚未記錄,才累加交易頻率
if !customerTradingPeriodsMap[customerID][formattedPeriod] {
    customerTradingPeriodsMap[customerID][formattedPeriod] = true
    customerTradingFrequencyMap[customerID]++
}

計算 M(Monetary)

把每個顧客所有交易的金額加總起來

// 累加每個顧客的總交易金額
customerTotalAmountMap[customerID] += amount

步驟二:計算分組門檻

當我們有了每個顧客的 R、F、M 數值後,要把他們分成幾個等級。我採用的是均分法:把所有顧客的數值排序,然後用百分位數來切分。

例如要分成 5 群(1-5 分),就計算第 20、40、60、80 百分位數作為門檻。

// 計算R分組門檻(注意:R值越小越好,所以是反向計分)
for i := 0; i < int(numGroups)-1; i++ {
    percentile := float64(i+1) / float64(numGroups) * 100
    rThresholds[i] = allRValues.Percentile(percentile)
}

// 計算F分組門檻(F值越大越好,所以是正向計分)
for i := 0; i < int(numGroups)-1; i++ {
    percentile := float64(i+1) / float64(numGroups) * 100
    fThresholds[i] = allTradingFrequencies.Percentile(percentile)
}

// 計算M分組門檻(M值越大越好,所以是正向計分)
for i := 0; i < int(numGroups)-1; i++ {
    percentile := float64(i+1) / float64(numGroups) * 100
    mThresholds[i] = allTotalAmounts.Percentile(percentile)
}

這裡要特別注意的是:R 分數是反向計算的。為什麼?因為 R 代表「距離上次交易的天數」,天數越小代表越活躍,應該得到越高的分數。而 F 和 M 則是正向計算,數值越大分數越高。

步驟三:為每個顧客評分

有了門檻值後,就可以為每個顧客計算分數了。

// 計算分數的邏輯
func calculateScore(value float64, thresholds []float64, ascending bool) int {
    score := 1
    for _, threshold := range thresholds {
        if ascending {
            // 正向計分:值越大分數越高
            if value > threshold {
                score++
            }
        } else {
            // 反向計分:值越小分數越高
            if value <= threshold {
                score++
            }
        }
    }
    return score
}

這個函數的邏輯很簡單:從最低分(1分)開始,每超過一個門檻就加一分。ascending 參數決定是正向還是反向計分。

步驟四:輸出結果

最後把每個顧客的 R、F、M 分數和總分整理成一個新的資料表。

// 創建RFM結果表
rfmTable := insyra.NewDataTable()

// 為每個顧客計算分數
for customerID := range customerLastTradingDayMap {
    rScore := calculateScore(float64(rValue), rThresholds, false)
    fScore := calculateScore(float64(fValue), fThresholds, true)
    mScore := calculateScore(mValue, mThresholds, true)
    rfmScore := rScore + fScore + mScore
    
    // 添加到結果表中
    rowData := map[string]any{
        "A": customerID,
        "B": rScore,
        "C": fScore,
        "D": mScore,
        "E": rfmScore,
    }
    rfmTable.AppendRowsByColIndex(rowData)
}

// 設定欄位名稱
rfmTable.SetColNameByIndex("A", "CustomerID")
rfmTable.SetColNameByIndex("B", "R_Score")
rfmTable.SetColNameByIndex("C", "F_Score")
rfmTable.SetColNameByIndex("D", "M_Score")
rfmTable.SetColNameByIndex("E", "RFM_Score")

如何使用 RFM 函數

理解了計算邏輯後,來看看實際上要怎麼使用這個函數。

我們使用 Kaggle 上的超市資料作為範例:https://www.kaggle.com/datasets/eslamessam2025/supermarket

並使用我自製的資料分析整合環境——Idensyra 示範。

準備資料

交易日期必須是字串,目前版本的 RFM 函數並不支援日期物件。這在大多數使用場景下(例如從 Excel 匯入資料)已經夠用。

資料長這樣:

這份資料所有的列如下:

Row ID, Order ID, Order Date, Ship Date, Ship Mode, Customer ID, Customer Name, Segment, Country, City, State, Postal Code, Region, Product ID, Category, Sub-Category, Product Name, Sales

設定參數

我們可以使用 Customer ID 作為顧客 ID 欄 ,Order Date 作為交易日期,Sales 作為金額欄。

日期格式則是 DD/MM/YYYY ,時間尺度設為一天。將顧客分為五組。

config := mkt.RFMConfig{
    CustomerIDColName: "Customer ID",  // 顧客ID欄位名稱
    TradingDayColName: "Order Date",  // 交易日期欄位名稱
    AmountColName: "Sales",          // 金額欄位名稱
    NumGroups: 5,                     // 分成5群(1-5分)
    DateFormat: "DD/MM/YYYY",         // 日期格式
    TimeScale: mkt.TimeScaleDaily,    // 以「天」為單位計算
}

執行分析

只需要呼叫 RFM 函數就能一鍵做完分析,非常輕鬆愉快。

rfmResult := mkt.RFM(csvFile, config)

查看結果

這是我們執行後得到的資料表,包含每個顧客的 R、F、M 分數,以及分數加總:

分數越高,代表顧客價值越高。你可以根據 RFM 總分或個別的 R、F、M 分數來進行顧客分群和個人化行銷。

CAI 函數

接下來分享 CAI(Customer Activity Index)的實作。

函數簽名

func CustomerActivityIndex(dt insyra.IDataTable, caiConfig CAIConfig) insyra.IDataTable

這個函數也可以用別名 CAI 來呼叫,功能完全相同。之所以使用全名 CustomerActivityIndex 是考量到 CAI 指標的知名度沒有 RFM 那麼高,用全稱比較能讓使用者知道它是幹嘛的。

這個函數接收兩個參數:

  • dt:包含交易紀錄的資料表(DataTable)。

  • caiConfig:CAI 分析的設定。

算完之後會回傳一個新的資料表,包含每個顧客的 MLE、WMLE 和 CAI 值。

重要限制:只有至少 4 筆交易紀錄的顧客才會被納入 CAI 計算。因為 CAI 需要至少 3 個交易間隔來判斷趨勢變化,而 3 個間隔需要 4 筆交易。

設定結構(CAIConfig)

type CAIConfig struct {
    CustomerIDColIndex string    // 顧客ID欄位索引(A, B, C, ...)
    CustomerIDColName  string    // 顧客ID欄位名稱
    TradingDayColIndex string    // 交易日期欄位索引
    TradingDayColName  string    // 交易日期欄位名稱
    DateFormat         string    // 日期格式(例如 "YYYY-MM-DD")
    TimeScale          TimeScale // 時間尺度(hourly, daily, weekly, monthly, yearly)
}

相較於 RFMConfig,CAIConfig 不需要金額欄位,因為 CAI 只關心交易時間的變化趨勢。

計算邏輯拆解

CAI 函數的計算分成以下步驟:

步驟一:蒐集每個顧客的交易時間

首先要從交易紀錄中,蒐集每個顧客的所有交易時間。

customerTransactionsTime := make(map[string][]time.Time)

dt.AtomicDo(func(dt *insyra.DataTable) {
    numRows, _ := dt.Size()

    for i := range numRows {
        customerID := conv.ToString(dt.GetElement(i, customerIDColIndex))
        tradingTimeStr := conv.ToString(dt.GetElement(i, tradingDayColIndex))

        if customerID  "" || tradingTimeStr  "" {
            continue
        }

        tradingTime, err := time.Parse(goDateFormat, tradingTimeStr)
        if err != nil {
            continue
        }

        customerTransactionsTime[customerID] = append(customerTransactionsTime[customerID], tradingTime)
    }
})

這段程式碼把每個顧客的所有交易時間都收集到一個陣列裡。

步驟二:加入最晚時間作為結束點

為了計算最後一個交易間隔,我們需要一個「結束時間點」。但是如果跟 RFM 一樣使用現在時間作為結束點,在分析過去資料時,CAI 的計算結果會被時間影響(最後一個間隔被拉大,導致分析出來的行為趨勢失真)。

因此我在這裡採用的方法是:找出所有顧客中最晚的交易時間,並將這個時間加到每個顧客的交易紀錄最後

// 找出所有顧客中最晚的交易時間
allLastTimes := []time.Time{}

for _, times := range customerTransactionsTime {
    lenTimes := len(times)

    if lenTimes < 3 {
        continue  // 加了最晚時間前,至少需三點
    }
    allLastTimes = append(allLastTimes, times[lenTimes-1])
}

algorithms.ParallelSortStableFunc(allLastTimes, func(a, b time.Time) int {
    return a.Compare(b)
})

latestTime := allLastTimes[len(allLastTimes)-1]

// 將最晚時間加到每個顧客的交易紀錄最後
for customerID, times := range customerTransactionsTime {
    customerTransactionsTime[customerID] = append(times, latestTime)
}

步驟三:計算交易間隔

有了每個顧客的交易時間序列後,就可以計算相鄰兩次交易之間的時間間隔。

for customerID, times := range customerTransactionsTime {
    if len(times) < 4 {
        continue  // 少於4次交易無法計算CAI
    }

    // 先排序交易時間
    insyra.SortTimes(times)

    // 計算間隔
    intervals := calculateIntervals(times, timeScale)
    customerTransactionsIntervals[customerID] = intervals
}

calculateIntervals 函數的實作:

func calculateIntervals(dateTimes []time.Time, scale TimeScale) []int64 {
    if len(dateTimes) < 2 {
        return nil
    }

    var intervals []int64
    for i := 1; i < len(dateTimes); i++ {
        interval := calculateTimeDifference(dateTimes[i], dateTimes[i-1], scale)

        if interval < 1 {
            // 同一時間尺度下的多次交易不計入間隔
            continue
        }
        intervals = append(intervals, interval)
    }
    return intervals
}

如果兩筆交易在同一個時間尺度內(例如同一天、同一小時),不重複計入間隔計算。

步驟四:計算 MLE(算術平均數)

MLE 就是把所有交易間隔加總後除以間隔數量。

for customerID, intervals := range customerTransactionsIntervals {
    if len(intervals) == 0 {
        continue
    }

    l := insyra.NewDataList(intervals)
    mle := l.Mean()

    customerMLEs[customerID] = mle
}

步驟五:計算 WMLE(加權平均數)

WMLE 是將每個間隔根據發生的先後順序加權。越晚發生的間隔權重越大。

for customerID, intervals := range customerTransactionsIntervals {
    if len(intervals) == 0 {
        continue
    }

    numIntervals := len(intervals)
    var weightDenominator float64
    
    // 計算權重分母(1 + 2 + 3 + ... + n)
    for i := 1; i <= numIntervals; i++ {
        weightDenominator += float64(i)
    }
   
    // 計算加權平均
    for i, interval := range intervals {
        weight := float64(i+1) / weightDenominator
        customerWMLEs[customerID] += weight * float64(interval)
    }
}

步驟六:計算 CAI 並輸出結果

最後依照公式 CAI = (MLE - WMLE) / MLE × 100% 計算 CAI 值。

resultDT := insyra.NewDataTable(
    insyra.NewDataList().SetName("CustomerID"),
    insyra.NewDataList().SetName("MLE"),
    insyra.NewDataList().SetName("WMLE"),
    insyra.NewDataList().SetName("CAI"),
)

for customerID, mle := range customerMLEs {
    wmle := customerWMLEs[customerID]
    cai := (mle - wmle) / mle * 100  // 百分比表示

    resultDT.AppendRowsByColName(map[string]any{
        "CustomerID": customerID,
        "MLE":        mle,
        "WMLE":       wmle,
        "CAI":        cai,
    })
}

如何使用 CAI 函數

使用方式和 RFM 類似,讓我們使用同一份資料來分析。

準備資料

同上,不再贅述。

設定參數

我們一樣把時間尺度設為天。

caiConfig := mkt.CAIConfig{
    CustomerIDColName: "Customer ID",
    TradingDayColName: "Order Date",
    DateFormat: "DD/MM/YYYY",
    TimeScale: mkt.TimeScaleDaily,
}

執行分析

馬上就把每一位顧客的 CAI 指標算出來了,等都不用等。

caiResult := mkt.CAI(csvFile, caiConfig)

查看結果

然後我們後續就可以根據 CAI 把顧客分成漸趨靜止穩定漸趨活躍,顧客的購買行為趨勢一目了然。


本篇文章使用的 Insyra 版本:v0.2.13

Insyra 官網:https://insyra.hazelnut-paradise.com

Insyra 說明文件:https://hazelnutparadise.github.io/insyra

Insyra GitHub 儲存庫:https://github.com/HazelnutParadise/insyra

評論