前幾天讀MacTalk轉載的一篇文章《SQL能解決的問題,別動不動整機器學習》,文章認為在新技術如機器學習、區塊鏈、虛擬現實等興起的今天,那些看似老舊的技術,反而有著歷久彌新的魅力。
我讀後有所感悟,Machine Learning確實很Cool,很Geek,但是在解決一些實際問題的時候,也許並不是最好的解決方案。再比如有一組漫畫,問:為什麼要做微服務?答:我也不知道,只是別人都在做。這當然只是一種漫畫式的戲謔,而此時由微服務需要引入分散式事務,增加了系統的複雜度與成本。此時再反觀,這些業務真的需要這樣的過度設計嗎?古語有云:殺雞焉用牛刀?動用航母去運輸農副產品,顯然不大合適。
不過問題一分為二,站在學習者的角度來說,多去了解學習先進技術,是大有裨益的。
之前老大交給我一個小需求,問題是這樣的:現在有上萬個文字檔案,每個檔案有幾千行文字,其中數字(幀數),空行,字串(臺詞)。需求是對文字中的字串進行必要去重,將幀數與臺詞匹配作為引數push到某介面以及其他的一些操作。
文字區域性:
現在我面對的主要是兩個問題,一個是如何較為有效地對資料進行清洗;二是資料量有點大,需要併發。
先來思考第一個問題,由於整個小系統是用Java寫的,我也沒有資料清洗的經驗,當時就先想著能不能用Java解決,假如不合適,再去查詢有沒有一些合適的Python輪子來處理這些。我先準備問問旁邊演算法崗的老哥有沒有好的解決方式,他看了後說先對空行進行消除,然後可以對相鄰的句子進行語義正確度的計算(大概是這麼說的,我不太記得術語了)留下值高的那一句等等。
頓時我有一種問題複雜了的感覺,也考慮了其可行性,首先有沒有現成的介面給我呼叫?百度Ai開放平臺確實有,但是肯定有次數限制,我不可能每條字串都去呼叫一下介面。而且傳送請求等待響應,然後再處理響應這塊的效率肯定很低。
我大致翻閱了一下臺詞字串,這些是OCR識別出來的,在正片的時候,由於底下只有一行,所以識別效果不錯,而在片頭片尾,由於有大量演員職員資訊與片頭片尾曲歌詞在螢幕上,所以合成的字串較為混亂。
重頭戲必然在正片部分,片頭片尾可以另行處理,況且在同一部劇的情況下,由於片頭尾屬於重複部分,甚至可以只處理一份,後面進行復制覆蓋即可(不像日本動漫會更換op/ed)
我們來梳理一下,要解決哪些問題?
1. 獲取所有檔案的路徑
解決方式:
自己編寫一個檔案工具類,傳入根路徑,使用遞迴來獲取所有檔案路徑,儲存在一個ArrayList中。由於我事先知道有接近一萬個檔案,而ArrayList在進行動態擴容時是比較消耗效能的,所以最好在定義的時候就初始化其長度。
程式碼:
public static List<String> getAllFilePath(String path) {
ArrayList<String> fileList = new ArrayList();
File file = new File(path);
if (file.exists()) {
File[] files = file.listFiles();
if (files == null || files.length == 0) {
return fileList;
}
for (File f : files) {
if (f.isDirectory()) {
fileList.addAll(getAllFilePath(f.getAbsolutePath()));
} else {
fileList.add(f.getPath());
}
}
}
return fileList;
}
複製程式碼
2. 檔案較多,整體所佔空間較大,全部讀入記憶體再處理不合理
使用流來逐行讀入處理。
程式碼:
public List<XXData> parserFile(String path) throws IOException, NoSuchFieldException, IllegalAccessException {
File file = new File(path);
BufferedReader br = new BufferedReader(new FileReader(file));
while ((content = br.readLine()) != null) {
// dosomething
}
複製程式碼
3. 對特殊符號、空白行進行初步過濾。
使用一些基本的正則來過濾。
4. 如何相鄰字串進行相似度比較?
由於識別出來的臺詞還是比較規則的,對於這個相似度計算的要求其實比較低。我這裡用的是Levenshtein這個演算法,來計算兩個字串之間的編輯距離,以此來比較字串之間的相似度。
public class Levenshtein {
private int compare(String str, String target) {
int d[][];
int n = str.length();
int m = target.length();
int i;
int j;
char ch1;
char ch2;
int temp;
if (n == 0) {
return m;
}
if (m == 0) {
return n;
}
d = new int[n + 1][m + 1];
for (i = 0; i <= n; i++) {
d[i][0] = i;
}
for (j = 0; j <= m; j++) {
d[0][j] = j;
}
for (i = 1; i <= n; i++) {
ch1 = str.charAt(i - 1);
for (j = 1; j <= m; j++) {
ch2 = target.charAt(j - 1);
if (ch1 == ch2 || ch1 == ch2 + 32 || ch1 + 32 == ch2) {
temp = 0;
} else {
temp = 1;
}
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + temp);
}
}
return d[n][m];
}
private int min(int one, int two, int three) {
return (one = one < two ? one : two) < three ? one : three;
}
/**
* 獲取兩字串的相似度
*/
public float getSimilarityRatio(String str, String target) {
return 1 - (float) compare(str, target) / Math.max(str.length(), target.length());
}
}
複製程式碼
5. 由於需要將幀數與對應臺詞匹配,那麼選擇map這種資料結構來暫時儲存是比較方便的。但是選用哪種Map呢?
HashMap不會維護插入順序,由於需要獲取上一條資料與當前資料進行相似度比較,所以不合適。那麼LinkedHashMap呢?它是基於連結串列,擁有有序性,但是它的遍歷時間複雜度是O(N),太慢了,能不能直接獲取尾節點呢?可以使用反射來獲取執行時資訊,直接獲取尾節點,複雜度O(1)。當時也考慮過TreeMap,有序,基於紅黑樹,查詢的時間複雜度為O(logN)。所以我們選擇的是LinkedHashMap。
程式碼:
public class ActorLineFileParser {
private static final Logger logger = LoggerFactory.getLogger(ActorLineFileParser.class);
public List<Data> parserFile(String path) throws IOException, NoSuchFieldException, IllegalAccessException {
File file = new File(path);
BufferedReader br = new BufferedReader(new FileReader(file));
String content = "";
Map<Integer, String> map = new LinkedHashMap<>();
Pattern pattern = Pattern.compile("[0-9]*");
Integer time = 0;
Levenshtein lt = new Levenshtein();
while ((content = br.readLine()) != null) {
if (content.length() > 0) {
if (pattern.matcher(content).matches()) {
time = Integer.parseInt(content);
continue;
}
if (time > headEndPoint && time < tailStartPoint ) {
Field tail = map.getClass().getDeclaredField("tail");
tail.setAccessible(true);
Map.Entry<Integer, String> previousLine = (Map.Entry<Integer, String>) tail.get(map);
content = content.trim().replaceAll(",|,|%|【|[a-z]|\?|%|\/|《|》", "");
if (previousLine == null) {
map.put(time, content);
continue;
}
float similarityRatio = lt.getSimilarityRatio(content, previousLine.getValue());
if (similarityRatio > 0.49) {
map.put(previousLine.getKey(), content);
} else {
map.put(time, content);
}
}
}
}
br.close();
// 後續操作省略
}
}
複製程式碼
6. 將資料處理封裝好後,需要進行push到某介面,並且持久化記錄
這些操作是比較耗時的,那就需要開啟多個執行緒來進行工作。我的思路是,每將一個文字中的內容清洗封裝完成後,就將這個實體(一個包含了多條字幕資訊的列表)加入一個集合,然後對這個實體進行其他的一些驗證以及進一步封裝等等的操作(這裡就不展開了),最後將其加入一個任務佇列,等待執行緒池的分配資源,進行網路傳輸與持久化。
這裡需要提醒的是,記得為執行緒池的阻塞佇列設定長度以及拒絕規則。否則一直是核心執行緒在工作,並不會動用設定的最大執行緒。同時打好日誌,方便檢視執行情況。
我忘記設定阻塞佇列長度,結果一直只有8個核心執行緒在工作,導致這個程式執行了30多個小時才結束。
如果你有耐心讀到這裡,我也希望你不要曲解我的本意。我並不是要唱衰機器學習、區塊鏈這些新技術,它們是利劍,正在被這個世界上最優秀的人才鍛造,網際網路時代的新階段需要它們去披荊斬棘。但是在有些情況下,使用基本操作,能獲得低成本、高效率的結果。因為這些現在看似尋常的技術,也曾是最優秀。