:零售銷售數(shù)據(jù)分析的完整流程與優(yōu)化技巧)
這個系列的024期按計劃應(yīng)該繼續(xù)深入NumPy的應(yīng)用。前面幾期我們把數(shù)組創(chuàng)建、索引切片、廣播機制這些基礎(chǔ)用法都過了一遍也做了幾個小案例從今天開始要進入真正的業(yè)務(wù)場景了。這次用的是我手頭整理的一份零售門店年度銷售匯總數(shù)據(jù)共9家門店、20個SKU、12個月2160條記錄。別小看這種二維表格日常數(shù)據(jù)分析里80%的場景都是這種結(jié)構(gòu)而NumPy做這類結(jié)構(gòu)化數(shù)據(jù)的讀取、清洗、聚合、統(tǒng)計計算本身就是一個高價值練習(xí)。本篇適合兩種人一是已經(jīng)會Python基礎(chǔ)語法、裝好了NumPy但不知道拿它做什么的二是用Pandas很久但從來沒搞明白底層邏輯的人。我盡量把所有分析動作都落在NumPy上一方面讓大家感受純數(shù)組操作怎么解決業(yè)務(wù)問題另一方面也方便后面切換到Pandas、Spark時能理解它們的底層原理。純NumPy做項目分析和直接用Pandas做差別其實很大哪個更適合什么場景我在第5節(jié)專門聊。1. 案例背景與數(shù)據(jù)設(shè)計說明1.1 這次案例要解決的問題這次模擬的場景是一家連鎖零售企業(yè)數(shù)據(jù)是從區(qū)域財務(wù)系統(tǒng)導(dǎo)出的月度銷售匯總表。注意這不是流水明細而是每個月、每個門店、每個商品已經(jīng)匯總好的記錄所以一行代表一個聚合單元。文件結(jié)構(gòu)很簡單門店編號、月份、商品編號、銷售額、銷量、訂單數(shù)。我給自己定的分析需求有6條全年銷售額和訂單量的月度趨勢判斷哪個月是旺季哪個月是淡季。各門店年度業(yè)績排序找出頭部和尾部門店。商品銷售結(jié)構(gòu)看哪些SKU貢獻了大頭收入。月度銷售額的集中程度和離散情況用分位數(shù)和標(biāo)準差衡量。異常月份和異常門店識別比如突然飆高的銷售額是不是數(shù)據(jù)錄錯了。銷量與銷售額的相關(guān)性判斷業(yè)績是走量驅(qū)動還是高客單驅(qū)動。這6個問題基本覆蓋了日常經(jīng)營分析里最常被老板問到的幾個方向。案例本身不復(fù)雜但貴在完整從原始CSV文件開始到清洗、聚合、指標(biāo)計算、結(jié)果輸出一條線走完。做完之后你能明顯感覺到NumPy在“處理規(guī)則明確的結(jié)構(gòu)化數(shù)據(jù)”這件事上是真的夠用且趁手的。1.2 模擬數(shù)據(jù)表結(jié)構(gòu)設(shè)計數(shù)據(jù)是我按真實業(yè)務(wù)習(xí)慣模擬生成的字段類型和含義如下字段名類型含義store_idint門店編號取值范圍1-9monthint月份取值范圍1-12product_idint商品SKU編號取值范圍101-120sales_amtfloat銷售額單位元volumefloat銷量單位件order_cntint訂單數(shù)CSV文件的前幾行長這樣store_id,month,product_id,sales_amt,volume,order_cnt 1,1,101,12800.50,52,86 1,1,102,8600.00,41,63 1,1,103,15200.80,73,92 1,1,104,5400.20,28,45 1,1,105,22100.00,96,120一共9個門店 × 12個月 × 20個商品正好2160行。銷售額我刻意制造了一部分異常值比如某個月某個商品銷售額離群用于演示異常檢測。如果你要復(fù)現(xiàn)直接把上面的結(jié)構(gòu)用循環(huán)生成就行核心是關(guān)注NumPy的處理手法數(shù)據(jù)本身是模擬的還是真實的不影響分析流程。1.3 為什么不用Pandas而用NumPy很多初學(xué)者看到這里會疑惑數(shù)據(jù)分析不是都用Pandas嗎確實Pandas處理這種表格會更順手一行g(shù)roupby就能完成分組求和。但我想說的是NumPy才是這一切的底層基礎(chǔ)。Pandas的Series和DataFrame內(nèi)部存儲結(jié)構(gòu)就是NumPy的ndarray。你把NumPy的數(shù)組操作搞明白了之后學(xué)Pandas基本就是學(xué)語法糖。反過來如果一上來就只會groupby遇到性能問題、內(nèi)存問題、或者需要自定義復(fù)雜算法的時候你會發(fā)現(xiàn)根本無從下手。因為這層抽象太舒服了反而把底層邏輯遮住了。另外從工程角度講NumPy更輕量。如果數(shù)據(jù)量在幾十萬行以下、分析邏輯以數(shù)學(xué)計算為主純NumPy方案的執(zhí)行速度和內(nèi)存占用都優(yōu)于引入Pandas。我實際項目里有個小習(xí)慣能用NumPy解決的就不開Pandas除非是要處理異構(gòu)表格、多級索引、時間序列重采樣這類復(fù)雜數(shù)據(jù)操作。Pandas負責(zé)數(shù)據(jù)整理NumPy負責(zé)數(shù)學(xué)計算兩者配合才是正解。本篇刻意不用Pandas是為了讓大家把基本功練扎實。2. 數(shù)據(jù)讀取與預(yù)處理NumPy的讀取細節(jié)2.1 用np.genfromtxt讀取CSVNumPy讀取CSV最常用的兩個函數(shù)是np.loadtxt和np.genfromtxt?,F(xiàn)實中我更推薦后者因為它對缺失值的容忍度更高不會一遇到空單元格就報錯。讀取代碼如下import numpy as np raw np.genfromtxt( sales_2024.csv, delimiter,, skip_header1, dtypefloat, encodingutf-8 ) print(raw.shape) # (2160, 6)這里解釋幾個容易踩坑的參數(shù)。skip_header1表示跳過第一行表頭delimiter,指定分隔符dtypefloat讓所有列都按浮點數(shù)讀入encodingutf-8解決中文環(huán)境下的編碼問題。如果不加encoding在Windows上很容易遇到UnicodeDecodeError這個我后面詳細說。讀取之后raw是一個二維ndarray我們可以按列拆開方便后續(xù)運算store_id raw[:, 0].astype(int) month raw[:, 1].astype(int) product_id raw[:, 2].astype(int) sales_amt raw[:, 3] volume raw[:, 4] order_cnt raw[:, 5].astype(int)這一步拆列看似簡單實際上隱藏了一個重要原則盡量把數(shù)據(jù)組織成“同類型的一維數(shù)組”因為NumPy對一維數(shù)組的廣播計算是最快、最不容易出錯的。后續(xù)所有分組、聚合操作都是基于這些一維數(shù)組的布爾掩碼來完成的。2.2 處理缺失值和臟數(shù)據(jù)現(xiàn)實數(shù)據(jù)不可能全是干凈的我這份模擬數(shù)據(jù)里有少量缺失值。第一步永遠是檢查缺失情況而不是上來就算均值# 按列統(tǒng)計缺失值數(shù)量 missing_count np.isnan(raw).sum(axis0) print(missing_count)如果發(fā)現(xiàn)某一列缺失值很多比如銷售額缺失了80條就要決定是刪除還是填充。刪除直接用布爾索引過濾填充則可以這樣寫# 用該列的中位數(shù)填充缺失值 col_median np.nanmedian(sales_amt) sales_amt np.where(np.isnan(sales_amt), col_median, sales_amt)np.where是三元判斷的向量化版本滿足條件的位置取中位數(shù)不滿足的位置保留原值。這里用中位數(shù)而不是均值是因為中位數(shù)對離群點更穩(wěn)健不會因為某個異常大值把填充值拉偏。臟數(shù)據(jù)也比你想的更常見。我檢查了一下這份數(shù)據(jù)里存在負銷售額、訂單數(shù)為0的記錄。碰到負銷售額第一反應(yīng)不是刪而是去確認是不是退貨沖銷。如果業(yè)務(wù)上明確負值就是異常那就過濾掉# 篩掉銷售額為負的記錄 valid_mask sales_amt 0 store_id store_id[valid_mask] month month[valid_mask] product_id product_id[valid_mask] sales_amt sales_amt[valid_mask] volume volume[valid_mask] order_cnt order_cnt[valid_mask]注意過濾時必須把所有關(guān)聯(lián)的一維數(shù)組一起過濾只過濾一個數(shù)組會導(dǎo)致數(shù)據(jù)錯位。這個錯誤我見過太多新手在犯。2.3 數(shù)據(jù)類型轉(zhuǎn)換和內(nèi)存控制拆列的時候我已經(jīng)做了類型轉(zhuǎn)換整數(shù)字段轉(zhuǎn)int金額字段保持float。為什么在意這個因為類型決定了內(nèi)存占用和計算精度。一個float64占8字節(jié)2160行 × 6列約100KB感覺不到差異。但如果是1000萬行數(shù)據(jù)那就是480MB直接讓內(nèi)存告急。實際生產(chǎn)環(huán)境里對于不需要極高精度的字段我會主動降精度raw32 raw.astype(np.float32) print(raw32.itemsize) # 4內(nèi)存直接減半但有一個原則中間計算過程我通常保留float64避免累積誤差只有存檔、落庫時才用float32。比如大數(shù)據(jù)量下求方差、相關(guān)性如果你在float32下計算結(jié)果可能和float64差出不少。這不是玄學(xué)是浮點數(shù)表示精度問題。3. 核心分析任務(wù)拆解與NumPy實現(xiàn)3.1 門店維度聚合用unique和bincount做分組先按門店匯總銷售額。最直觀的寫法是循環(huán)加布爾掩碼store_ids np.unique(store_id) store_sales [] for sid in store_ids: mask store_id sid store_sales.append(sales_amt[mask].sum()) store_sales np.array(store_sales) print(store_ids) print(store_sales)這段代碼邏輯清晰但有一個性能問題循環(huán)次數(shù)等于門店數(shù)。如果門店數(shù)只有9個無所謂但如果分組鍵有10萬個取值循環(huán)就會非常慢。NumPy里更快的方式是np.bincount# bincount的下標(biāo)從0開始所以門店1的數(shù)據(jù)落在結(jié)果下標(biāo)1的位置 store_sales_fast np.bincount(store_id, weightssales_amt) print(store_sales_fast)np.bincount第一個參數(shù)是分組鍵第二個參數(shù)是權(quán)重返回的結(jié)果就是每個分組的加權(quán)和。它比循環(huán)快一個數(shù)量級但要注意兩點分組鍵必須是非負整數(shù)結(jié)果長度等于分組鍵最大值加1。這也是我在代碼里把store_id轉(zhuǎn)成int的原因。有了各門店的銷售額排名就很簡單了sort_idx np.argsort(store_sales_fast)[::-1] for i in sort_idx: if i 0: continue # 跳過無意義的下標(biāo)0 print(f門店{i}: {store_sales_fast[i]:,.0f}元)市場份額也能順手算出來total_sales store_sales_fast.sum() share store_sales_fast / total_sales * 100這一步做完你已經(jīng)能看到頭部門店和尾部門店的差距了。3.2 時間趨勢分析按月份聚合門店聚合是橫向維度時間趨勢是縱向維度。按月份匯總可以這樣寫months np.arange(1, 13) monthly_sales np.array([ sales_amt[month m].sum() for m in months ]) monthly_orders np.array([ order_cnt[month m].sum() for m in months ])這個寫法很直白12個月的循環(huán)完全能接受。如果你想更優(yōu)雅一點可以用np.add.at做累加操作monthly_sales_v2 np.zeros(12) np.add.at(monthly_sales_v2, month - 1, sales_amt)np.add.at是不經(jīng)過中間數(shù)組的累加函數(shù)在多月份索引重復(fù)的情況下也能正確累加。它比循環(huán)快而且代碼更短。不過理解難度稍高所以我通常先講循環(huán)版本再說優(yōu)化版本。有了月度銷售額環(huán)比率是個很自然的指標(biāo)mo_growth np.zeros(12) mo_growth[1:] np.diff(monthly_sales) / monthly_sales[:-1] * 100 for m, g in enumerate(mo_growth, 1): print(f{m}月: {g:.2f}%)np.diff是求相鄰元素差結(jié)果長度比原數(shù)組少1。把第一個位置補0就能對齊月份下標(biāo)。注意用monthly_sales[:-1]做分母避免越界這個細節(jié)我在踩坑部分會再次強調(diào)。3.3 商品銷售結(jié)構(gòu)與異常值識別商品維度的聚合和門店完全一樣只是把分組鍵換成product_id。做完之后我習(xí)慣用np.argsort找出TOP5和BOTTOM5product_ids np.unique(product_id) product_sales np.bincount(product_id, weightssales_amt) top5_idx np.argsort(product_sales)[::-1][:5] bottom5_idx np.argsort(product_sales)[:5] print(銷售額TOP5商品, [(pid, product_sales[pid]) for pid in top5_idx if pid ! 0]) print(銷售額BOTTOM5商品, [(pid, product_sales[pid]) for pid in bottom5_idx])異常值識別我推薦用中位數(shù)絕對偏差法也就是MAD而不是普通Z-score。原因是Z-score的均值和標(biāo)準差本身會被異常值污染一個極端大值會把標(biāo)準差拉高導(dǎo)致真正的異常點被掩蓋。MAD的計算方式更穩(wěn)健med np.median(sales_amt) mad np.median(np.abs(sales_amt - med)) threshold 3 * 1.4826 * mad outliers np.abs(sales_amt - med) threshold print(異常記錄數(shù), outliers.sum()) print(異常銷售額, sales_amt[outliers])這里面的1.4826是個常數(shù)作用是讓MAD在正態(tài)分布下和標(biāo)準差等價。如果你不想記這個數(shù)也可以用四分位距法IQR效果類似q1, q3 np.percentile(sales_amt, [25, 75]) iqr q3 - q1 outliers_iqr (sales_amt q1 - 1.5 * iqr) | (sales_amt q3 1.5 * iqr)兩種方法選一種即可我實際項目里更常用MAD因為它在樣本量小的時候也穩(wěn)定。4. 統(tǒng)計指標(biāo)計算與業(yè)務(wù)解讀4.1 均值、標(biāo)準差、分位數(shù)描述性統(tǒng)計是分析報告的骨架。NumPy一次性可以算出這些指標(biāo)overall_mean sales_amt.mean() overall_std sales_amt.std() quartiles np.percentile(sales_amt, [25, 50, 75]) q1, q2, q3 quartiles iqr q3 - q1 print(f銷售額均值{overall_mean:,.2f}) print(f標(biāo)準差{overall_std:,.2f}) print(f四分位數(shù)Q1{q1:,.2f}, Q2{q2:,.2f}, Q3{q3:,.2f}) print(f四分位距{iqr:,.2f})均值反映平均水平標(biāo)準差反映波動程度分位數(shù)反映分布形態(tài)。比如Q1和Q3距離越遠說明中間50%數(shù)據(jù)的跨度越大。如果均值遠大于中位數(shù)說明存在右側(cè)長尾也就是少數(shù)高銷售額記錄拉高了整體水平。光看整體不夠我還想看每個門店的月度波動。把數(shù)據(jù)組織成二維矩陣門店為行、月份為列store_month_matrix np.zeros((9, 12)) for sid in store_ids: for m in range(1, 13): store_month_matrix[sid - 1, m - 1] sales_amt[(store_id sid) (month m)].sum()然后按行計算變異系數(shù)CV即標(biāo)準差除以均值store_cv store_month_matrix.std(axis1) / store_month_matrix.mean(axis1)變異系數(shù)是個無量綱指標(biāo)適合比較不同門店的穩(wěn)定性。CV越小說明門店月度銷售越平穩(wěn)CV大說明某幾個月起伏特別劇烈。結(jié)合前面算的月度趨勢能快速定位問題門店。4.2 相關(guān)性與增長率分析銷量和銷售額的相關(guān)性很有意思。如果兩者高度正相關(guān)說明業(yè)績主要由走量驅(qū)動如果相關(guān)系數(shù)很低說明不同商品的單價差異極大銷售結(jié)構(gòu)里可能有高客單商品在起作用。計算相關(guān)系數(shù)只需一行corr_matrix np.corrcoef(volume, sales_amt) corr corr_matrix[0, 1] print(f銷量與銷售額相關(guān)系數(shù){corr:.4f})np.corrcoef返回的是相關(guān)系數(shù)矩陣對角線都是1非對角線就是兩兩之間的相關(guān)系數(shù)。我見過很多新手直接print(corr_matrix)然后看著輸出發(fā)懵這里明確說一下取值方式。增長率方面除了前面算的月度環(huán)比還可以算全年增長total_growth (monthly_sales[-1] - monthly_sales[0]) / monthly_sales[0] * 100 print(f全年銷售額增長率{total_growth:.2f}%)不過環(huán)比更能看出業(yè)務(wù)節(jié)奏比如3月環(huán)比2月漲了多少、9月環(huán)比8月漲了多少。這些數(shù)字最后都要落到業(yè)務(wù)解釋上漲是因為節(jié)日促銷跌是因為春節(jié)放假而不是空對空。分析結(jié)果如果脫離業(yè)務(wù)背景就只是一堆數(shù)字。4.3 結(jié)果導(dǎo)出與可視化銜接分析結(jié)果最終要交付給業(yè)務(wù)方。用np.savetxt導(dǎo)出月度匯總summary np.column_stack((months, monthly_sales, monthly_orders)) np.savetxt( monthly_summary.csv, summary, delimiter,, headermonth,sales_amt,order_cnt, comments, fmt%.2f )這里fmt%.2f控制導(dǎo)出格式避免輸出一長串浮點數(shù)。header參數(shù)會在CSV第一行寫表頭comments是去掉默認的#注釋前綴??梢暬矫鍺umPy的數(shù)組可以直接喂給matplotlib不需要轉(zhuǎn)成Python列表import matplotlib.pyplot as plt plt.plot(months, monthly_sales, markero) plt.title(2024月度銷售額趨勢) plt.xlabel(月份) plt.ylabel(銷售額) plt.show()如果你后續(xù)想畫更復(fù)雜的圖比如門店Top10柱狀圖、商品貢獻餅圖從NumPy數(shù)組到matplotlib是零成本的。這也是我一直強調(diào)先學(xué)NumPy的原因它和整個Python數(shù)據(jù)生態(tài)的接口是天然對齊的。5. 踩坑實錄與性能優(yōu)化技巧5.1 我遇到過的典型坑這個案例我跑了很多遍也踩過很多坑。以下幾條是個人實戰(zhàn)中最高頻的問題整理成速查表問題現(xiàn)象根本原因解決辦法讀取CSV報UnicodeDecodeError文件編碼不是系統(tǒng)默認編碼np.genfromtxt加encodingutf-8聚合結(jié)果下標(biāo)錯位忘記np.bincount從0開始門店編號從1開始時結(jié)果[0]直接忽略求均值得到nan數(shù)據(jù)里有缺失值沒先排查先np.isnan().sum()檢查再填充或過濾np.diff結(jié)果錯位結(jié)果長度比原數(shù)組少1先初始化一個全0數(shù)組再錯位賦值過濾條件后數(shù)據(jù)全亂只過濾了部分數(shù)組所有關(guān)聯(lián)數(shù)組必須同步應(yīng)用同一掩碼浮點數(shù)比較不穩(wěn)定精度問題金額統(tǒng)一乘以100轉(zhuǎn)整數(shù)處理或用np.isclose其中下標(biāo)錯位這個問題是我自己在用np.bincount時踩得最多的一回。門店編號從1開始結(jié)果數(shù)組下標(biāo)0沒有任何數(shù)據(jù)導(dǎo)致所有后續(xù)排名和中位數(shù)的理解都偏了一位。所以我現(xiàn)在只要用到bincount第一件事就是打印len(result)確認是否多了一個無用下標(biāo)。5.2 讓NumPy跑得更快的小習(xí)慣代碼能跑通和跑得快是兩回事。以下幾個習(xí)慣我是在處理百萬級數(shù)據(jù)時才真正體會到的第一能用向量化運算就不寫for循環(huán)。NumPy的加減乘除、比較、邏輯運算都是按元素執(zhí)行的速度極快。比如分組求和用np.bincount替代循環(huán)速度能提升幾十倍。第二避免在循環(huán)里拼接數(shù)組。很多人會寫arr np.append(arr, x)這是性能殺手。正確做法是先分配一個足夠大的結(jié)果數(shù)組再用索引賦值填進去。舉個例子# 不推薦循環(huán)內(nèi)append result np.array([]) for x in range(10000): result np.append(result, np.sin(x)) # 推薦預(yù)分配空間 result np.zeros(10000) for i in xrange(10000): result[i] np.sin(i)第三注意復(fù)制和視圖的區(qū)別。b a只是把b指向同一個數(shù)組修改b會影響a這經(jīng)常導(dǎo)致莫名其妙的bug。如果確實要獨立副本用b a.copy()。第四隨機數(shù)生成統(tǒng)一用np.random.default_rng()而不是老的np.random.seed()。前者生成的隨機數(shù)質(zhì)量更好而且線程安全數(shù)據(jù)模擬、抽樣都會用到。5.3 如何擴展成自動化腳本分析做一次不難難的是每天、每周重復(fù)做。所以我會把代碼封裝成函數(shù)輸入是CSV路徑輸出是幾個匯總數(shù)組def build_sales_summary(csv_path): raw np.genfromtxt(csv_path, delimiter,, skip_header1, dtypefloat, encodingutf-8) store_id raw[:, 0].astype(int) month raw[:, 1].astype(int) product_id raw[:, 2].astype(int) sales_amt raw[:, 3] volume raw[:, 4] order_cnt raw[:, 5].astype(int) # 缺失值填充、異常值過濾… monthly_sales np.array([sales_amt[month m].sum() for m in range(1, 13)]) store_sales np.bincount(store_id, weightssales_amt) product_sales np.bincount(product_id, weightssales_amt) return monthly_sales, store_sales, product_sales后續(xù)接定時任務(wù)、寫報告、發(fā)郵件都是圍繞這個函數(shù)展開。我在真實項目里就是這么干的NumPy負責(zé)所有核心計算Pandas或openpyxl負責(zé)生成Excel報表matplotlib負責(zé)出圖。最后再分享一個小技巧分析過程中多打印數(shù)組形狀和前幾行數(shù)據(jù)能避免一半以上的邏輯錯誤。NumPy的數(shù)組運算很強大但一旦形狀對不上報錯信息往往不直觀。養(yǎng)成print(arr.shape)的習(xí)慣很多時候能省下半小時的排查時間。我個人在實際操作中的體會是這個案例的價值不在統(tǒng)計數(shù)字本身而在把一個業(yè)務(wù)問題拆成數(shù)組操作的過程。數(shù)據(jù)量小的時候感覺不到差距一旦上了幾十萬行寫不寫向量化就是秒級和分鐘級的差別。所以我在項目里一直堅持先用NumPy把核心邏輯驗證通過再決定要不要引入更上層的工具?;A(chǔ)打牢了往上走反而更快。