深入推薦引擎相關演算法 - 聚類
探索推薦引擎內部的祕密,第 3 部分
深入推薦引擎相關演算法 - 聚類
趙 晨婷 和 馬 春娥
2011 年 3 月 24 日釋出
系列內容:
此內容是該系列 3 部分中的第 3 部分: 探索推薦引擎內部的祕密
聚類分析
什麼是聚類分析?
聚類 (Clustering) 就是將資料物件分組成為多個類或者簇 (Cluster),它的目標是:在同一個簇中的物件之間具有較高的相似度,而不同簇中的物件差別較大。所以,在很多應用中,一個簇中的資料物件可以被作為一個整體來對待,從而減少計算量或者提高計算質量。
其實聚類是一個人們日常生活的常見行為,即所謂“物以類聚,人以群分”,核心的思想也就是聚類。人們總是不斷地改進下意識中的聚類模式來學習如何區分各個事物和人。同時,聚類分析已經廣泛的應用在許多應用中,包括模式識別,資料分析,影象處理以及市場研究。通過聚類,人們能意識到密集和稀疏的區域,發現全域性的分佈模式,以及資料屬性之間的有趣的相互關係。
聚類同時也在 Web 應用中起到越來越重要的作用。最被廣泛使用的既是對 Web 上的文件進行分類,組織資訊的釋出,給使用者一個有效分類的內容瀏覽系統(入口網站),同時可以加入時間因素,進而發現各個類內容的資訊發展,最近被大家關注的主題和話題,或者分析一段時間內人們對什麼樣的內容比較感興趣,這些有趣的應用都得建立在聚類的基礎之上。作為一個數據挖掘的功能,聚類分析能作為獨立的工具來獲得資料分佈的情況,觀察每個簇的特點,集中對特定的某些簇做進一步的分析,此外,聚類分析還可以作為其他演算法的預處理步驟,簡化計算量,提高分析效率,這也是我們在這裡介紹聚類分析的目的。
不同的聚類問題
對於一個聚類問題,要挑選最適合最高效的演算法必須對要解決的聚類問題本身進行剖析,下面我們就從幾個側面分析一下聚類問題的需求。
聚類結果是排他的還是可重疊的
為了很好理解這個問題,我們以一個例子進行分析,假設你的聚類問題需要得到二個簇:“喜歡詹姆斯卡梅隆電影的使用者”和“不喜歡詹姆斯卡梅隆的使用者”,這其實是一個排他的聚類問題,對於一個使用者,他要麼屬於“喜歡”的簇,要麼屬於不喜歡的簇。但如果你的聚類問題是“喜歡詹姆斯卡梅隆電影的使用者”和“喜歡里奧納多電影的使用者”,那麼這個聚類問題就是一個可重疊的問題,一個使用者他可以既喜歡詹姆斯卡梅隆又喜歡里奧納多。
所以這個問題的核心是,對於一個元素,他是否可以屬於聚類結果中的多個簇中,如果是,則是一個可重疊的聚類問題,如果否,那麼是一個排他的聚類問題。
基於層次還是基於劃分
其實大部分人想到的聚類問題都是“劃分”問題,就是拿到一組物件,按照一定的原則將它們分成不同的組,這是典型的劃分聚類問題。但除了基於劃分的聚類,還有一種在日常生活中也很常見的型別,就是基於層次的聚類問題,它的聚類結果是將這些物件分等級,在頂層將物件進行大致的分組,隨後每一組再被進一步的細分,也許所有路徑最終都要到達一個單獨例項,這是一種“自頂向下”的層次聚類解決方法,對應的,也有“自底向上”的。其實可以簡單的理解,“自頂向下”就是一步步的細化分組,而“自底向上”就是一步步的歸併分組。
簇數目固定的還是無限制的聚類
這個屬性很好理解,就是你的聚類問題是在執行聚類演算法前已經確定聚類的結果應該得到多少簇,還是根據資料本身的特徵,由聚類演算法選擇合適的簇的數目。
基於距離還是基於概率分佈模型
在本系列的第二篇介紹協同過濾的文章中,我們已經詳細介紹了相似性和距離的概念。基於距離的聚類問題應該很好理解,就是將距離近的相似的物件聚在一起。相比起來,基於概率分佈模型的,可能不太好理解,那麼下面給個簡單的例子。
一個概率分佈模型可以理解是在 N 維空間的一組點的分佈,而它們的分佈往往符合一定的特徵,比如組成一個特定的形狀。基於概率分佈模型的聚類問題,就是在一組物件中,找到能符合特定分佈模型的點的集合,他們不一定是距離最近的或者最相似的,而是能完美的呈現出概率分佈模型所描述的模型。
下面圖 1 給出了一個例子,對同樣一組點集,應用不同的聚類策略,得到完全不同的聚類結果。左側給出的結果是基於距離的,核心的原則就是將距離近的點聚在一起,右側給出的基於概率分佈模型的聚類結果,這裡採用的概率分佈模型是一定弧度的橢圓。圖中專門標出了兩個紅色的點,這兩點的距離很近,在基於距離的聚類中,將他們聚在一個類中,但基於概率分佈模型的聚類則將它們分在不同的類中,只是為了滿足特定的概率分佈模型(當然這裡我特意舉了一個比較極端的例子)。所以我們可以看出,在基於概率分佈模型的聚類方法裡,核心是模型的定義,不同的模型可能導致完全不同的聚類結果。
圖 1 基於距離和基於概率分佈模型的聚類問題
Apache Mahout 中的聚類分析框架
Apache Mahout 是 Apache Software Foundation (ASF) 旗下的一個開源專案,提供一些可擴充套件的機器學習領域經典演算法的實現,旨在幫助開發人員更加方便快捷地建立智慧應用程式,並且,在 Mahout 的最近版本中還加入了對 Apache Hadoop 的支援,使這些演算法可以更高效的執行在雲端計算環境中。
關於 Apache Mahout 的安裝和配置請參考《基於 Apache Mahout 構建社會化推薦引擎》,它是筆者 09 年發表的一篇關於基於 Mahout 實現推薦引擎的 developerWorks 文章,其中詳細介紹了 Mahout 的安裝步驟。
Mahout 中提供了常用的多種聚類演算法,涉及我們剛剛討論過的各種型別演算法的具體實現,下面我們就進一步深入幾個典型的聚類演算法的原理,優缺點和實用場景,以及如何使用 Mahout 高效的實現它們。
深入聚類演算法
深入介紹聚類演算法之前,這裡先對 Mahout 中對各種聚類問題的資料模型進行簡要的介紹。
資料模型
Mahout 的聚類演算法將物件表示成一種簡單的資料模型:向量 (Vector)。在向量資料描述的基礎上,我們可以輕鬆的計算兩個物件的相似性,關於向量和向量的相似度計算,本系列的上一篇介紹協同過濾演算法的文章中已經進行了詳細的介紹,請參考《“探索推薦引擎內部的祕密”系列 - Part 2: 深入推薦引擎相關演算法 -- 協同過濾》。
Mahout 中的向量 Vector 是一個每個域是浮點數 (double) 的複合物件,最容易聯想到的實現就是一個浮點數的陣列。但在具體應用由於向量本身資料內容的不同,比如有些向量的值很密集,每個域都有值;有些呢則是很稀疏,可能只有少量域有值,所以 Mahout 提供了多個實現:
- DenseVector,它的實現就是一個浮點數陣列,對向量裡所有域都進行儲存,適合用於儲存密集向量。
- RandomAccessSparseVector 基於浮點數的 HashMap 實現的,key 是整形 (int) 型別,value 是浮點數 (double) 型別,它只儲存向量中不為空的值,並提供隨機訪問。
- SequentialAccessVector 實現為整形 (int) 型別和浮點數 (double) 型別的並行陣列,它也只儲存向量中不為空的值,但只提供順序訪問。
使用者可以根據自己演算法的需求選擇合適的向量實現類,如果演算法需要很多隨機訪問,應該選擇 DenseVector 或者 RandomAccessSparseVector,如果大部分都是順序訪問,SequentialAccessVector 的效果應該更好。
介紹了向量的實現,下面我們看看如何將現有的資料建模成向量,術語就是“如何對資料進行向量化”,以便採用 Mahout 的各種高效的聚類演算法。
- 簡單的整形或浮點型的資料
這種資料最簡單,只要將不同的域存在向量中即可,比如 n 維空間的點,其實本身可以被描述為一個向量。
- 列舉型別資料
這類資料是對物體的描述,只是取值範圍有限。舉個例子,假設你有一個蘋果資訊的資料集,每個蘋果的資料包括:大小,重量,顏色等,我們以顏色為例,設蘋果的顏色資料包括:紅色,黃色和綠色。在對資料進行建模時,我們可以用數字來表示顏色,紅色 =1,黃色 =2,綠色 =3,那麼大小直徑 8cm,重量 0.15kg,顏色是紅色的蘋果,建模的向量就是 <8, 0.15, 1>。
下面的清單 1 給出了對以上兩種資料進行向量化的例子。
清單 1. 建立簡單的向量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 建立一個二維點集的向量組
public static final double[][] points = { { 1, 1 }, { 2, 1 }, { 1, 2 },
{ 2, 2 }, { 3, 3 }, { 8, 8 }, { 9, 8 }, { 8, 9 }, { 9, 9 }, { 5, 5 },
{ 5, 6 }, { 6, 6 }};
public static List<
Vector
> getPointVectors(double[][] raw) {
List<
Vector
> points = new ArrayList<
Vector
>();
for (int i = 0; i <
raw.length
; i++) {
double[]
fr
=
raw
[i];
// 這裡選擇建立 RandomAccessSparseVector
Vector
vec
=
new
RandomAccessSparseVector(fr.length);
// 將資料存放在建立的 Vector 中
vec.assign(fr);
points.add(vec);
}
return points;
}
// 建立蘋果資訊資料的向量組
public static List<Vector> generateAppleData() {
List<
Vector
> apples = new ArrayList<
Vector
>();
// 這裡建立的是 NamedVector,其實就是在上面幾種 Vector 的基礎上,
//為每個 Vector 提供一個可讀的名字
NamedVector apple = new NamedVector(new DenseVector(
new double[] {0.11, 510, 1}),
"Small round green apple");
apples.add(apple);
apple = new NamedVector(new DenseVector(new double[] {0.2, 650, 3}),
"Large oval red apple");
apples.add(apple);
apple = new NamedVector(new DenseVector(new double[] {0.09, 630, 1}),
"Small elongated red apple");
apples.add(apple);
apple = new NamedVector(new DenseVector(new double[] {0.25, 590, 3}),
"Large round yellow apple");
apples.add(apple);
apple = new NamedVector(new DenseVector(new double[] {0.18, 520, 2}),
"Medium oval green apple");
apples.add(apple);
return apples;
}
- 文字資訊
作為聚類演算法的主要應用場景 - 文字分類,對文字資訊的建模也是一個常見的問題。在資訊檢索研究領域已經有很好的建模方式,就是資訊檢索領域中最常用的向量空間模型 (Vector Space Model, VSM)。因為向量空間模型不是本文的重點,這裡給一個簡要的介紹,有興趣的朋友可以查閱參考目錄中給出的相關文件。
文字的向量空間模型就是將文字資訊建模為一個向量,其中每一個域是文字中出現的一個詞的權重。關於權重的計算則有很多中:
- 最簡單的莫過於直接計數,就是詞在文本里出現的次數。這種方法簡單,但是對文字內容描述的不夠精確。
- 詞的頻率 (Team Frequency, TF):就是將詞在文字中出現的頻率作為詞的權重。這種方法只是對於直接計數進行了歸一化處理,目的是讓不同長度的文字模型有統一的取值空間,便於文字相似度的比較,但可以看出,簡單計數和詞頻都不能解決“高頻無意義詞彙權重大的問題”,也就是說對於英文文字中,“a”,“the”這樣高頻但無實際意義的詞彙並沒有進行過濾,這樣的文字模型在計算文字相似度時會很不準確。
- 詞頻 - 逆向文字頻率 (Term Frequency – Inverse Document Frequency, TF-IDF):它是對 TF 方法的一種加強,字詞的重要性隨著它在檔案中出現的次數成正比增加,但同時會隨著它在所有文字中出現的頻率成反比下降。舉個例子,對於“高頻無意義詞彙”,因為它們大部分會出現在所有的文字中,所以它們的權重會大打折扣,這樣就使得文字模型在描述文字特徵上更加精確。在資訊檢索領域,TF-IDF 是對文字資訊建模的最常用的方法。
對於文字資訊的向量化,Mahout 已經提供了工具類,它基於 Lucene 給出了對文字資訊進行分析,然後建立文字向量。下面的清單 2 給出了一個例子,分析的文字資料是路透提供的新聞資料,參考資源裡給出了下載地址。將資料集下載後,放在“clustering/reuters”目錄下。
清單 2. 建立文字資訊的向量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public static void documentVectorize(String[] args) throws Exception{
//1. 將路透的資料解壓縮 , Mahout 提供了專門的方法
DocumentClustering.extractReuters();
//2. 將資料儲存成 SequenceFile,因為這些工具類就是在 Hadoop 的基礎上做的,所以首先我們需要將資料寫
// 成 SequenceFile,以便讀取和計算
DocumentClustering.transformToSequenceFile();
//3. 將 SequenceFile 檔案中的資料,基於 Lucene 的工具進行向量化
DocumentClustering.transformToVector();
}
public static void extractReuters(){
//ExtractReuters 是基於 Hadoop 的實現,所以需要將輸入輸出的檔案目錄傳給它,這裡我們可以直接把它映
// 射到我們本地的一個資料夾,解壓後的資料將寫入輸出目錄下
File inputFolder = new File("clustering/reuters");
File outputFolder = new File("clustering/reuters-extracted");
ExtractReuters extractor = new ExtractReuters(inputFolder, outputFolder);
extractor.extract();
}
public static void transformToSequenceFile(){
//SequenceFilesFromDirectory 實現將某個檔案目錄下的所有檔案寫入一個 SequenceFiles 的功能
// 它其實本身是一個工具類,可以直接用命令列呼叫,這裡直接呼叫了它的 main 方法
String[] args = {"-c", "UTF-8", "-i", "clustering/reuters-extracted/", "-o",
"clustering/reuters-seqfiles"};
// 解釋一下引數的意義:
// -c: 指定檔案的編碼形式,這裡用的是"UTF-8"
// -i: 指定輸入的檔案目錄,這裡指到我們剛剛匯出檔案的目錄
// -o: 指定輸出的檔案目錄
try {
SequenceFilesFromDirectory.main(args);
} catch (Exception e) {
e.printStackTrace();
}
}
public static void transformToVector(){
//SparseVectorsFromSequenceFiles 實現將 SequenceFiles 中的資料進行向量化。
// 它其實本身是一個工具類,可以直接用命令列呼叫,這裡直接呼叫了它的 main 方法
String[] args = {"-i", "clustering/reuters-seqfiles/", "-o",
"clustering/reuters-vectors-bigram", "-a",
"org.apache.lucene.analysis.WhitespaceAnalyzer"
, "-chunk", "200", "-wt", "tfidf", "-s", "5",
"-md", "3", "-x", "90", "-ng", "2", "-ml", "50", "-seq"};
// 解釋一下引數的意義:
// -i: 指定輸入的檔案目錄,這裡指到我們剛剛生成 SequenceFiles 的目錄
// -o: 指定輸出的檔案目錄
// -a: 指定使用的 Analyzer,這裡用的是 lucene 的空格分詞的 Analyzer
// -chunk: 指定 Chunk 的大小,單位是 M。對於大的檔案集合,我們不能一次 load 所有檔案,所以需要
// 對資料進行切塊
// -wt: 指定分析時採用的計算權重的模式,這裡選了 tfidf
// -s: 指定詞語在整個文字集合出現的最低頻度,低於這個頻度的詞彙將被丟掉
// -md: 指定詞語在多少不同的文字中出現的最低值,低於這個值的詞彙將被丟掉
// -x: 指定高頻詞彙和無意義詞彙(例如 is,a,the 等)的出現頻率上限,高於上限的將被丟掉
// -ng: 指定分詞後考慮詞彙的最大長度,例如 1-gram 就是,coca,cola,這是兩個詞,
// 2-gram 時,coca cola 是一個詞彙,2-gram 比 1-gram 在一定情況下分析的更準確。
// -ml: 指定判斷相鄰詞語是不是屬於一個詞彙的相似度閾值,當選擇 >1-gram 時才有用,其實計算的是
// Minimum Log Likelihood Ratio 的閾值
// -seq: 指定生成的向量是 SequentialAccessSparseVectors,沒設定時預設生成還是
// RandomAccessSparseVectors
try {
SparseVectorsFromSequenceFiles.main(args);
} catch (Exception e) {
e.printStackTrace();
}
}
這裡補充一點,生成的向量化檔案的目錄結構是這樣的:圖 2 文字資訊向量化
- df-count 目錄:儲存著文字的頻率資訊
- tf-vectors 目錄:儲存著以 TF 作為權值的文字向量
- tfidf-vectors 目錄:儲存著以 TFIDF 作為權值的文字向量
- tokenized-documents 目錄:儲存著分詞過後的文字資訊
- wordcount 目錄:儲存著全域性的詞彙出現的次數
- dictionary.file-0 目錄:儲存著這些文字的詞彙表
- frequcency-file-0 目錄 : 儲存著詞彙表對應的頻率資訊。
介紹完向量化問題,下面我們深入分析各個聚類演算法,首先介紹的是最經典的 K 均值演算法。
K 均值聚類演算法
K 均值是典型的基於距離的排他的劃分方法:給定一個 n 個物件的資料集,它可以構建資料的 k 個劃分,每個劃分就是一個聚類,並且 k<=n,同時還需要滿足兩個要求:
- 每個組至少包含一個物件
- 每個物件必須屬於且僅屬於一個組。
K 均值的基本原理是這樣的,給定 k,即要構建的劃分的數目,
- 首先建立一個初始劃分,隨機地選擇 k 個物件,每個物件初始地代表了一個簇中心。對於其他的物件,根據其與各個簇中心的距離,將它們賦給最近的簇。
- 然後採用一種迭代的重定位技術,嘗試通過物件在劃分間移動來改進劃分。所謂重定位技術,就是當有新的物件加入簇或者已有物件離開簇的時候,重新計算簇的平均值,然後對物件進行重新分配。這個過程不斷重複,直到沒有簇中物件的變化。
當結果簇是密集的,而且簇和簇之間的區別比較明顯時,K 均值的效果比較好。對於處理大資料集,這個演算法是相對可伸縮的和高效的,它的複雜度是 O(nkt),n 是物件的個數,k 是簇的數目,t 是迭代的次數,通常 k<<n,且 t<<n,所以演算法經常以區域性最優結束。
K 均值的最大問題是要求使用者必須事先給出 k 的個數,k 的選擇一般都基於一些經驗值和多次實驗結果,對於不同的資料集,k 的取值沒有可借鑑性。另外,K 均值對“噪音”和孤立點資料是敏感的,少量這類的資料就能對平均值造成極大的影響。
說了這麼多理論的原理,下面我們基於 Mahout 實現一個簡單的 K 均值演算法的例子。如前面介紹的,Mahout 提供了基本的基於記憶體的實現和基於 Hadoop 的 Map/Reduce 的實現,分別是 KMeansClusterer 和 KMeansDriver,下面給出一個簡單的例子,就基於我們在清單 1 裡定義的二維點集資料。
清單 3. K 均值聚類演算法示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
|
介紹完 K 均值聚類演算法,我們可以看出它最大的優點是:原理簡單,實現起來也相對簡單,同時執行效率和對於大資料量的可伸縮性還是較強的。然而缺點也是很明確的,首先它需要使用者在執行聚類之前就有明確的聚類個數的設定,這一點是使用者在處理大部分問題時都不太可能事先知道的,一般需要通過多次試驗找出一個最優的 K 值;其次就是,由於演算法在最開始採用隨機選擇初始聚類中心的方法,所以演算法對噪音和孤立點的容忍能力較差。所謂噪音就是待聚類物件中錯誤的資料,而孤立點是指與其他資料距離較遠,相似性較低的資料。對於 K 均值演算法,一旦孤立點和噪音在最開始被選作簇中心,對後面整個聚類過程將帶來很大的問題,那麼我們有什麼方法可以先快速找出應該選擇多少個簇,同時找到簇的中心,這樣可以大大優化 K 均值聚類演算法的效率,下面我們就介紹另一個聚類方法:Canopy 聚類演算法。
Canopy 聚類演算法
Canopy 聚類演算法的基本原則是:首先應用成本低的近似的距離計算方法高效的將資料分為多個組,這裡稱為一個 Canopy,我們姑且將它翻譯為“華蓋”,Canopy 之間可以有重疊的部分;然後採用嚴格的距離計算方式準確的計算在同一 Canopy 中的點,將他們分配與最合適的簇中。Canopy 聚類演算法經常用於 K 均值聚類演算法的預處理,用來找合適的 k 值和簇中心。
下面詳細介紹一下建立 Canopy 的過程:初始,假設我們有一組點集 S,並且預設了兩個距離閾值,T1,T2(T1>T2);然後選擇一個點,計算它與 S 中其他點的距離(這裡採用成本很低的計算方法),將距離在 T1 以內的放入一個 Canopy 中,同時從 S 中去掉那些與此點距離在 T2 以內的點(這裡是為了保證和中心距離在 T2 以內的點不能再作為其他 Canopy 的中心),重複整個過程直到 S 為空為止。
對 K 均值的實現一樣,Mahout 也提供了兩個 Canopy 聚類的實現,下面我們就看看具體的程式碼例子。
清單 4. Canopy 聚類演算法示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
|
模糊 K 均值聚類演算法
模糊 K 均值聚類演算法是 K 均值聚類的擴充套件,它的基本原理和 K 均值一樣,只是它的聚類結果允許存在物件屬於多個簇,也就是說:它屬於我們前面介紹過的可重疊聚類演算法。為了深入理解模糊 K 均值和 K 均值的區別,這裡我們得花些時間瞭解一個概念:模糊引數(Fuzziness Factor)。
與 K 均值聚類原理類似,模糊 K 均值也是在待聚類物件向量集合上迴圈,但是它並不是將向量分配給距離最近的簇,而是計算向量與各個簇的相關性(Association)。假設有一個向量 v,有 k 個簇,v 到 k 個簇中心的距離分別是 d1,d2… dk,那麼 V 到第一個簇的相關性 u1可以通過下面的算式計算:
計算 v 到其他簇的相關性只需將 d1替換為對應的距離。
從上面的算式,我們看出,當 m 近似 2 時,相關性近似 1;當 m 近似 1 時,相關性近似於到該簇的距離,所以 m 的取值在(1,2)區間內,當 m 越大,模糊程度越大,m 就是我們剛剛提到的模糊引數。
講了這麼多理論的原理,下面我們看看如何使用 Mahout 實現模糊 K 均值聚類,同前面的方法一樣,Mahout 一樣提供了基於記憶體和基於 Hadoop Map/Reduce 的兩種實現 FuzzyKMeansClusterer 和 FuzzyMeansDriver,分別是清單 5 給出了一個例子。
清單 5. 模糊 K 均值聚類演算法示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
|