├── README.md ├── categoricalClustDist.py ├── categoricalClustDist_mosek.py └── export_d2s.py /README.md: -------------------------------------------------------------------------------- 1 | # Utilities related to D2 Clustering for Document Data 2 | 3 | This repository includes python 2.7 scripts that process a document dataset file into .d2s format that is ready for applying software package **d2_kmeans**. The clustering result provided by **d2_kmeans** is then evaluated by different metrics. If you are interested in the software **d2_kmeans** and reproduce the results in the paper, please contact the author @JianboYe directly. 4 | 5 | 6 | The utilities involved were used for generating part of the results reported in the following paper: 7 | 8 | [Jianbo Ye](http://personal.psu.edu/jxy198), Yanran Li, Zhaohui Wu, James Z. Wang, Wenjie Li, Jia Li, Determining Gains Acquired from Word Embedding Quantitatively Using Discrete Distribution Clustering, Proceedings of The Annual Meeting of the Association for Computational Linguistics (ACL), Vancouver, Canada, July 2017. Long paper. 9 | 10 | ## Quickstart 11 | 12 | Download sample datasets from the author's webpage. 13 | 14 | ``` 15 | $ wget http://infolab.stanford.edu/~wangz/project/linguistics/ACL17/acl2017dataset.zip 16 | $ unzip acl2017dataset.zip 17 | ``` 18 | 19 | Download pre-trained wordvecs, two of which are public downloadable. 20 | 21 | - glove_6B_300d.bin 22 | - GoogleNews-vectors-negative300.bin 23 | - [word2vec_400_10_10.bin](https://psu.box.com/s/bah111znok5xs6cdztddfwdc9msq33g1) 24 | 25 | Install python (version 2.7) and its dependencies. The tested versions are 26 | 27 | - numpy (1.9.2) 28 | - scipy (1.9.2) 29 | - sklearn (0.16.1) 30 | - cvxopt (1.1.7) 31 | - gensim (0.12.1) 32 | - nltk (3.0.5) 33 | - mosek (optional, 7.x) 34 | 35 | You may need adapt the code to newer versions if needed. 36 | 37 | After you configure the python environment properly, you can start from a sample dataset, say ``story_cluster.txt``, and a wordvec model, say `glove_6B_300d.bin`. The following command create d2s formated data from `story_cluster.txt`. Edit the source for adapting to other datasets. 38 | 39 | ``` 40 | $ python export_d2s.py 41 | raw categories: 54 42 | document count: 1983 43 | average words: 22 44 | (1983, 4849) 45 | ``` 46 | 47 | It creates two files: `story_cluster.d2s` and `story_cluster.d2s.vocab0`. At this point, you need to request a patent protected C/MPI software called [d2_kmeans](https://github.com/bobye/d2_kmeans). The software will take these two files are input and output clustering labels as a file named `story_cluster.d2s_[xxxxxx].label_o` in the same directory. Type the same command again to evaluate the result that was reported in the paper. 48 | 49 | ``` 50 | $ python export_d2s.py 51 | ``` 52 | 53 | 如果你想尝试重现文章中的结果,还可以看[这篇文章](https://zhuanlan.zhihu.com/p/50412488)。 54 | 55 | ---- 56 | The MIT License (MIT) 57 | 58 | Copyright (c) 2017 Jianbo Ye 59 | 60 | Permission is hereby granted, free of charge, to any person obtaining a copy 61 | of this software and associated documentation files (the "Software"), to deal 62 | in the Software without restriction, including without limitation the rights 63 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 64 | copies of the Software, and to permit persons to whom the Software is 65 | furnished to do so, subject to the following conditions: 66 | 67 | The above copyright notice and this permission notice shall be included in 68 | all copies or substantial portions of the Software. 69 | 70 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 71 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 72 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 73 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 74 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 75 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 76 | THE SOFTWARE. 77 | 78 | 79 | -------------------------------------------------------------------------------- /categoricalClustDist.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compute Categorical Cluster Distance 3 | """ 4 | 5 | #Author: Yukun Chen 6 | #Contact; cykustc@gmail.com 7 | #Copyright: Penn State University, 8 | 9 | import csv 10 | import numpy as np 11 | import sys 12 | from scipy.spatial import distance 13 | from cvxopt import matrix, solvers 14 | 15 | def read_tokens(cluster_result_file): 16 | """read clustering result from csv file (only one record each row) to a list""" 17 | with open(cluster_result_file,'rb') as csvfile: 18 | filereader=csv.reader(csvfile,delimiter=','); 19 | result=list(); 20 | for row in filereader: 21 | result.append(row[0]); 22 | return result 23 | 24 | def cluster_info(clustering_result): 25 | """return cluster number: clust_num and cluster name dictionary: cluster_dict in a tuple (clust_num,cluster_dict) 26 | """ 27 | cluster_set=list(set(clustering_result)); 28 | cluster_num=len(cluster_set); 29 | cluster_dict=dict(zip(cluster_set,range(cluster_num))); 30 | return (cluster_num,cluster_dict) 31 | 32 | def token_to_mat(tokens): 33 | cluster_num, cluster_dict = cluster_info(tokens); 34 | N = len(tokens); 35 | clus_mat = np.zeros((N,cluster_num)); 36 | for i in xrange(N): 37 | clus_mat[i][cluster_dict[tokens[i]]]=1; 38 | return clus_mat 39 | 40 | def categorical_clust_dist(clus_mat_A,clus_mat_B,method='even'): 41 | """ 42 | Compute Clustering distance from [clusters clus_mat_A with weights w_A] to [clusters clus_mat_B with weights w_B] 43 | 44 | More details please refer to Section 4.1 of "A New Mallows Distance Based 45 | Metric for Comparing Clusterings", Ding Zhou, Jia Li, Hongyuan Zha. 46 | 47 | Return a dictionary contains the Categorical Cluster Distance and matching weights {"dist":,"matching"} 48 | """ 49 | n = clus_mat_A.shape[1]; 50 | m = clus_mat_B.shape[1]; 51 | if method=='even': 52 | w_A=1.0/n*np.ones(n) 53 | w_B=1.0/m*np.ones(m) 54 | elif method=='instance_count': 55 | w_A=np.sum(clus_mat_A,axis=0) 56 | w_A=w_A/np.sum(w_A) 57 | w_B=np.sum(clus_mat_B,axis=0) 58 | w_B=w_B/np.sum(w_B) 59 | A = np.zeros((n+m,n*m)); 60 | for k in xrange(n): 61 | A[k][np.arange(k,n*m,n)]=1; 62 | for k in xrange(m): 63 | A[n+k][np.arange(k*n,k*n+n)]=1; 64 | A = A[:-1,:] 65 | D = distance.cdist(clus_mat_A.T,clus_mat_B.T,'cityblock'); #Computes the city block or Manhattan distance 66 | f = D.reshape((1,n*m)); 67 | b = np.concatenate((w_A.T,w_B.T),axis=0) 68 | b = b[:-1] 69 | c = matrix(f.T); 70 | beq = matrix(b); 71 | Aeq = matrix(A); 72 | 73 | G = matrix(-1.0*np.eye(m*n),(m*n,m*n)) 74 | h = matrix(0,(m*n,1),'d') 75 | 76 | solvers.options['show_progress'] = False 77 | sol = solvers.lp(c,G,h,A=Aeq,b=beq); 78 | x=sol['x']; 79 | #print sol 80 | x=np.array(x); 81 | x=x.reshape((n,m), order='F') 82 | return {"dist":sol['primal objective'],"matching":x} 83 | 84 | 85 | if __name__ == '__main__': 86 | clus_mat_A = token_to_mat(read_tokens('cluster_resultsA.txt')) 87 | clus_mat_B = token_to_mat(read_tokens('cluster_resultsB.txt')) 88 | # print clus_mat_A, clus_mat_B 89 | if clus_mat_A.shape[0]!=clus_mat_B.shape[0]: 90 | print "number of instances in two clustering result are not the same!"; 91 | sys.exit(0) 92 | result=categorical_clust_dist(clus_mat_A,clus_mat_B,method='instance_count') 93 | # result=categorical_clust_dist(clus_mat_A,clus_mat_A,method='even') 94 | print result['dist'] 95 | print result['matching'] 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /categoricalClustDist_mosek.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compute Categorical Cluster Distance 3 | """ 4 | 5 | #Author: Yukun Chen 6 | #Contact; cykustc@gmail.com 7 | #Copyright: Penn State University, 8 | 9 | import csv 10 | import numpy as np 11 | import sys 12 | from scipy.spatial import distance 13 | from cvxopt import matrix, spmatrix, sparse, solvers 14 | import mosek 15 | 16 | def read_tokens(cluster_result_file): 17 | """read clustering result from csv file (only one record each row) to a list""" 18 | with open(cluster_result_file,'rb') as csvfile: 19 | filereader=csv.reader(csvfile,delimiter=','); 20 | result=list(); 21 | for row in filereader: 22 | result.append(row[0]); 23 | return result 24 | 25 | def cluster_info(clustering_result): 26 | """return cluster number: clust_num and cluster name dictionary: cluster_dict in a tuple (clust_num,cluster_dict) 27 | """ 28 | cluster_set=list(set(clustering_result)); 29 | cluster_num=len(cluster_set); 30 | cluster_dict=dict(zip(cluster_set,range(cluster_num))); 31 | return (cluster_num,cluster_dict) 32 | 33 | def token_to_mat(tokens): 34 | cluster_num, cluster_dict = cluster_info(tokens); 35 | N = len(tokens); 36 | clus_mat = np.zeros((N,cluster_num)); 37 | for i in xrange(N): 38 | clus_mat[i][cluster_dict[tokens[i]]]=1; 39 | return clus_mat 40 | 41 | def categorical_clust_dist(clus_mat_A,clus_mat_B,method='even'): 42 | """ 43 | Compute Clustering distance from [clusters clus_mat_A with weights w_A] to [clusters clus_mat_B with weights w_B] 44 | 45 | More details please refer to Section 4.1 of "A New Mallows Distance Based 46 | Metric for Comparing Clusterings", Ding Zhou, Jia Li, Hongyuan Zha. 47 | 48 | Return a dictionary contains the Categorical Cluster Distance and matching weights {"dist":,"matching"} 49 | """ 50 | n = clus_mat_A.shape[1]; 51 | m = clus_mat_B.shape[1]; 52 | if method=='even': 53 | w_A=1.0/n*np.ones(n) 54 | w_B=1.0/m*np.ones(m) 55 | elif method=='instance_count': 56 | w_A=np.sum(clus_mat_A,axis=0) 57 | w_A=w_A/np.sum(w_A) 58 | w_B=np.sum(clus_mat_B,axis=0) 59 | w_B=w_B/np.sum(w_B) 60 | A = np.zeros((n+m,n*m)); 61 | for k in xrange(n): 62 | A[k][np.arange(k,n*m,n)]=1; 63 | for k in xrange(m): 64 | A[n+k][np.arange(k*n,k*n+n)]=1; 65 | A = A[:-1,:] 66 | D = distance.cdist(clus_mat_A.T,clus_mat_B.T,'cityblock'); #Computes the city block or Manhattan distance 67 | f = D.reshape((1,n*m)); 68 | b = np.concatenate((w_A.T,w_B.T),axis=0) 69 | b = b[:-1] 70 | c = matrix(f.T); 71 | beq = matrix(b); 72 | Aeq = matrix(A); 73 | 74 | G = matrix(-1.0*np.eye(m*n),(m*n,m*n)) 75 | h = matrix(0,(m*n,1),'d') 76 | 77 | solvers.options['show_progress'] = False 78 | solvers.options['mosek'] = {mosek.iparam.log: 0} 79 | solvers.options['MOSEK'] = {mosek.iparam.log: 0} 80 | sol = solvers.lp(c,G,h,A=Aeq,b=beq,solver='mosek'); 81 | x=sol['x']; 82 | #print sol 83 | x=np.array(x); 84 | x=x.reshape((n,m), order='F') 85 | return {"dist":sol['primal objective'],"matching":x} 86 | 87 | 88 | if __name__ == '__main__': 89 | clus_mat_A = token_to_mat(read_tokens('cluster_resultsA.txt')) 90 | clus_mat_B = token_to_mat(read_tokens('cluster_resultsB.txt')) 91 | # print clus_mat_A, clus_mat_B 92 | if clus_mat_A.shape[0]!=clus_mat_B.shape[0]: 93 | print "number of instances in two clustering result are not the same!"; 94 | sys.exit(0) 95 | result=categorical_clust_dist(clus_mat_A,clus_mat_B,method='instance_count') 96 | # result=categorical_clust_dist(clus_mat_A,clus_mat_A,method='even') 97 | print result['dist'] 98 | print result['matching'] 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /export_d2s.py: -------------------------------------------------------------------------------- 1 | from gensim import models 2 | from sklearn.feature_extraction.text import TfidfVectorizer 3 | from sklearn.feature_extraction.text import CountVectorizer 4 | from sklearn import metrics 5 | import numpy as np 6 | from gensim.models import word2vec,LdaModel 7 | from gensim.corpora import MmCorpus 8 | 9 | import cPickle,os,re,codecs,sys,glob 10 | 11 | from categoricalClustDist import categorical_clust_dist,token_to_mat 12 | #from spelling_corrector import correct 13 | 14 | def extract_features(in_file='story_clusters.txt',features_file='feature_words.txt', filtered_category_list=[]): 15 | f = codecs.open(in_file,'r',encoding='utf-8',errors='ignore') 16 | clusters = f.read().strip('%%%').split('\n%%%') 17 | docs = [] 18 | labels = [] 19 | word_length = 0 20 | document_count = 0 21 | for i,cluster in enumerate(clusters): 22 | if (not filtered_category_list) or (cluster.strip().split('\n')[0] in filtered_category_list): 23 | for ln in cluster.strip().split('\n')[1:]: 24 | docs.append(ln) 25 | labels.append(i) 26 | document_count = document_count + 1 27 | word_length = word_length + len(ln.split(' ')); 28 | 29 | print "raw categories: %d" % len(clusters) 30 | print "document count: %d" % document_count 31 | print "average words: %d" % (word_length / document_count) 32 | 33 | vectorizer = CountVectorizer(lowercase=True, stop_words='english', token_pattern=r"(?u)\b[a-zA-Z_][a-zA-Z_]+\b") 34 | vectorizer.fit_transform(docs) 35 | features = vectorizer.get_feature_names() 36 | fw = open(features_file,'w') 37 | fw.write(u'\n'.join(features).encode('utf-8')) 38 | fw.close() 39 | 40 | def build_word2vec_index(feature_file='feature_words.txt', word_vecs_file='GoogleNews-vectors-negative300.bin', out_dic='word_vecs.pkl'): 41 | words = open(feature_file).read().strip().split('\n') 42 | dic = dict() 43 | model = word2vec.Word2Vec.load_word2vec_format(word_vecs_file, binary=True, norm_only=False) 44 | for w in words: 45 | try: 46 | v = model[w] 47 | dic[w] = v 48 | except: 49 | continue # whatever 50 | try: 51 | v = model[correct(w)] 52 | dic[w] = v 53 | except: 54 | print w + ' not existed in word2vec model' 55 | continue 56 | fw = open(out_dic,'wb') 57 | cPickle.dump(dic, fw) 58 | fw.close() 59 | 60 | def convert_d2_format(in_file='story_clusters.txt', embedding_dic='word_vecs.pkl',embedding_dim_size=300, weighting_type='tf', d2_vocab='story_cluster.d2s.vocab0', d2s_file='story_cluster.d2s',filtered_category_list=[]): 61 | word2vec_dic = cPickle.load(open(embedding_dic,'rb')) 62 | vocab = word2vec_dic.keys() 63 | #clusters = open(in_file).read().strip('%%%').split('\n%%%') 64 | f = codecs.open(in_file,'r',encoding='utf-8',errors='ignore') 65 | clusters = f.read().strip('%%%').split('\n%%%') 66 | docs = [] 67 | labels = [] 68 | for i,cluster in enumerate(clusters): 69 | if (not filtered_category_list) or (cluster.strip().split('\n')[0] in filtered_category_list): 70 | for ln in cluster.strip().split('\n')[1:]: 71 | docs.append(ln) 72 | labels.append(i) 73 | if weighting_type == 'tf': 74 | vectorizer = CountVectorizer(lowercase=True, stop_words='english', vocabulary=vocab) 75 | if weighting_type == 'tfidf': 76 | vectorizer = TfidfVectorizer(lowercase=True, stop_words='english', vocabulary=vocab) 77 | X = vectorizer.fit_transform(docs) 78 | print X.shape 79 | fw = open(d2s_file,'w') 80 | for i in range(X.shape[0]): 81 | fw.write(str(embedding_dim_size)+'\n') 82 | nonzero_ids = X[i].nonzero() 83 | if (len(nonzero_ids[0])>0): 84 | fw.write(str(len(nonzero_ids[0]))+'\n') 85 | fw.write(' '.join(str(X[i][0,j]) for j in nonzero_ids[1])+'\n') 86 | fw.write(' '.join(str(j+1) for j in nonzero_ids[1])+'\n') 87 | else: 88 | print >> sys.stderr, "empty document found!" 89 | fw.write('1\n') 90 | fw.write('1\n') 91 | fw.write('0\n') 92 | fw.close() 93 | fw = open(d2_vocab,'w') 94 | fw.write(str(embedding_dim_size)+' '+str(len(vocab))+'\n') 95 | fw.write('\n'.join(' '.join(str(v) for v in word2vec_dic[w]) for w in vocab)) 96 | fw.close() 97 | 98 | 99 | def d2clustering_metrics(in_file='story_clusters.txt',in_label_file=None,filtered_category_list=[]): 100 | f = codecs.open(in_file, 'r', encoding='utf-8', errors='ignore') 101 | clusters = f.read().strip('%%%').split('\n%%%') 102 | labels = [] 103 | for i,cluster in enumerate(clusters): 104 | if (not filtered_category_list) or (cluster.strip().split('\n')[0] in filtered_category_list): 105 | for ln in cluster.strip().split('\n')[1:]: 106 | labels.append(i) 107 | lines = open(in_label_file).read().strip().split() 108 | d2_labels = [int(i) for i in lines] 109 | print 'number of clusters: %d' % (max(d2_labels)+1) 110 | print np.array(np.sum(token_to_mat(labels), axis=0), dtype='int32') 111 | print np.array(np.sum(token_to_mat(d2_labels), axis=0), dtype='int32') 112 | print 'Homogeneity: %0.3f' % metrics.homogeneity_score(labels, d2_labels) 113 | print 'Completeness: %0.3f' % metrics.completeness_score(labels, d2_labels) 114 | print 'V-measure: %0.3f' % metrics.v_measure_score(labels, d2_labels) 115 | print 'Normalized Mutual Information: %0.3f' % metrics.normalized_mutual_info_score(labels, d2_labels) 116 | print 'Adjusted Mutual Information: %0.3f' % metrics.adjusted_mutual_info_score(labels, d2_labels) 117 | print 'Adjusted Rand Index: %0.3f' % metrics.adjusted_rand_score(labels, d2_labels) 118 | #print 'Categorical Cluster Distance: %0.3f' % categorical_clust_dist(token_to_mat(labels), token_to_mat(d2_labels), method='instance_count')['dist'] 119 | 120 | 121 | if __name__ == '__main__': 122 | dataset = 'story' 123 | #dataset = 'bbc' 124 | #dataset = 'bbc_title' 125 | #dataset = 'bbc_abstract' 126 | #dataset = 'reuters' 127 | #dataset = 'reuters_title' 128 | #dataset = 'bbcsport' 129 | #dataset = 'bbcsport_title' 130 | #dataset = '20newsclean' 131 | #dataset = 'ohsumed_sz25' 132 | 133 | vec_dim = 400 134 | word_vecs='word2vec_400_10_10.bin' 135 | #word_vecs='glove_6B_300d.bin' 136 | #word_vecs='GoogleNews-vectors-negative300.bin' 137 | #word_vecs='ohsumed-full_50_20_2.8.bin' 138 | 139 | cluster_file = 'acl2017dataset/' + dataset + '_clusters.txt' 140 | vec_dic = dataset + '_word_vecs.pkl' 141 | 142 | reuters_r10_categories = ['acq', 'crude', 'earn', 'coffee', 'interest', 'money-fx', 'money-supply', 'ship', 'trade', 'sugar'] 143 | 144 | if dataset == 'reuters': 145 | category_list = reuters_r10_categories 146 | else: 147 | category_list = [] 148 | 149 | is_result_avail = False; 150 | for label_file in glob.glob("./"+dataset+"_*_o"): 151 | print '---------------------------------------------------' 152 | print 'Method: D2 Clustering' 153 | print 'Vocabulary Embedding: ' + word_vecs 154 | d2clustering_metrics(in_file=cluster_file, in_label_file=label_file, filtered_category_list=category_list) 155 | is_result_avail = True; 156 | 157 | if is_result_avail: 158 | sys.exit(0) 159 | 160 | 161 | if dataset.startswith('reuters'): 162 | extract_features(in_file=cluster_file, features_file='reuters.terms', filtered_category_list = reuters_r10_categories) 163 | build_word2vec_index(feature_file='reuters.terms', word_vecs_file=word_vecs, out_dic=vec_dic) 164 | convert_d2_format(in_file=cluster_file, embedding_dic=vec_dic, embedding_dim_size=vec_dim, weighting_type='tfidf', d2_vocab=dataset + '_cluster.d2s.vocab0', d2s_file=dataset + '_cluster.d2s', filtered_category_list = reuters_r10_categories) 165 | else: 166 | extract_features(in_file=cluster_file, features_file=dataset + '.terms') 167 | build_word2vec_index(feature_file=dataset + '.terms', word_vecs_file=word_vecs, out_dic=vec_dic) 168 | convert_d2_format(in_file=cluster_file, embedding_dic=vec_dic, embedding_dim_size=vec_dim, weighting_type='tfidf', d2_vocab=dataset + '_cluster.d2s.vocab0', d2s_file=dataset + '_cluster.d2s') 169 | 170 | # 171 | #build_word2vec_index() 172 | #convert_d2_format() 173 | 174 | --------------------------------------------------------------------------------