From b78c9329001809ec3f6bad871f68b03e017e8ccf Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 11:21:38 -0400 Subject: [PATCH 01/22] [MRG] Create SOM(Self-Organized Maps) algorithm --- examples/som/README.txt | 6 + examples/som/build.py | 21 + examples/som/convergence.py | 24 + examples/som/embed.py | 27 + examples/som/marginal.py | 24 + examples/som/neuron.py | 24 + examples/som/projection.py | 24 + examples/som/significance.py | 24 + examples/som/starburst.py | 24 + examples/som/topo.py | 24 + sklearn/som/som.py | 1626 ++++++++++++++++++++++++++++++++++ sklearn/som/vsom.pyd | Bin 0 -> 161208 bytes 12 files changed, 1848 insertions(+) create mode 100644 examples/som/README.txt create mode 100644 examples/som/build.py create mode 100644 examples/som/convergence.py create mode 100644 examples/som/embed.py create mode 100644 examples/som/marginal.py create mode 100644 examples/som/neuron.py create mode 100644 examples/som/projection.py create mode 100644 examples/som/significance.py create mode 100644 examples/som/starburst.py create mode 100644 examples/som/topo.py create mode 100644 sklearn/som/som.py create mode 100644 sklearn/som/vsom.pyd diff --git a/examples/som/README.txt b/examples/som/README.txt new file mode 100644 index 0000000000000..128d5c1cce311 --- /dev/null +++ b/examples/som/README.txt @@ -0,0 +1,6 @@ +.. _som_examples: + +Self Organized Map +-------------- + +Examples concerning the :mod:`sklearn.som` module. diff --git a/examples/som/build.py b/examples/som/build.py new file mode 100644 index 0000000000000..c8ede51a731c1 --- /dev/null +++ b/examples/som/build.py @@ -0,0 +1,21 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) \ No newline at end of file diff --git a/examples/som/convergence.py b/examples/som/convergence.py new file mode 100644 index 0000000000000..87e12e004ea0b --- /dev/null +++ b/examples/som/convergence.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# map quality +som.convergence(m) \ No newline at end of file diff --git a/examples/som/embed.py b/examples/som/embed.py new file mode 100644 index 0000000000000..bd56c820ca2cf --- /dev/null +++ b/examples/som/embed.py @@ -0,0 +1,27 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display the embedding accuracy of the map +som.embed(m) + +# display the embedding accuracies of the individual features +som.embed(m,verb=True) \ No newline at end of file diff --git a/examples/som/marginal.py b/examples/som/marginal.py new file mode 100644 index 0000000000000..3abc4664e4daa --- /dev/null +++ b/examples/som/marginal.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display marginal distribution of 3rd dimension +som.marginal(m,2) \ No newline at end of file diff --git a/examples/som/neuron.py b/examples/som/neuron.py new file mode 100644 index 0000000000000..89a3918b311ad --- /dev/null +++ b/examples/som/neuron.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display the neuron at position (4,4) +som.neuron(m,3,3) \ No newline at end of file diff --git a/examples/som/projection.py b/examples/som/projection.py new file mode 100644 index 0000000000000..78909799c7ad6 --- /dev/null +++ b/examples/som/projection.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display the label association for the map +som.projection(m) \ No newline at end of file diff --git a/examples/som/significance.py b/examples/som/significance.py new file mode 100644 index 0000000000000..471f8920786c9 --- /dev/null +++ b/examples/som/significance.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display the relative feature significance graphically +som.significance(m) \ No newline at end of file diff --git a/examples/som/starburst.py b/examples/som/starburst.py new file mode 100644 index 0000000000000..058013bce323a --- /dev/null +++ b/examples/som/starburst.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display the starburst for the map +som.starburst(m) \ No newline at end of file diff --git a/examples/som/topo.py b/examples/som/topo.py new file mode 100644 index 0000000000000..9bcec69c2cce3 --- /dev/null +++ b/examples/som/topo.py @@ -0,0 +1,24 @@ +import som +import pandas as pd +import vsom +from sklearn import datasets + +# import iris datasets +iris = datasets.load_iris() + +# initialize label, if there is no label set labels = None +labels = iris.target + +# initialize data +data = pd.DataFrame(iris.data[:, :4]) +data.columns = iris.feature_names + +# som written entirely in python, som_f written by fortran, +# which is much faster than python +algorithm = "som" + +# Build a map +m = som.build(data,labels,algorithm=algorithm) + +# display estimated topographical accuracy of the map +som.topo(m) \ No newline at end of file diff --git a/sklearn/som/som.py b/sklearn/som/som.py new file mode 100644 index 0000000000000..d60d6658107f4 --- /dev/null +++ b/sklearn/som/som.py @@ -0,0 +1,1626 @@ +import sys + +import numpy as np +import pandas as pd +import math +import random +import matplotlib.pyplot as plt +import seaborn as sns # Plot density by map.marginal +import vsom # Call vsom.f90 (Fortran package) +import statsmodels.stats.api as sms # t-test +import statistics as stat # F-test +from matplotlib.mlab import PCA as PCA +from random import randint # Get rotation and Standard deviations via PCA +from sklearn.metrics.pairwise import euclidean_distances +from sklearn.neighbors import KNeighborsClassifier +from scipy import stats # KS Test +from scipy.stats import f # F-test +from itertools import combinations + + +def map_build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"): + """ map_build -- construct a SOM, returns an object of class 'map' + + parameters: + - data - a dataframe where each row contains an unlabeled training + instance + - labels - a vector or dataframe with one label for each observation + in data + - xdim, ydim - the dimensions of the map + - alpha - the learning rate, should be a positive non-zero real number + - train - number of training iterations + - algorithm - selection switch + retuns: + - an object of type 'map' -- see below + + NOTE: default algorithm: "som" also available: "som_f" + + """ + algorithms = ["som","som_f"] + + # check if the dims are reasonable + if (xdim < 3 or ydim < 3): + sys.exit("map.build: map is too small.") + + try: + index_algorithm = algorithms.index(algorithm) + except ValueError: + sys.exit("map_build only supports 'som','som_f'") + + if index_algorithm == 0 : # som by Fortran + neurons = vsom_r(data, + xdim=xdim, + ydim=ydim, + alpha=alpha, + train=train) + + elif index_algorithm == 1 : # som by python + neurons = vsom_f(data, + xdim=xdim, + ydim=ydim, + alpha=alpha, + train=train) + + else: + sys.exit("map.build only supports 'som','som_f'") + + map = { 'data':data, + 'labels':labels, + 'xdim':xdim, + 'ydim':ydim, + 'alpha':alpha, + 'train':train, + 'algorithm':algorithm, + 'neurons':neurons} + + visual = [] + + for i in range(data.shape[0]): + b = best_match(map, data.iloc[[i]]) + visual.extend([b]) + + map['visual'] = visual + + return(map) + + +def map_convergence(map, conf_int=.95, k=50, verb=False, ks=False): + """ map.convergence - the convergence index of a map + + parameters: + - map is an object if type 'map' + - conf_int is the confidence interval of the quality assessment (default 95%) + - k is the number of samples used for the estimated topographic accuracy computation + - verb if true reports the two convergence components separately, otherwise it will + report the linear combination of the two + - ks is a switch, true for ks-test, false for standard var and means test + + - return value is the convergence index + """ + if ks: + embed = map_embed_ks(map, conf_int, verb=False) + else: + embed = map_embed_vm(map, conf_int, verb=False) + + topo = map_topo(map, k, conf_int, verb=False, interval=False) + + if verb: + return {"embed": embed, "topo": topo} + else: + return (0.5*embed + 0.5*topo) + + +def map_embed(map, conf_int=.95, verb=False, ks=False): + """ map.embed - evaluate the embedding of a map using the F-test and + a Bayesian estimate of the variance in the training data. + parameters: + - map is an object if type 'map' + - conf_int is the confidence interval of the convergence test (default 95%) + - verb is switch that governs the return value false: single convergence value + is returned, true: a vector of individual feature congences is returned. + + - return value is the cembedding of the map (variance captured by the map so far) + + Hint: the embedding index is the variance of the training data captured by the map; + maps with convergence of less than 90% are typically not trustworthy. Of course, + the precise cut-off depends on the noise level in your training data. + """ + + if ks: + return map_embed_ks(map, conf_int, verb) + else: + return map_embed_vm(map, conf_int, verb) + + +def map_topo(map, k=50, conf_int=.95, verb=False, interval=True): + """ map_topo - measure the topographic accuracy of the map using sampling + + parameters: + - map is an object if type 'map' + - k is the number of samples used for the accuracy computation + - conf.int is the confidence interval of the accuracy test (default 95%) + - verb is switch that governs the return value, false: single accuracy value + is returned, true: a vector of individual feature accuracies is returned. + - interval is a switch that controls whether the confidence interval is computed. + + return value is the estimated topographic accuracy + + """ + + # data.df is a matrix that contains the training data + data_df = map['data'] + + if (k > data_df.shape[0]): + sys.exit("map_topo: sample larger than training data.") + + data_sample_ix = [randint(1, data_df.shape[0]) for _ in range(k)] + + # compute the sum topographic accuracy - the accuracy of a single sample + # is 1 if the best matching unit is a neighbor otherwise it is 0 + acc_v = [] + for i in range(k): + acc_v.append(accuracy(map, data_df.iloc[data_sample_ix[i]-1], data_sample_ix[i])) + + # ########################################################################### + # # + # Notice: if you see the system Error, please remove the -1 above # + # # + # ########################################################################### + # compute the confidence interval values using the bootstrap + if interval: + bval = bootstrap(map, conf_int, data_df, k, acc_v) + + # the sum topographic accuracy is scaled by the number of samples - estimated + # topographic accuracy + if verb: + return acc_v + else: + val = np.sum(acc_v)/k + if interval: + return {'val': val, 'lo': bval['lo'], 'hi': bval['hi']} + else: + return val + + +def map_starburst(map, explicit=False, smoothing=2, merge_clusters=True, merge_range=.25): + """ map_starburst - compute and display the starburst representation of clusters + + parameters: + - map is an object if type 'map' + - explicit controls the shape of the connected components + - smoothing controls the smoothing level of the umat (NULL,0,>0) + - merge_clusters is a switch that controls if the starburst clusters are merged together + - merge_range - a range that is used as a percentage of a certain distance in the code + to determine whether components are closer to their centroids or + centroids closer to each other. + + """ + + umat = compute_umat(map, smoothing=smoothing) + plot_heat(map, umat, explicit=explicit, comp=True, merge=merge_clusters, merge_range=merge_range) + + +def map_projection(map): + """ map_projection - print the association of labels with map elements + + parameters: + - map is an object if type 'map' + + return values: + - a dataframe containing the projection onto the map for each observation + + """ + + # if not (map['labels'] is None) and (len(map['labels']) != 0): + # sys.exit("map.projection: no labels available") + + labels_v = map['labels'] + x_v = [] + y_v = [] + + for i in range(len(labels_v)): + + ix = map['visual'][i] + coord = coordinate(map, ix) + x_v.append(coord[0]) + y_v.append(coord[1]) + + return pd.DataFrame({'labels': labels_v, 'x': x_v, 'y': y_v}) + + +def map_significance(map, graphics=True, feature_labels=False): + """ map_significance - compute the relative significance of each feature and plot it + + parameters: + - map is an object if type 'map' + - graphics is a switch that controls whether a plot is generated or not + - feature.labels is a switch to allow the plotting of feature names vs feature indices + + return value: + - a vector containing the significance for each feature + + """ + + data_df = map['data'] + nfeatures = data_df.shape[1] + + # Compute the variance of each feature on the map + var_v = [randint(1, 1) for _ in range(nfeatures)] + + for i in range(nfeatures): + var_v[i] = np.var(np.array(data_df)[:, i]) + + # we use the variance of a feature as likelihood of + # being an important feature, compute the Bayesian + # probability of significance using uniform priors + + var_sum = np.sum(var_v) + prob_v = var_v/var_sum + + # plot the significance + if graphics: + y = max(prob_v) + + plt.axis([0, nfeatures+1, 0, y]) + + x = np.arange(1, nfeatures+1) + tag = list(data_df) + + plt.xticks(x, tag) + plt.yticks = np.linspace(0, y, 5) + + i = 1 + for xc in prob_v: + plt.axvline(x=i, ymin=0, ymax=xc) + i += 1 + + plt.xlabel('Features') + plt.ylabel('Significance') + plt.show() + else: + return prob_v + + +def map_neuron(map, x, y): + """ map_neuron - returns the contents of a neuron at (x, y) on the map as a vector + + parameters: + map - the neuron map + x - map x-coordinate of neuron + y - map y-coordinate of neuron + + return value: + a vector representing the neuron + + """ + + ix = rowix(map, x, y) + return map['neurons'][ix] + + +def map_marginal(map, marginal): + """ map_marginal - plot that shows the marginal probability distribution of the neurons and data + + parameters: + - map is an object of type 'map' + - marginal is the name of a training data frame dimension or index + + """ + + # check if the second argument is of type character + if type(marginal) == str and marginal in list(map['data']): + + f_ind = list(map['data']).index(marginal) + f_name = marginal + train = np.matrix(map['data'])[:, f_ind] + neurons = map['neurons'][:, f_ind] + plt.ylabel('Density') + plt.xlabel(f_name) + sns.kdeplot(np.ravel(train), label="training data", shade=True, color="b") + sns.kdeplot(neurons, label="neurons", shade=True, color="r") + plt.legend(fontsize=15) + plt.show() + + elif type(marginal) == int and marginal < map['ydim'] - 1 and marginal >= 0: + + f_ind = marginal + f_name = list(map['data'])[marginal] + train = np.matrix(map['data'])[:, f_ind] + neurons = map['neurons'][:, f_ind] + plt.ylabel('Density') + plt.xlabel(f_name) + sns.kdeplot(np.ravel(train), label="training data", shade=True, color="b") + sns.kdeplot(neurons, label="neurons", shade=True, color="r") + plt.legend(fontsize=15) + plt.show() + + else: + sys.exit("map.marginal: second argument is not the name of a training data frame dimension or index") + +# --------------------- local functions ---------------------------# + + +def map_embed_vm(map, conf_int=.95, verb=False): + """ map_embed_vm -- using variance test and mean test to evaluate the map quality """ + + # map_df is a dataframe that contains the neurons + map_df = map['neurons'] + + # data_df is a dataframe that contain the training data + data_df = np.array(map['data']) + + # do the F-test on a pair of datasets + vl = df_var_test1(map_df, data_df, conf_int) + + # do the t-test on a pair of datasets + ml = df_mean_test(map_df, data_df, conf=conf_int) + + # compute the variance captured by the map -- but only if the means have converged as well. + nfeatures = map_df.shape[1] + prob_v = map_significance(map, graphics=False) + var_sum = 0 + + for i in range(nfeatures): + + if (vl['conf_int_lo'][i] <= 1.0 and vl['conf_int_hi'][i] >= 1.0 and ml['conf_int_lo'][i] <= 0.0 and ml['conf_int_hi'][i] >= 0.0): + var_sum = var_sum + prob_v[i] + else: + # not converged - zero out the probability + prob_v[i] = 0 + + # return the variance captured by converged features + if verb: + return prob_v + else: + return var_sum + + +def map_embed_ks(map, conf_int=0.95, verb=False): + """ map_embed_ks -- using the kolgomorov-smirnov test to evaluate the map quality """ + + # map_df is a dataframe that contains the neurons + map_df = map['neurons'] + + # data_df is a dataframe that contain the training data + data_df = np.array(map['data']) + + nfeatures = map_df.shape[1] + + # use the Kolmogorov-Smirnov Test to test whether the neurons and training data appear + # to come from the same distribution + ks_vector = [] + for i in range(nfeatures): + ks_vector.append(stats.mstats.ks_2samp(map_df[:, i], data_df[:, i])) + + prob_v = map_significance(map, graphics=False) + var_sum = 0 + + # compute the variance captured by the map + for i in range(nfeatures): + + # the second entry contains the p-value + if ks_vector[i][1] > (1 - conf_int): + var_sum = var_sum + prob_v[i] + else: + # not converged - zero out the probability + prob_v[i] = 0 + + # return the variance captured by converged features + if verb: + return prob_v + else: + return var_sum + + +def bootstrap(map, conf_int, data_df, k, sample_acc_v): + """ bootstrap -- compute the topographic accuracies for the given confide """ + + ix = int(100 - conf_int*100) + bn = 200 + + bootstrap_acc_v = [np.sum(sample_acc_v)/k] + + for i in range(2, bn+1): + + bs_v = np.array([randint(1, k) for _ in range(k)])-1 + a = np.sum(list(np.array(sample_acc_v)[list(bs_v)]))/k + bootstrap_acc_v.append(a) + + bootstrap_acc_sort_v = np.sort(bootstrap_acc_v) + + lo_val = bootstrap_acc_sort_v[ix-1] + hi_val = bootstrap_acc_sort_v[bn-ix-1] + + return {'lo': lo_val, 'hi': hi_val} + + +def best_match(map, obs, full=False): + """ best_match -- given observation obs, return the best matching neuron """ + + # NOTE: replicate obs so that there are nr rows of obs + obs_m = np.tile(obs, (map['neurons'].shape[0], 1)) + diff = map['neurons'] - obs_m + squ = diff * diff + s = np.sum(squ, axis=1) + d = np.sqrt(s) + o = np.argsort(d) + + if full: + return o + else: + return o[0] + + +def accuracy(map, sample, data_ix): + """ accuracy -- the topographic accuracy of a single sample is 1 is the best matching unit + and the second best matching unit are are neighbors otherwise it is 0 + + """ + + o = best_match(map, sample, full=True) + best_ix = o[0] + second_best_ix = o[1] + + # sanity check + coord = coordinate(map, best_ix) + coord_x = coord[0] + coord_y = coord[1] + + map_ix = map['visual'][data_ix-1] + coord = coordinate(map, map_ix) + map_x = coord[0] + map_y = coord[1] + + if (coord_x != map_x or coord_y != map_y or best_ix != map_ix): + print("Error: best_ix: ", best_ix, " map_ix: ", map_ix, "\n") + + # determine if the best and second best are neighbors on the map + best_xy = coordinate(map, best_ix) + second_best_xy = coordinate(map, second_best_ix) + diff_map = np.array(best_xy) - np.array(second_best_xy) + diff_map_sq = diff_map * diff_map + sum_map = np.sum(diff_map_sq) + dist_map = np.sqrt(sum_map) + + # it is a neighbor if the distance on the map + # between the bmu and 2bmu is less than 2, should be 1 or 1.414 + if dist_map < 2: + return 1 + else: + return 0 + + +def coordinate(map, rowix): + """ coordinate -- convert from a row index to a map xy-coordinate """ + x = (rowix) % map['xdim'] + y = (rowix) // map['xdim'] + return [x, y] + + +def rowix(map, x, y): + """ rowix -- convert from a map xy-coordinate to a row index """ + rix = x + y*map['xdim'] + return rix + + +def plot_heat(map, heat, explicit=False, comp=True, merge=False, merge_range=0.25): + + """ plot_heat - plot a heat map based on a 'map', this plot also contains the connected + components of the map based on the landscape of the heat map + + parameters: + - map is an object if type 'map' + - heat is a 2D heat map of the map returned by 'map' + - labels is a vector with labels of the original training data set + - explicit controls the shape of the connected components + - comp controls whether we plot the connected components on the heat map + - merge controls whether we merge the starbursts together. + - merge_range - a range that is used as a percentage of a certain distance in the code + to determine whether components are closer to their centroids or + centroids closer to each other. + + """ + + # keep an unaltered copy of the unified distance matrix, + # required for merging the starburst clusters + umat = heat + + x = map['xdim'] + y = map['ydim'] + nobs = map['data'].shape[0] + count = np.matrix([[0]*y]*x) + + # need to make sure the map doesn't have a dimension of 1 + if (x <= 1 or y <= 1): + sys.exit("plot.heat: map dimensions too small") + + tmp = pd.cut(heat, bins=100, labels=False) + + tmp_1 = np.array(np.matrix.transpose(tmp)) + fig, ax = plt.subplots() + ax.pcolor(tmp_1, cmap=plt.cm.YlOrRd) + ax.set_xticks(np.arange(x)+0.5, minor=False) + ax.set_yticks(np.arange(y)+0.5, minor=False) + plt.xlabel("x") + plt.ylabel("y") + ax.set_xticklabels(np.arange(x), minor=False) + ax.set_yticklabels(np.arange(y), minor=False) + ax.xaxis.set_tick_params(labeltop='on') + ax.yaxis.set_tick_params(labelright='on') + + # put the connected component lines on the map + if comp: + if not merge: + # find the centroid for each neuron on the map + centroids = compute_centroids(map, heat, explicit) + else: + # find the unique centroids for the neurons on the map + centroids = compute_combined_clusters(map, umat, explicit, merge_range) + + # connect each neuron to its centroid + for ix in range(x): + for iy in range(y): + cx = centroids['centroid_x'][ix, iy] + cy = centroids['centroid_y'][ix, iy] + plt.plot([ix+0.5, cx+0.5], [iy+0.5, cy+0.5], color='grey', linestyle='-', linewidth=1.0) + + # put the labels on the map if available + if not (map['labels'] is None) and (len(map['labels']) != 0): + + # count the labels in each map cell + for i in range(nobs): + + nix = map['visual'][i] + c = coordinate(map, nix) + ix = c[0] + iy = c[1] + + count[ix-1, iy-1] = count[ix-1, iy-1]+1 + + for i in range(nobs): + + c = coordinate(map, map['visual'][i]) + ix = c[0] + iy = c[1] + + # we only print one label per cell + if count[ix-1, iy-1] > 0: + + count[ix-1, iy-1] = 0 + ix = ix - .5 + iy = iy - .5 + l = map['labels'][i] + plt.text(ix+1, iy+1, l) + + plt.show() + + +def compute_centroids(map, heat, explicit=False): + """ compute_centroids -- compute the centroid for each point on the map + + parameters: + - map is an object if type 'map' + - heat is a matrix representing the heat map representation + - explicit controls the shape of the connected component + + return value: + - a list containing the matrices with the same x-y dims as the original + map containing the centroid x-y coordinates + + """ + + xdim = map['xdim'] + ydim = map['ydim'] + centroid_x = np.matrix([[-1] * ydim for _ in range(xdim)]) + centroid_y = np.matrix([[-1] * ydim for _ in range(xdim)]) + + heat = np.matrix(heat) + + def compute_centroid(ix, iy): + + if (centroid_x[ix, iy] > -1) and (centroid_y[ix, iy] > -1): + return {"bestx": centroid_x[ix, iy], "besty": centroid_y[ix, iy]} + + min_val = heat[ix, iy] + min_x = ix + min_y = iy + + # (ix, iy) is an inner map element + if ix > 0 and ix < xdim-1 and iy > 0 and iy < ydim-1: + + if heat[ix-1, iy-1] < min_val: + min_val = heat[ix-1, iy-1] + min_x = ix-1 + min_y = iy-1 + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix+1, iy-1] < min_val: + min_val = heat[ix+1, iy-1] + min_x = ix+1 + min_y = iy-1 + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + if heat[ix+1, iy+1] < min_val: + min_val = heat[ix+1, iy+1] + min_x = ix+1 + min_y = iy+1 + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + if heat[ix-1, iy+1] < min_val: + min_val = heat[ix-1, iy+1] + min_x = ix-1 + min_y = iy+1 + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # (ix, iy) is bottom left corner + elif ix == 0 and iy == 0: + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + if heat[ix+1, iy+1] < min_val: + min_val = heat[ix+1, iy+1] + min_x = ix+1 + min_y = iy+1 + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + # (ix, iy) is bottom right corner + elif ix == xdim-1 and iy == 0: + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + if heat[ix-1, iy+1] < min_val: + min_val = heat[ix-1, iy+1] + min_x = ix-1 + min_y = iy+1 + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # (ix, iy) is top right corner + elif ix == xdim-1 and iy == ydim-1: + + if heat[ix-1, iy-1] < min_val: + min_val = heat[ix-1, iy-1] + min_x = ix-1 + min_y = iy-1 + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # (ix, iy) is top left corner + elif ix == 0 and iy == ydim-1: + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix+1, iy-1] < min_val: + min_val = heat[ix+1, iy-1] + min_x = ix+1 + min_y = iy-1 + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + # (ix, iy) is a left side element + elif ix == 0 and iy > 0 and iy < ydim-1: + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix+1, iy-1] < min_val: + min_val = heat[ix+1, iy-1] + min_x = ix+1 + min_y = iy-1 + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + if heat[ix+1, iy+1] < min_val: + min_val = heat[ix+1, iy+1] + min_x = ix+1 + min_y = iy+1 + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + # (ix, iy) is a bottom side element + elif ix > 0 and ix < xdim-1 and iy == 0: + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + if heat[ix+1, iy+1] < min_val: + min_val = heat[ix+1, iy+1] + min_x = ix+1 + min_y = iy+1 + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + if heat[ix-1, iy+1] < min_val: + min_val = heat[ix-1, iy+1] + min_x = ix-1 + min_y = iy+1 + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # (ix, iy) is a right side element + elif ix == xdim-1 and iy > 0 and iy < ydim-1: + + if heat[ix-1, iy-1] < min_val: + min_val = heat[ix-1, iy-1] + min_x = ix-1 + min_y = iy-1 + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix, iy+1] < min_val: + min_val = heat[ix, iy+1] + min_x = ix + min_y = iy+1 + + if heat[ix-1, iy+1] < min_val: + min_val = heat[ix-1, iy+1] + min_x = ix-1 + min_y = iy+1 + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # (ix, iy) is a top side element + elif ix > 0 and ix < xdim-1 and iy == ydim-1: + + if heat[ix-1, iy-1] < min_val: + min_val = heat[ix-1, iy-1] + min_x = ix-1 + min_y = iy-1 + + if heat[ix, iy-1] < min_val: + min_val = heat[ix, iy-1] + min_x = ix + min_y = iy-1 + + if heat[ix+1, iy-1] < min_val: + min_val = heat[ix+1, iy-1] + min_x = ix+1 + min_y = iy-1 + + if heat[ix+1, iy] < min_val: + min_val = heat[ix+1, iy] + min_x = ix+1 + min_y = iy + + if heat[ix-1, iy] < min_val: + min_val = heat[ix-1, iy] + min_x = ix-1 + min_y = iy + + # if successful + # move to the square with the smaller value, i_e_, call compute_centroid on this new square + # note the RETURNED x-y coords in the centroid.x and centroid.y matrix at the current location + # return the RETURNED x-y coordinates + if min_x != ix or min_y != iy: + r_val = compute_centroid(min_x, min_y) + + # if explicit is set show the exact connected component + # otherwise construct a connected componenent where all + # nodes are connected to a centrol node + if explicit: + + centroid_x[ix, iy] = min_x + centroid_y[ix, iy] = min_y + return {"bestx": min_x, "besty": min_y} + + else: + centroid_x[ix, iy] = r_val['bestx'] + centroid_y[ix, iy] = r_val['besty'] + return r_val + + else: + centroid_x[ix, iy] = ix + centroid_y[ix, iy] = iy + return {"bestx": ix, "besty": iy} + + for i in range(xdim): + for j in range(ydim): + compute_centroid(i, j) + + return {"centroid_x": centroid_x, "centroid_y": centroid_y} + + +def compute_umat(map, smoothing=None): + """ compute_umat -- compute the unified distance matrix + + parameters: + - map is an object if type 'map' + - smoothing is either NULL, 0, or a positive floating point value controlling the + smoothing of the umat representation + return value: + - a matrix with the same x-y dims as the original map containing the umat values + + """ + + d = euclidean_distances(map['neurons'], map['neurons']) + umat = compute_heat(map, d, smoothing) + + return umat + + +def compute_heat(map, d, smoothing=None): + """ compute_heat -- compute a heat value map representation of the given distance matrix + + parameters: + - map is an object if type 'map' + - d is a distance matrix computed via the 'dist' function + - smoothing is either NULL, 0, or a positive floating point value controlling the + smoothing of the umat representation + return value: + - a matrix with the same x-y dims as the original map containing the heat + + """ + + x = map['xdim'] + y = map['ydim'] + heat = np.matrix([[0.0] * y for _ in range(x)]) + + if x == 1 or y == 1: + sys.exit("compute_heat: heat map can not be computed for a map with a dimension of 1") + + # this function translates our 2-dim map coordinates + # into the 1-dim coordinates of the neurons + def xl(ix, iy): + + return ix + iy * x # Python start with 0, so we should minus 1 at the end + + # check if the map is larger than 2 x 2 (otherwise it is only corners) + if x > 2 and y > 2: + # iterate over the inner nodes and compute their umat values + for ix in range(1, x-1): + for iy in range(1, y-1): + sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/8 + + # iterate over bottom x axis + for ix in range(1, x-1): + iy = 0 + sum = d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/5 + + # iterate over top x axis + for ix in range(1, x-1): + iy = y-1 + sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/5 + + # iterate over the left y-axis + for iy in range(1, y-1): + ix = 0 + sum = d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + heat[ix, iy] = sum/5 + + # iterate over the right y-axis + for iy in range(1, y-1): + ix = x-1 + sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/5 + + # compute umat values for corners + if x >= 2 and y >= 2: + # bottom left corner + ix = 0 + iy = 0 + sum = d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + heat[ix, iy] = sum/3 + + # bottom right corner + ix = x-1 + iy = 0 + sum = d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/3 + + # top left corner + ix = 0 + iy = y-1 + sum = d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + heat[ix, iy] = sum/3 + + # top right corner + ix = x-1 + iy = y-1 + sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix-1, iy)] + heat[ix, iy] = sum/3 + + # smooth the heat map + pts = [] + + for i in range(y): + for j in range(x): + pts.extend([[j, i]]) + + if smoothing is not None: + if smoothing == 0: + heat = smooth_2d(heat, nrow=x, ncol=y, surface=False) + elif smoothing > 0: + heat = smooth_2d(heat, nrow=x, ncol=y, surface=False, theta=smoothing) + else: + sys.exit("compute.heat: bad value for smoothing parameter") + + return heat + + +def df_var_test1(df1, df2, conf): + """ df_var_test -- a function that applies the F-test testing the ratio + of the variances of the two data frames + + parameters: + - df1,df2 - data frames with the same number of columns + - conf - confidence level for the F-test (default .95) + + """ + + if df1.shape[1] != df2.shape[1]: + sys.exit("df.var.test: cannot compare variances of data frames") + + # init our working arrays + var_ratio_v = [randint(1, 1) for _ in range(df1.shape[1])] + var_confintlo_v = [randint(1, 1) for _ in range(df1.shape[1])] + var_confinthi_v = [randint(1, 1) for _ in range(df1.shape[1])] + + def var_test(x, y, ratio=1, conf_level=0.95): + + DF_x = len(x) - 1 + DF_y = len(y) - 1 + V_x = stat.variance(x.tolist()) + V_y = stat.variance(y.tolist()) + + ESTIMATE = V_x / V_y + + BETA = (1 - conf_level) / 2 + CINT = [ESTIMATE / f.ppf(1 - BETA, DF_x, DF_y), ESTIMATE / f.ppf(BETA, DF_x, DF_y)] + + return {"estimate": ESTIMATE, "conf_int": CINT} + + # compute the F-test on each feature in our populations + for i in range(df1.shape[1]): + + t = var_test(df1[:, i], df2[:, i], conf_level=conf) + var_ratio_v[i] = t['estimate'] + var_confintlo_v[i] = t['conf_int'][0] + var_confinthi_v[i] = t['conf_int'][1] + + # return a list with the ratios and conf intervals for each feature + return {"ratio": var_ratio_v, "conf_int_lo": var_confintlo_v, "conf_int_hi": var_confinthi_v} + + +def df_mean_test(df1, df2, conf=0.95): + + """ df_mean_test -- a function that applies the t-test testing the difference + of the means of the two data frames + + parameters: + - df1,df2 - data frames with the same number of columns + - conf - confidence level for the t-test (default .95) + + """ + + if df1.shape[1] != df2.shape[1]: + sys.exit("df.mean.test: cannot compare means of data frames") + + # init our working arrays + mean_diff_v = [randint(1, 1) for _ in range(df1.shape[1])] + mean_confintlo_v = [randint(1, 1) for _ in range(df1.shape[1])] + mean_confinthi_v = [randint(1, 1) for _ in range(df1.shape[1])] + + def t_test(x, y, conf_level=0.95): + estimate_x = np.mean(x) + estimate_y = np.mean(y) + cm = sms.CompareMeans(sms.DescrStatsW(x), sms.DescrStatsW(y)) + conf_int_lo = cm.tconfint_diff(alpha=1-conf_level, usevar='unequal')[0] + conf_int_hi = cm.tconfint_diff(alpha=1-conf_level, usevar='unequal')[1] + + return {"estimate": [estimate_x, estimate_y], "conf_int": [conf_int_lo, conf_int_hi]} + + # compute the F-test on each feature in our populations + for i in range(df1.shape[1]): + t = t_test(x=df1[:, i], y=df2[:, i], conf_level=conf) + mean_diff_v[i] = t['estimate'][0] - t['estimate'][1] + mean_confintlo_v[i] = t['conf_int'][0] + mean_confinthi_v[i] = t['conf_int'][1] + + # return a list with the ratios and conf intervals for each feature + return {"diff": mean_diff_v, "conf_int_lo": mean_confintlo_v, "conf_int_hi": mean_confinthi_v} + + +def vsom_r(data, xdim, ydim, alpha, train): + """ vsom_r - vectorized, unoptimized version of the stochastic SOM + training algorithm written entirely in R + + """ + # some constants + dr = data.shape[0] + dc = data.shape[1] + nr = xdim*ydim + nc = dc # dim of data and neurons is the same + + # build and initialize the matrix holding the neurons + cells = nr * nc # No. of neurons times number of data dimensions + + # vector with small init values for all neurons + v = np.random.uniform(-1, 1, cells) + + # NOTE: each row represents a neuron, each column represents a dimension. + neurons = np.reshape(v, (nr, nc)) # rearrange the vector as matrix + + # compute the initial neighborhood size and step + nsize = max(xdim, ydim) + 1 + nsize_step = np.ceil(train/nsize) + step_counter = 0 # counts the number of epochs per nsize_step + + # convert a 1D rowindex into a 2D map coordinate + def coord2D(rowix): + + x = (np.array(rowix) - 1) % xdim + y = (np.array(rowix) - 1) // xdim + + return np.concatenate((x, y)) + + # constants for the Gamma function + m = [i for i in range(1, nr+1)] # a vector with all neuron 1D addresses + + # x-y coordinate of ith neuron: m2Ds[i,] = c(xi, yi) + m2Ds = np.matrix.transpose(coord2D(m).reshape(2, nr)) + + # neighborhood function + def Gamma(c): + + # lookup the 2D map coordinate for c + c2D = m2Ds[c, ] + # a matrix with each row equal to c2D + c2Ds = np.outer(np.linspace(1, 1, nr), c2D) + # distance vector of each neuron from c in terms of map coords! + d = np.sqrt(np.dot((c2Ds - m2Ds)**2, [1, 1])) + # if m on the grid is in neigh then alpha else 0.0 + hood = np.where(d < nsize*1.5, alpha, 0.0) + + return hood + + # training # + # the epochs loop + for epoch in range(1, train): + + # hood size decreases in disrete nsize.steps + step_counter = step_counter + 1 + if step_counter == nsize_step: + + step_counter = 0 + nsize = nsize - 1 + + # create a sample training vector + ix = randint(0, dr-1) + xk = data.iloc[[ix]] + + # competitive step + xk_m = np.outer(np.linspace(1, 1, nr), xk) + + diff = neurons - xk_m + squ = diff * diff + s = np.dot(squ, np.linspace(1, 1, nc)) + o = np.argsort(s) + c = o[0] + + # update step + gamma_m = np.outer(Gamma(c), np.linspace(1, 1, nc)) + neurons = neurons - diff * gamma_m + + return neurons + + +def vsom_f(data, xdim, ydim, alpha, train): + """ vsom_f - vectorized and optimized version of the stochastic SOM + training algorithm written in Fortran90 + + """ + + # some constants + dr = data.shape[0] + dc = data.shape[1] + nr = xdim*ydim + nc = dc # dim of data and neurons is the same + + # build and initialize the matrix holding the neurons + cells = nr * nc # no. of neurons times number of data dimensions + v = np.random.uniform(-1, 1, cells) # vector with small init values for all neurons + + # NOTE: each row represents a neuron, each column represents a dimension. + neurons = np.reshape(v, (nr, nc)) # rearrange the vector as matrix + + neurons = vsom.vsom(neurons, np.array(data), xdim, ydim, alpha, train, dr, dc) + + return neurons + + +def compute_combined_clusters(map, heat, explicit, rang): + """ compute_combined_clusters -- to Combine connected components that + represent the same cluster + + TOP LEVEL FOR DISTANCE MATRIX ORIENTED CLUSTER COMBINE + """ + + # compute the connected components + centroids = compute_centroids(map, heat, explicit) + # Get unique centroids + unique_centroids = get_unique_centroids(map, centroids) + # Get distance from centroid to cluster elements for all centroids + within_cluster_dist = distance_from_centroids(map, centroids, unique_centroids, heat) + # Get average pairwise distance between clusters + between_cluster_dist = distance_between_clusters(map, centroids, unique_centroids, heat) + # Get a boolean matrix of whether two components should be combined + combine_cluster_bools = combine_decision(within_cluster_dist, between_cluster_dist, rang) + # Create the modified connected components grid + ne_centroid = new_centroid(combine_cluster_bools, centroids, unique_centroids, map) + + return ne_centroid + + +def get_unique_centroids(map, centroids): + """ get_unique_centroids -- a function that computes a list of unique + centroids from a matrix of centroid + locations. + + parameters: + - map is an object of type 'map' + - centroids - a matrix of the centroid locations in the map + + """ + + # get the dimensions of the map + xdim = map['xdim'] + ydim = map['ydim'] + xlist = [] + ylist = [] + x_centroid = centroids['centroid_x'] + y_centroid = centroids['centroid_y'] + + for ix in range(xdim): + for iy in range(ydim): + cx = x_centroid[ix, iy] + cy = y_centroid[ix, iy] + + # Check if the x or y of the current centroid is not in the list + # and if not + # append both the x and y coordinates to the respective lists + if not(cx in xlist) or not(cy in ylist): + xlist.append(cx) + ylist.append(cy) + + # return a list of unique centroid positions + return {"position_x": xlist, "position_y": ylist} + + +def distance_from_centroids(map, centroids, unique_centroids, heat): + """ distance_from_centroids -- A function to get the average distance + from centroid by cluster. + + parameters: + - map is an object of type 'map' + - centroids - a matrix of the centroid locations in the map + - unique.centroids - a list of unique centroid locations + - heat is a unified distance matrix + + """ + + centroids_x_positions = unique_centroids['position_x'] + centroids_y_positions = unique_centroids['position_y'] + within = [] + + for i in range(len(centroids_x_positions)): + cx = centroids_x_positions[i] + cy = centroids_y_positions[i] + + # compute the average distance + distance = cluster_spread(cx, cy, np.matrix(heat), centroids, map) + + # append the computed distance to the list of distances + within.append(distance) + + return within + + +def cluster_spread(x, y, umat, centroids, map): + """ cluster_spread -- Function to calculate the average distance in + one cluster given one centroid. + + parameters: + - x - x position of a unique centroid + - y - y position of a unique centroid + - umat - a unified distance matrix + - centroids - a matrix of the centroid locations in the map + - map is an object of type 'map' + + """ + + centroid_x = x + centroid_y = y + sum = 0 + elements = 0 + xdim = map['xdim'] + ydim = map['ydim'] + centroid_weight = umat[centroid_x, centroid_y] + + for xi in range(xdim): + for yi in range(ydim): + cx = centroids['centroid_x'][xi, yi] + cy = centroids['centroid_y'][xi, yi] + + if(cx == centroid_x and cy == centroid_y): + cweight = umat[xi, yi] + sum = sum + abs(cweight - centroid_weight) + elements = elements + 1 + + average = sum / elements + + return average + + +def distance_between_clusters(map, centroids, unique_centroids, umat): + """ distance_between_clusters -- A function to compute the average + pairwise distance between clusters. + + parameters: + - map is an object of type 'map' + - centroids - a matrix of the centroid locations in the map + - unique.centroids - a list of unique centroid locations + - umat - a unified distance matrix + + """ + + cluster_elements = list_clusters(map, centroids, unique_centroids, umat) + + tmp_1 = np.zeros(shape=(max([len(cluster_elements[i]) for i in range(len(cluster_elements))]), len(cluster_elements))) + # tmp_2 = np.zeros(shape=(max([len(cluster_elements[i]) for i in range(len(cluster_elements))]),len(cluster_elements))) + + for i in range(len(cluster_elements)): + for j in range(len(cluster_elements[i])): + tmp_1[j, i] = cluster_elements[i][j] + + columns = tmp_1.shape[1] + + tmp = np.matrix.transpose(np.array(list(combinations([i for i in range(columns)], 2)))) + + tmp_3 = np.zeros(shape=(tmp_1.shape[0], tmp.shape[1])) + + for i in range(tmp.shape[1]): + tmp_3[:, i] = np.where(tmp_1[:, tmp[1, i]]*tmp_1[:, tmp[0, i]] != 0, abs(tmp_1[:, tmp[0, i]] - tmp_1[:, tmp[1, i]]), 0) + # both are not equals 0 + + mean = np.true_divide(tmp_3.sum(0), (tmp_3 != 0).sum(0)) + index = 0 + mat = np.zeros((columns, columns)) + + for xi in range(columns-1): + for yi in range(xi, columns-1): + mat[xi, yi + 1] = mean[index] + mat[yi + 1, xi] = mean[index] + index = index + 1 + + return mat + + +def list_clusters(map, centroids, unique_centroids, umat): + """ list_clusters -- A function to get the clusters as a list of lists. + + parameters: + - map is an object of type 'map' + - centroids - a matrix of the centroid locations in the map + - unique.centroids - a list of unique centroid locations + - umat - a unified distance matrix + + """ + + centroids_x_positions = unique_centroids['position_x'] + centroids_y_positions = unique_centroids['position_y'] + cluster_list = [] + + for i in range(len(centroids_x_positions)): + cx = centroids_x_positions[i] + cy = centroids_y_positions[i] + + # get the clusters associated with a unique centroid and store it in a list + cluster_list.append(list_from_centroid(map, cx, cy, centroids, umat)) + + return cluster_list + + +def list_from_centroid(map, x, y, centroids, umat): + """ list.from.centroid -- A funtion to get all cluster elements + associated to one centroid. + + parameters: + - map is an object of type 'map' + - x - the x position of a centroid + - y - the y position of a centroid + - centroids - a matrix of the centroid locations in the map + - umat - a unified distance matrix + + """ + + centroid_x = x + centroid_y = y + xdim = map['xdim'] + ydim = map['ydim'] + + cluster_list = [] + for xi in range(xdim): + for yi in range(ydim): + cx = centroids['centroid_x'][xi, yi] + cy = centroids['centroid_y'][xi, yi] + + if(cx == centroid_x and cy == centroid_y): + cweight = np.matrix(umat)[xi, yi] + cluster_list.append(cweight) + + return cluster_list + + +def combine_decision(within_cluster_dist, distance_between_clusters, rang): + """ combine_decision -- A function that produces a boolean matrix + representing which clusters should be combined. + + parameters: + - within_cluster_dist - A list of the distances from centroid to + cluster elements for all centroids + - distance_between_clusters - A list of the average pairwise distance + between clusters + - range is the distance where the clusters are merged together. + + """ + + inter_cluster = distance_between_clusters + centroid_dist = within_cluster_dist + dim = inter_cluster.shape[1] + to_combine = np.matrix([[False]*dim]*dim) + + for xi in range(dim): + for yi in range(dim): + cdist = inter_cluster[xi, yi] + if cdist != 0: + rx = centroid_dist[xi] * rang + ry = centroid_dist[yi] * rang + if cdist < centroid_dist[xi] + rx or cdist < centroid_dist[yi] + ry: + to_combine[xi, yi] = True + + return to_combine + + +def swap_centroids(map, x1, y1, x2, y2, centroids): + """ swap_centroids -- A function that changes every instance of a + centroid to one that it should be combined with. + parameters: + - map is an object of type 'map' + - x1 - + - y1 - + - x2 - + - y2 - + - centroids - a matrix of the centroid locations in the map + + """ + + xdim = map['xdim'] + ydim = map['ydim'] + compn_x = centroids['centroid_x'] + compn_y = centroids['centroid_y'] + for xi in range(xdim): + for yi in range(ydim): + if compn_x[xi, 0] == x1 and compn_y[yi, 0] == y1: + compn_x[xi, 0] = x2 + compn_y[yi, 0] = y2 + + return {"centroid_x": compn_x, "centroid_y": compn_y} + + +def new_centroid(bmat, centroids, unique_centroids, map): + """ new_centroid -- A function to combine centroids based on matrix of + booleans. + + parameters: + - bmat - a boolean matrix containing the centroids to merge + - centroids - a matrix of the centroid locations in the map + - unique.centroids - a list of unique centroid locations + - map is an object of type 'map' + + """ + + bmat_rows = bmat.shape[0] + bmat_columns = bmat.shape[1] + centroids_x = unique_centroids['position_x'] + centroids_y = unique_centroids['position_y'] + components = centroids + + for xi in range(bmat_rows): + for yi in range(bmat_columns): + if bmat[xi, yi]: + x1 = centroids_x[xi] + y1 = centroids_y[xi] + x2 = centroids_x[yi] + y2 = centroids_y[yi] + components = swap_centroids(map, x1, y1, x2, y2, components) + + return components + + +def smooth_2d(Y, ind=None, weight_obj=None, grid=None, nrow=64, ncol=64, + surface=True, theta=None): + """ smooth_2d -- Kernel Smoother For Irregular 2-D Data """ + + def exp_cov(x1, x2, theta=2, p=2, distMat=0): + + x1 = x1*(1/theta) + x2 = x2*(1/theta) + + distMat = euclidean_distances(x1, x2) + + distMat = distMat**p + + return np.exp(-distMat) + + NN = [[1]*ncol] * nrow + grid = {'x': [i for i in range(nrow)], "y": [i for i in range(ncol)]} + + if weight_obj is None: + dx = grid['x'][1] - grid['x'][0] + dy = grid['y'][1] - grid['y'][0] + m = len(grid['x']) + n = len(grid['y']) + M = 2 * m + N = 2 * n + + xg = [] + + for i in range(N): + for j in range(M): + xg.extend([[j, i]]) + + xg = np.matrix(xg) + + center = [] + center.append([int(dx * M/2-1), int((dy * N)/2-1)]) + + out = exp_cov(xg, np.matrix(center)) + out = np.matrix.transpose(np.reshape(out, (N, M))) + temp = np.zeros((M, N)) + temp[int(M/2-1)][int(N/2-1)] = 1 + + wght = np.fft.fft2(out)/(np.fft.fft2(temp) * M * N) + weight_obj = {"m": m, "n": n, "N": N, "M": M, "wght": wght} + + temp = np.zeros((weight_obj['M'], weight_obj['N'])) + temp[0:m, 0:n] = Y + temp2 = np.fft.ifft2(np.fft.fft2(temp) * weight_obj['wght']).real[0:weight_obj['m'], 0:weight_obj['n']] + + temp = np.zeros((weight_obj['M'], weight_obj['N'])) + temp[0:m, 0:n] = NN + temp3 = np.fft.ifft2(np.fft.fft2(temp) * weight_obj['wght']).real[0:weight_obj['m'], 0:weight_obj['n']] + + return temp2/temp3 + + +def som_init(data, xdim, ydim, init="linear"): + """ som_init -- initiate the neurals for iteration """ + + if (xdim == 1 and ydim == 1): + sys.exit("Need at least two map cells.") + + INIT = ["random", "linear"] + + try: + init_type = INIT.index(init) + except ValueError: + sys.exit("Init only supports `random', and `linear'") + + def RandomInit(xdim, ydim): + # uniformly random in (min, max) for each dimension + ans = np.zeros((xdim * ydim, data.shape[1])) + mi = data.min(axis=0) + ma = data.max(axis=0) + + for i in range(xdim*ydim): + ans[i, ] = mi + (ma - mi) * random.uniform(0, 1) + + return ans + + def LinearInit(xdim, ydim): + # get the first two principle components + + pcm = PCA(data) + pc = pcm.Wt[:, :2] + sd = pcm.sigma[0:2] + mn = data.mean(axis=0) + ans = np.zeros((xdim * ydim, data.shape[1])) + # give the 1st pc to the bigger dimension + if (xdim >= ydim): + xtick = sd[0] * pc[:, 0] + ytick = sd[1] * pc[:, 1] + else: + xtick = sd[1] * pc[:, 1] + ytick = sd[0] * pc[:, 0] + + if xdim == 1: + xis = np.linspace(0, 0, xdim-1) + else: + xis = np.linspace(-2, 2, xdim) + + if ydim == 1: + yis = np.linspace(0, 0, ydim-1) + else: + yis = np.linspace(-2, 2, ydim) + + for i in range(xdim*ydim): + xi = i % xdim + yi = i // xdim + ans[i, ] = mn + xis[xi] * xtick + yis[yi] * ytick + + return ans + + if init_type == 0: + code = RandomInit(xdim, ydim) + elif init_type == 1: + code = LinearInit(xdim, ydim) + + return code diff --git a/sklearn/som/vsom.pyd b/sklearn/som/vsom.pyd new file mode 100644 index 0000000000000000000000000000000000000000..c9e0e0e0120ade250c5fa9258c021936f435f922 GIT binary patch literal 161208 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

!p6jogv}2pG(;JmA=1(HjkVoqV&CghSamT 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¨qYw)0>+1WdBScesn}E0P*OEG?!l5X zRJ=2m{qWSkYdG2PRjMbkj*qOTKgrqpLgurF@);~5Oa87yi6JK5h*0&EyB&v=Gb`#z z*5^xN_k{XH$GTbMnjGE=A_|LBThgzcnM@8rY}|-{cxkEd9!o zSs%AomQ?H|y*)9mp`>O{sG(%p;P}Rpksvf!VUM?%0#}}1`S+y6@k|etU+&6OQXXpy z7l`yse7OsKF+S9nPtUi)k(8ddh;+HDBuaTdN%DT8Xk|&&$H%QKso6{NJ~6(bq<)Xn zP_kxl!h7%y8Q|9t%l`AZwxU>X0^;+hWNK5jwY0XtDr zdZMAk`3k51_V?GT0dLnJY^o{usBYBVm6uGpxN%Sd(eNCYbj;VT3s5Meap1ql#V()%8I5~1b?*z5nicCje1 zkcZVK+fa*F(@wT+SxbK_rrq79^oDFZ`{V$X#&&J$F>Qq7atB;muo z>)6v6T;^m%TcC5jz^HQTR^8Ke#`EN+B?a+SCY9W6jKxVBQ7Teo53~@A1xS1s?}iA; zG`sfH;AwkRZz94B->-g^Y88J2gcR<14-!9kxD?=5M5vb$sn|pQf<{Sa(DyoK=YKhFG`-3-ZLfqj)R>!^UDG zz1y?NUvDe*+uVO$8aR*l|LB;GHzMhHQvCg-_{*uk{r#`1fogjGjKa;wzhC|e(;Y#Y ze~HKO`lEKZ;wl)Z2*?#aH3nR4!@uM-d^3cwoc8|3o?@NB&S&v=MfdIO$n@SL~-Pa}Aa+<<2@ct&o(gFRlcfY;bM ze3E#e7*-cdGoJya{syVO3?5pm+SBTHK)EnRs_IOvFT?WZHE9RmTf{9z5b>v)?ZnBi zsjl+bMs+&^LvgSg3?iGHqq@(_2*VlVLn9mgs7`u3l!G0t!S5uU30FTwxo9kjg>TQt}oqGC_TBWMyTGd{r7U}S%7xXhC^pnq8h;b!&D)86y_jRCDX_QT%d>$W5 zcwVp_l$)_6m`BNia#f@30c9iJOY?X>4GJCfhu899oF375z6{C$dz(%2dY{KNku z)n%M6#IV=PIf(2oXsOCT8NojJSJHYO8pw#dXVw;^#erXA0xl zf+oUjVCD-RR0r~*eMih=33#rkR{egH0hD@?549#J7l2^6zC-%*pM%DIS$}6LB6!y! zpOJaOT1;^n7wt}P`>lS9>I3DBk}!Xq@W z%HQATr`!h0VN`*~QwvJ4-cPv?lw}Q^!YlLLGQQ+Rt>hl$%jCh_m%nc^AOZO@ z-lh{e9Nm{sAT{^pB+7NW1|@xY9I(88*?Z-Hn8gMKfvwV7@<_u*`?XPb@V`kLleBf||8fm@wc0Cpj*E{Q8{b$NJ140#mNbBkJIFiDF# z0v;$=)F9QjcS!3^LLM>`-s^eyUt1FiH9GO0e{KrXb&fDbwBgn@lMI= zw&HRut0RkwVNEA)QGWyJ%_<^`qUe-c6uw-{bC5+P--boeGX-u@RRGdGT-IsDb}ohO zjRIs128{Z`Cf-!dm3PlV5hy>T;*=$OP@x*7E)LCVl%-psWI++5lB$Bx%MJA1271&$ zZ#K|t3PLY6&@%@576W~`fxfID^hpMK+(2(N(BEUA*B6A2g+XKuih7S4=*2Tg{%Av>NEk4fO9C=xqg|Uq|Dkqcoq;~bK>vh+-d7O%WdnVI zfnIK)XAJa#g3#YE&?^n}Ne2361O2Ij&@US3a}4w%1N|WbeW)Px3kG_*fqosKj_z^y z8tBgygnr&YpJbq4GtjFH^pS$l&l%`N20G1^=w&ZA(4Q{|{Z#|~IwBbjtL~zKPNA3J z5z)nwg3!+x=+_MN^9K5r;#~9>3qn6_pkFr7Up3HQH_%TMg#L$N-zo_Gkb!>4 zK;LhmuQbpD@j}LN&l~78-=x`L-ChH|#z1!pLO*DrA285qu3wX9={y6yv>@~m1AV`N zzS}@AHPB}jgnqz4-)o>}4D?^(If&4P=;JC1LVw0U-(#T14fHn*^s0i;_Z#TD4fLpi ze$GIzDF}VYK+hQHTMYDX80gCiLf>nk#|`vW1N|=z^!kF(pEA&+270rBe$YT)QxN(d z1AU8u-e{ojHPBlMLLV^DTMhK(271OoZz~9Ww}IYlpw}7b?FRbRg3$X6^hN`Hfq~v) zpm!C7o-xpu8|ak=dcA?3EC@Ympw}7ba}4w<1HG>x^tge(z(6lI(5D&b0|lXX8R(S; z`XmGW8fL*oFC*S8Dg%#rhKNnZ^eO2s_QoP+zEwUQnJz3Ujqd77t$t>rweKL!(-#bRxlzI$oaC7^Fbr$=Z%~jjhwIJ*^Cyi)?GAme#*%C zh>>%>k@Gbp=krF+aW7|Ky=#^AhBLj1SYLNWe=G1LG;xBV&JWa z$QRF~dT0znPq(H0@SN%C+bLv>BKZ#aoSGg{K}hiUZi-Xm2~<8F@x+j<4uT>ddgi4c z@`>RE@IZ~=p?AyRB)&sFV|A6r@VRSGSiVT6IDZ0=Az9Kq@+7P8sa>?RhCO;?<>Dy; zc1EURlxd(`z+*nRGvpzg@iL=ySoa3tfj~X}dG7-(L@L$#DLr#}D4%~oN<6Cc9j2aj zEMhhFOj^TvzVWDwkz~vd;Q`Up0W)yj- zp6S>Ip86Z`e4KcC{q;Tq$^cU153`s+;4g5x6&2x8P7)7tW)`Jv!_(pSphf-tD5=8= zB@eRCIZPe)QT1zwv<-^lxNTHu_vu2~2E|&V`k;7gQI&%EG7|U>X+M9a>MIjZqrN=r z;mof=8}&)9!+iBlTyKCUDR?-_&p|merdRew-7q!}MryKa8-5DPF8;PNr%=u%aKZ~J zXKBw|9mrYzu3Fz(+1A{+4EeKi z<+M=*Tg`b^)r`d>>U0RAGRqt_pT^BCT0Vr}9UQB%L1{i;Yy(rE6czY~5E}Za!Y`H3 zJA}-e>p}9$<=v+t+sw}OXbIXKggYT{{VXdvpSJfi*i8)eUt?gUnS z>x+37E$!z@lJ^q%?D?J8PpPsWU0%B4WnZ-L5^J!{;X^f5~ z^z${PAGYuXHav?bV_4E5g0ytp1drqT8NGd;PhI1OWV>o=Rez_6axF}&y2Y`4M3h1^ zOWZ5-Sc`Z!zD;>0c`o_6cgWW72TB{sbSxX~y(<;NZrw^5fqPNXQc==;)qVYsPN%qNnq~CYhjqNX<`#S@LKQO?aj&74>PIQtW~@TF~p0R!8%%&wz(2*3;_q zpj^N|kMh^JIwA7m1^*5xZ)iOKimUp^q~-}dgj_EuimVGR57ossWOWBIs^gv1g_1D1 zMx~c0vM!FRx~P%2)L&T_FRHpAo=NIHok%@T^Xs_>R_@cYEZ2$MRCX$tmIb>o%SAq= zA2NcimvxaZAJNO)q~xJIZqnTHoyg;j0(4pE%9MxKyq{C*XOWTztE|&n`w>4E@`8k1 zKcm~>8nPCqLmHdY@c}B=@A)li8z?OrQ(I^ zAaXmBi)y_3!6o*djb$zjh?3@!N3aAGj~cac3fVj$a*lUf8}!~Fk4d2ToJ0EZR`AeW zJ4k-4%Oq`eJ;vMHuJ!mY%Xh6`2F1jqcdae7cQjXIvzc2;sUP$n^_-dVJ!&+A ztab2sq(xmB;CJj`t#?zLy<0k@?C9O^A^c^U2~t~U`g1*aXw8&T+_hP|s`E%4wksWa ztCS%LJ7l?PJ}S#4?|6UT&2mwFomBO;;Fi(qYlf*Ut%v$n{f!Gn0+#^p8}X z{+Yob^+VrAa2vx1&M|Ci((id3G7ikZFpKLa|0uxJA+;6y@owo*_La*Z^NswA-j;_7 z#`LeLU{GbxnVD>i$JfzxVf{x*yJMG~^AOgrzwGoz^o`2iH&)rj^MV#uF37A#WU4Pv z{i$mC)iZ?}G$SxhN+sS`zX%;_l)BTfHI1@#D|!$?5$J6Np)WAd&l~6`4fID1^sNP< zR~hK98tBIj^kxISt044B1O2pte$+r;X`m+yLa#8;Pa5cl4D@9NdS5~4a}4z32Kqq* zogz8i>IMozpJkvQHP8WgQw5=y8|a4&^!)}p#o2n& zg3u=!=m!k+Jq9|B<@B===%-y-3IzE0j;AyUl4kcfxg#3&lu>}4D=%fp;Ndh zN+H_$JqCK*K>v3G{l$XNuj2u_j=tMKj~eK&8|WtrLVwFZ&lu=i4D@pb`YQ#YUo+6- z270T3{w)LjOhM>Z4D_gh-fWX&6L zkIr*1TFW~^f5kvwV4#;9 z=m`V8v>^18270A|KFL5|Z=laA2>pbCKF2^WGSC|h^ooMej~nRa2Ksea8sNN<=F%Di zy{aJe7Y+1D2KqGvy~;qZDG2?jfnH>wUpCMu8tBUkLZ=m+G_5YVjs{Po)Lk^te}>08 zf=751N|=z^tOV~ zM-24y2Kq?@{TTy&YeDD-4D?qG^y3ElfPvms5c)F)`e_6GsDU0e(31tB?>Ep-8t8`% z^i~7CuORdx1O2#xe$YUtcO7(pA1DZYuYrElKtEuhR~hI}6@>njfquw9-*2Fo8|Xs? zq39;>pfY!ChJs=eCy6jOHh;|RD_;t4g}UHcel#%JH1&Qp*O4P zJ|@Y{%PDt(A}y0snn00H9y#TMpvbjHIAt5TR%_#>! zi3+W9%JW3g%JsLP$S+Ab&$mEnL6c4O#wkApQM7VB0g7Cuho|}H zp##+>%JqFvwu-kyInQ;*Q<41DuOyFyR-_Ano;Gdk&VUOFkhG0v#~|sq4!`t zjXeU2jI21;7EolA%PG{C(yK@KUr6}hLD4O97!>~Pn=JbmL6P6$@qB&+${J1P>!36$ z9U}Wc3A}_3ucWtwa$Vzj0F(+XpN*6X?V*<{0g7BNg`+| zOE?UQTpgFoa}1OkE!8Pd_G;ere?XCMMDtWdDCPo<=N3@vH5*$B$^p&09|1+KQHE0R z%|QY~S}8ifGorNW((7yMS_2iJ5;8~Mg{y#8u8)CIua#mLlvyet@CQB*N`*!_4vK8m zdCsqba$2+1AAr)PVO;@*4$qr@35xDbbMYo`y@s_66y3sGK#^k^jvfJ}RKrSv(yHnC zE4e(8PN1>qnDQB6plU%l#Eu2`NV^i$V%AZJ)pp*e69K#P|iSW9#0!6 zYaaLe;5JY$YdJqg`M`#}c5ELg<)Dx(7PhB~qjv(jrm;T*0o;*oVO0{x5MHCJF8Bku)>f!}ZS~T>p5Jj`+3!qRO?qU4`6pDX%Nz>h( zi7@=8PpiSu3`8{52@$nkD=_C`YxjzYdCS8$Si5RMXg0yct@f<$Mb$ z705Y=5^8rZ33lSD|;U(a(yJLDX^K6_l0ty{oEAd58&TBG%8x*s#KMe}Z$H((I zQ23iF6+&Y#fKsouklzGlxu&s;pv+PFp`3pP%JWKQqBu9BJ=9X&0?Jm+HmX6{tL3vC zlrB}Ll+Qz;%+e^EKpD{T`4A`zR6fjFB4}HX+Ow7?DQ8f;-uzRbJg?<*5R|Q2D|!T! zHmzK*fKsQhNW$-dvPGl(7!-=W@W0TiH3zYu##08$5mqj#`8z=wg664)xFxItr9!jJ z&7ho9_1>{Fg>Q;6_EZ#-CkmeP8deIF{hG)9J}6r;DkB_@^@pI);n~j%2IWhjTp5rp z32~nWC8=rbbx`UxnJs){;Ak~i0?IW_Li!Hj8Kq~JFKQBag_TQ6ct3cSY4!C1P`nt4 zbx?FY{~IVt4eLKZ8PV){>U$}FIw}I)K?KUIUuOjMa~pW}XmxQP zC|6Xu+-!o)EuBylO8O9Z_G&V>gEFF(^h2QNt=~sMS*~Gy8Wj0vkrmh>g6tLz>kHsH zs#0~(Mx{7LG@h@6r$%ctUjwB=>F_eHs-v;?Y^NJeg%h2zw7l$2bo9#eaC>_ywnJXU zyAv^a-jSmHLq!g#e0eJJ!Y65N#HIBG0(^&z%4%%7n#`oCUExV=@3hfwqTdX#xq_yL zn_3Z(CsV!XaWa`OHWZDTiLkIs_YQnRPK&!_lYHvWfX!h7GukPGE83r66GkLD_6Lr~ z+ryYgG}zb>As;oIhnFUoO8Sc=>V*O$`kIeQ4Esx4CIUD`FGZ#p*-Iv$H#$!FI~LS~)Qq%O!X7jgiBtRJcFD z_HT)$QoSjD+QTpgpAbbYvpt1Vz1fV* z0O*J3kVA{GjiK3&!_|uxEm>3-$dj7wRg;^X6LZopU6e1q*tI4%2T9J!(@7IavNHj zP#z;$Lz;HM4v3A#pcFoXf@|zEPC5!CdNYg$Tc}&Q*yHStsPoPkT;7)p#vIx;dAnjx z#gZv(TAYQ&CVCUe-gF?nGn_;qFst?cd>4Q+tJIBg;Kp1SYwPb>XbCt%a9Id)>}> zZ+kfIMl-!B+DqM|L>R@$U{&iQs+pkXup1jW??UtI(qbK1gn9j}fp=la+q=oQXv-eo z&9Ui7)`MNop{NXUgt}p99u&cXvErsQ6pX;)S7k=5(i;qN; zw7yskCd2zS;46A&0&X(ay?EJDHw9yYVIVo;2dt8p3hWH0nF_1#SzLF|VnaB0{l1>j$_sZCJg=Wd)O~fIx%% z4(kzPVr>v6dKWqfh`zXXNg&pfb@}(~bjH7acRLeVH`W)4B}ost`C+XK(ulh~*3V5+ zs!rh1{YW|>Z@X-3c^BGVTE_(WF-O4AL^@j;PREqA#+`usKzAbAvRc%C&gIJfhbeH9O2yXcf)8z8Lo|vn z`N@R2#=V=Xs3FS9X?0%=f`O-HwUR2}Hl-i#O^`)~(F->vR%KHuco<_)`NpuLJv9&H zarpW>6;AcLw76=Tf{NAjtuyl`@Kd0`O-&C4Txv4-8UocK>I(3$kiAQ04&EheWU7}g zS+aNuGN#6xN-rtS0mJU|Put6{Yp{f@qb{Sn`0CIiK6;YixO8c^E4T-q;<hS$Vm%u&n3?Ysoa;03N4pUghvOR)J7JiC4YAC|1b5@8YEd8P zjIrW0ZciPOj% z(RE9PhqFEHh)!AUBR+uG?t0#uu4Vx!tcRZRGE-#jq8w}F4fK}y1gA#6Nrb<#rGgGbt$Ct zIj-B?+wOK`6I8!?0$Ru?ZRj@+C(uS*MV{-9>Ga=G^ZXAKOzlC&NEa~m5-Z6R`-r=F$ zY(`+4O+Elar2vL?j3}tISad&0&%(I^AhXvD@Pa-PL9Co&VKY||YsvGq)NCS0RlJxc zVKX7DT%!mi3>mt~Am9{jj2iVzvrYPnPGU62!aEF4k(I~{nK%78y%sM)6AvBupLKlL zugc%r9of#q6|X3x8zI-V1m<~BLrhA7uDPElrO%OBqD|;cYmEU3g(P{K+?M`^RHxe# zPNie5SqwMO^Q?{a@5F#29YBS~Qn{^#6oqVbRXi3(l%u)>O#0B2O}9U8R(Hin0=0zB zGlL#IkvSubYu_dvqEd7+>yguK$fS}9^x+M+I= zxj!Uf!#FONu#i<~b~3SAaI-jyKW5{ZiU$zrEd3D`ixu+(any11Jl7!ngMA=NYAAS) zJVIra20qWSWu1_ar1WG_s5O-O`PllkzlOPlS-z=U(AY%E{TVm+y^5$4T#sfiMW{sC_J3t zQ@dPA(3|Khv>pJ8;|1heYw0)oAa9}sS%}d+&! z7!=gF4QUqJNL+~I>+xMol3|KiZ~aJxG>^$=nMP}t{cVY8pJc_VX+u6Fx^Z44R13Ys z1kri0nWtlsPhfoAfZxc`La_eN*Jn{h|dRl~1y8xCgLqc+k=liMV}oq?Rx7dj(M zIjKVM@Xi+{koM&xPVzU+kNjg(v_$%Fl2GKTaxM==R(rAr@)#i+2TKZWC^{2*I^)}p=yxxtQ8pVL4xxJRH9L=!Wr1?4oI6eBXlNvuX9cE{SXam@Y<9fM+#oypwOx;>~LKn{KK+S#&wALLMe( zmzibhBSOiZXPSctz@C@*RmAU^s$)eH53O7J{mnCP2B^L;ArZw0LYRi={~@LpZTiLq zOltYokEe{BW(Kdl&{IxTIzE5nRSbKInt#;Jbznx*p6~Gi$_>YHzIK6+NxiNa#rJh< zW^b?RjhbG#I_iVZW#fi_?t~QV*@%d9R9nlGunS3KYsbA^R Date: Thu, 14 Sep 2017 13:00:42 -0400 Subject: [PATCH 02/22] [MRG] Create SOM(Self-Organized Maps) algorithm --- sklearn/som/som.py | 592 +++++++++++++++++++++++++++------------------ 1 file changed, 361 insertions(+), 231 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index d60d6658107f4..efe2cf24898b7 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -1,78 +1,120 @@ +# som.py +# version 1.0 +# (c) 2017-2017 Lutz Hamel, Li Yuan, University of Rhode Island +# +# This file constitues a set of routines which are useful in constructing +# and evaluating self-organizing maps (SOMs). +# The main utilities available in this file are: +# build --------- constructs a map +# convergence --- reports the map convergence index +# embed --------- reports the embedding of the map in terms of modeling the +# underlying data distribution (100% if all feature +# distributions are modeled correctly, 0% if none are) +# embed_ks ------ reports the embedding of the map using the +# Kolmogorov-Smirnov Test +# topo ---------- reports the estimated topographic accuracy +# significance -- graphically reports the significance of each feature with +# respect to the self-organizing map model +# starburst ----- displays the starburst representation of the SOM model, +# the centers of starbursts are the centers of clusters +# projection ---- print a table with the associations of labels with map +# elements +# neuron -------- returns the contents of a neuron at (x,y) on the map as a +# vector +# marginal ------ displays a density plot of a training dataframe dimension +# overlayed with the neuron density for that same dimension +# or index. +# +# +# ## License +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# ## + import sys import numpy as np import pandas as pd -import math -import random import matplotlib.pyplot as plt -import seaborn as sns # Plot density by map.marginal +import seaborn as sns # Plot density by marginal import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test import statistics as stat # F-test -from matplotlib.mlab import PCA as PCA -from random import randint # Get rotation and Standard deviations via PCA +from random import randint from sklearn.metrics.pairwise import euclidean_distances -from sklearn.neighbors import KNeighborsClassifier from scipy import stats # KS Test from scipy.stats import f # F-test from itertools import combinations -def map_build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"): - """ map_build -- construct a SOM, returns an object of class 'map' +def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, + algorithm="som"): + """ build -- construct a SOM, returns an object of class 'map' parameters: - - data - a dataframe where each row contains an unlabeled training - instance - - labels - a vector or dataframe with one label for each observation + - data - a dataframe where each row contains an unlabeled training + instance + - labels - a vector or dataframe with one label for each observation in data - xdim, ydim - the dimensions of the map - - alpha - the learning rate, should be a positive non-zero real number - - train - number of training iterations + - alpha - the learning rate, should be a positive non-zero real number + - train - number of training iterations - algorithm - selection switch + retuns: - an object of type 'map' -- see below + Example: + - m = som.build(data,labels,algorithm=algorithm) + NOTE: default algorithm: "som" also available: "som_f" - """ - algorithms = ["som","som_f"] + """ + algorithms = ["som", "som_f"] - # check if the dims are reasonable + # check if the dims are reasonable if (xdim < 3 or ydim < 3): - sys.exit("map.build: map is too small.") + sys.exit("build: map is too small.") try: index_algorithm = algorithms.index(algorithm) except ValueError: - sys.exit("map_build only supports 'som','som_f'") + sys.exit("build only supports 'som','som_f'") - if index_algorithm == 0 : # som by Fortran - neurons = vsom_r(data, + if index_algorithm == 0: # som by python + neurons = vsom_p(data, xdim=xdim, ydim=ydim, alpha=alpha, train=train) - elif index_algorithm == 1 : # som by python + elif index_algorithm == 1: # som by Fortran neurons = vsom_f(data, - xdim=xdim, - ydim=ydim, - alpha=alpha, - train=train) - - else: - sys.exit("map.build only supports 'som','som_f'") - - map = { 'data':data, - 'labels':labels, - 'xdim':xdim, - 'ydim':ydim, - 'alpha':alpha, - 'train':train, - 'algorithm':algorithm, - 'neurons':neurons} - + xdim=xdim, + ydim=ydim, + alpha=alpha, + train=train) + + else: + sys.exit("build only supports 'som','som_f'") + + map = { + 'data': data, + 'labels': labels, + 'xdim': xdim, + 'ydim': ydim, + 'alpha': alpha, + 'train': train, + 'algorithm': algorithm, + 'neurons': neurons} + visual = [] for i in range(data.shape[0]): @@ -84,74 +126,90 @@ def map_build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="so return(map) -def map_convergence(map, conf_int=.95, k=50, verb=False, ks=False): +def convergence(map, conf_int=.95, k=50, verb=False, ks=False): """ map.convergence - the convergence index of a map parameters: - map is an object if type 'map' - - conf_int is the confidence interval of the quality assessment (default 95%) - - k is the number of samples used for the estimated topographic accuracy computation - - verb if true reports the two convergence components separately, otherwise it will - report the linear combination of the two + - conf_int is the confidence interval of the quality assessment + (default 95%) + - k is the number of samples used for the estimated topographic accuracy + computation + - verb if true reports the two convergence components separately, + otherwise it will report the linear combination of the two - ks is a switch, true for ks-test, false for standard var and means test - return value is the convergence index + + Example: + - som.convergence(m) """ if ks: - embed = map_embed_ks(map, conf_int, verb=False) + embed = embed_ks(map, conf_int, verb=False) else: - embed = map_embed_vm(map, conf_int, verb=False) + embed = embed_vm(map, conf_int, verb=False) - topo = map_topo(map, k, conf_int, verb=False, interval=False) + topo_ = topo(map, k, conf_int, verb=False, interval=False) if verb: - return {"embed": embed, "topo": topo} + return {"embed": embed, "topo": topo_} else: - return (0.5*embed + 0.5*topo) + return (0.5*embed + 0.5*topo_) -def map_embed(map, conf_int=.95, verb=False, ks=False): +def embed(map, conf_int=.95, verb=False, ks=False): """ map.embed - evaluate the embedding of a map using the F-test and a Bayesian estimate of the variance in the training data. parameters: - map is an object if type 'map' - conf_int is the confidence interval of the convergence test (default 95%) - - verb is switch that governs the return value false: single convergence value - is returned, true: a vector of individual feature congences is returned. + - verb is switch that governs the return value false: single convergence + value is returned, true: a vector of individual feature congences is + returned. - - return value is the cembedding of the map (variance captured by the map so far) + - return value is the cembedding of the map (variance captured by the map + so far) - Hint: the embedding index is the variance of the training data captured by the map; - maps with convergence of less than 90% are typically not trustworthy. Of course, - the precise cut-off depends on the noise level in your training data. + Example: + - som.embed(m) + - som.embed(m,verb=True) + + Hint: the embedding index is the variance of the training data captured by + the map; maps with convergence of less than 90% are typically not + trustworthy. Of course, the precise cut-off depends on the noise + level in your training data. """ if ks: - return map_embed_ks(map, conf_int, verb) + return embed_ks(map, conf_int, verb) else: - return map_embed_vm(map, conf_int, verb) + return embed_vm(map, conf_int, verb) -def map_topo(map, k=50, conf_int=.95, verb=False, interval=True): - """ map_topo - measure the topographic accuracy of the map using sampling +def topo(map, k=50, conf_int=.95, verb=False, interval=True): + """ topo - measure the topographic accuracy of the map using sampling parameters: - map is an object if type 'map' - k is the number of samples used for the accuracy computation - conf.int is the confidence interval of the accuracy test (default 95%) - - verb is switch that governs the return value, false: single accuracy value - is returned, true: a vector of individual feature accuracies is returned. - - interval is a switch that controls whether the confidence interval is computed. + - verb is switch that governs the return value, false: single accuracy + value is returned, true: a vector of individual feature accuracies is + returned. + - interval is a switch that controls whether the confidence interval is + computed. return value is the estimated topographic accuracy + Example: + - som.topo(m) """ # data.df is a matrix that contains the training data data_df = map['data'] if (k > data_df.shape[0]): - sys.exit("map_topo: sample larger than training data.") + sys.exit("topo: sample larger than training data.") data_sample_ix = [randint(1, data_df.shape[0]) for _ in range(k)] @@ -159,18 +217,16 @@ def map_topo(map, k=50, conf_int=.95, verb=False, interval=True): # is 1 if the best matching unit is a neighbor otherwise it is 0 acc_v = [] for i in range(k): - acc_v.append(accuracy(map, data_df.iloc[data_sample_ix[i]-1], data_sample_ix[i])) - - # ########################################################################### - # # - # Notice: if you see the system Error, please remove the -1 above # - # # - # ########################################################################### + acc_v.append(accuracy(map, + data_df.iloc[data_sample_ix[i]-1], + data_sample_ix[i])) + # compute the confidence interval values using the bootstrap if interval: bval = bootstrap(map, conf_int, data_df, k, acc_v) - # the sum topographic accuracy is scaled by the number of samples - estimated + # the sum topographic accuracy is scaled by the number of samples - + # estimated # topographic accuracy if verb: return acc_v @@ -182,26 +238,36 @@ def map_topo(map, k=50, conf_int=.95, verb=False, interval=True): return val -def map_starburst(map, explicit=False, smoothing=2, merge_clusters=True, merge_range=.25): - """ map_starburst - compute and display the starburst representation of clusters - +def starburst(map, explicit=False, smoothing=2, merge_clusters=True, + merge_range=.25): + """ starburst - compute and display the starburst representation of clusters + parameters: - map is an object if type 'map' - explicit controls the shape of the connected components - smoothing controls the smoothing level of the umat (NULL,0,>0) - - merge_clusters is a switch that controls if the starburst clusters are merged together - - merge_range - a range that is used as a percentage of a certain distance in the code - to determine whether components are closer to their centroids or - centroids closer to each other. + - merge_clusters is a switch that controls if the starburst clusters are + merged together + - merge_range - a range that is used as a percentage of a certain distance + in the code to determine whether components are closer to + their centroids or centroids closer to each other. + + Example: + - som.starburst(m) """ umat = compute_umat(map, smoothing=smoothing) - plot_heat(map, umat, explicit=explicit, comp=True, merge=merge_clusters, merge_range=merge_range) + plot_heat(map, + umat, + explicit=explicit, + comp=True, + merge=merge_clusters, + merge_range=merge_range) -def map_projection(map): - """ map_projection - print the association of labels with map elements +def projection(map): + """ projection - print the association of labels with map elements parameters: - map is an object if type 'map' @@ -209,10 +275,10 @@ def map_projection(map): return values: - a dataframe containing the projection onto the map for each observation - """ + Example: + - som.projection(m) - # if not (map['labels'] is None) and (len(map['labels']) != 0): - # sys.exit("map.projection: no labels available") + """ labels_v = map['labels'] x_v = [] @@ -228,17 +294,22 @@ def map_projection(map): return pd.DataFrame({'labels': labels_v, 'x': x_v, 'y': y_v}) -def map_significance(map, graphics=True, feature_labels=False): - """ map_significance - compute the relative significance of each feature and plot it - +def significance(map, graphics=True, feature_labels=False): + """ significance - compute the relative significance of each feature and + plot it + parameters: - map is an object if type 'map' - graphics is a switch that controls whether a plot is generated or not - - feature.labels is a switch to allow the plotting of feature names vs feature indices + - feature.labels is a switch to allow the plotting of feature names vs + feature indices return value: - a vector containing the significance for each feature + Example: + - som.significance(m) + """ data_df = map['data'] @@ -281,8 +352,9 @@ def map_significance(map, graphics=True, feature_labels=False): return prob_v -def map_neuron(map, x, y): - """ map_neuron - returns the contents of a neuron at (x, y) on the map as a vector +def neuron(map, x, y): + """ neuron - returns the contents of a neuron at (x, y) on the map as + a vector parameters: map - the neuron map @@ -292,19 +364,26 @@ def map_neuron(map, x, y): return value: a vector representing the neuron + Example: + - som.neuron(m,3,3) + """ ix = rowix(map, x, y) return map['neurons'][ix] -def map_marginal(map, marginal): - """ map_marginal - plot that shows the marginal probability distribution of the neurons and data +def marginal(map, marginal): + """ marginal - plot that shows the marginal probability distribution + of the neurons and data parameters: - map is an object of type 'map' - marginal is the name of a training data frame dimension or index + Example: + - som.marginal(m,2) + """ # check if the second argument is of type character @@ -316,12 +395,16 @@ def map_marginal(map, marginal): neurons = map['neurons'][:, f_ind] plt.ylabel('Density') plt.xlabel(f_name) - sns.kdeplot(np.ravel(train), label="training data", shade=True, color="b") + sns.kdeplot(np.ravel(train), + label="training data", + shade=True, + color="b") sns.kdeplot(neurons, label="neurons", shade=True, color="r") plt.legend(fontsize=15) plt.show() - elif type(marginal) == int and marginal < map['ydim'] - 1 and marginal >= 0: + elif (type(marginal) == int and marginal < map['ydim'] - 1 and + marginal >= 0): f_ind = marginal f_name = list(map['data'])[marginal] @@ -329,19 +412,26 @@ def map_marginal(map, marginal): neurons = map['neurons'][:, f_ind] plt.ylabel('Density') plt.xlabel(f_name) - sns.kdeplot(np.ravel(train), label="training data", shade=True, color="b") + sns.kdeplot(np.ravel(train), + label="training data", + shade=True, + color="b") sns.kdeplot(neurons, label="neurons", shade=True, color="r") plt.legend(fontsize=15) plt.show() else: - sys.exit("map.marginal: second argument is not the name of a training data frame dimension or index") + sys.exit("marginal: second argument is not the name of a training \ + data frame dimension or index") # --------------------- local functions ---------------------------# -def map_embed_vm(map, conf_int=.95, verb=False): - """ map_embed_vm -- using variance test and mean test to evaluate the map quality """ +def embed_vm(map, conf_int=.95, verb=False): + """ embed_vm -- using variance test and mean test to evaluate the + map quality + + """ # map_df is a dataframe that contains the neurons map_df = map['neurons'] @@ -355,14 +445,17 @@ def map_embed_vm(map, conf_int=.95, verb=False): # do the t-test on a pair of datasets ml = df_mean_test(map_df, data_df, conf=conf_int) - # compute the variance captured by the map -- but only if the means have converged as well. + # compute the variance captured by the map -- + # but only if the means have converged as well. nfeatures = map_df.shape[1] - prob_v = map_significance(map, graphics=False) + prob_v = significance(map, graphics=False) var_sum = 0 for i in range(nfeatures): - if (vl['conf_int_lo'][i] <= 1.0 and vl['conf_int_hi'][i] >= 1.0 and ml['conf_int_lo'][i] <= 0.0 and ml['conf_int_hi'][i] >= 0.0): + if (vl['conf_int_lo'][i] <= 1.0 and vl['conf_int_hi'][i] >= 1.0 and + ml['conf_int_lo'][i] <= 0.0 and ml['conf_int_hi'][i] >= 0.0): + var_sum = var_sum + prob_v[i] else: # not converged - zero out the probability @@ -375,8 +468,11 @@ def map_embed_vm(map, conf_int=.95, verb=False): return var_sum -def map_embed_ks(map, conf_int=0.95, verb=False): - """ map_embed_ks -- using the kolgomorov-smirnov test to evaluate the map quality """ +def embed_ks(map, conf_int=0.95, verb=False): + """ embed_ks -- using the kolgomorov-smirnov test to evaluate the map + quality + + """ # map_df is a dataframe that contains the neurons map_df = map['neurons'] @@ -386,13 +482,14 @@ def map_embed_ks(map, conf_int=0.95, verb=False): nfeatures = map_df.shape[1] - # use the Kolmogorov-Smirnov Test to test whether the neurons and training data appear + # use the Kolmogorov-Smirnov Test to test whether the neurons and training + # data appear # to come from the same distribution ks_vector = [] for i in range(nfeatures): ks_vector.append(stats.mstats.ks_2samp(map_df[:, i], data_df[:, i])) - prob_v = map_significance(map, graphics=False) + prob_v = significance(map, graphics=False) var_sum = 0 # compute the variance captured by the map @@ -413,7 +510,9 @@ def map_embed_ks(map, conf_int=0.95, verb=False): def bootstrap(map, conf_int, data_df, k, sample_acc_v): - """ bootstrap -- compute the topographic accuracies for the given confide """ + """ bootstrap -- compute the topographic accuracies for the given confide + + """ ix = int(100 - conf_int*100) bn = 200 @@ -435,7 +534,9 @@ def bootstrap(map, conf_int, data_df, k, sample_acc_v): def best_match(map, obs, full=False): - """ best_match -- given observation obs, return the best matching neuron """ + """ best_match -- given observation obs, return the best matching neuron + + """ # NOTE: replicate obs so that there are nr rows of obs obs_m = np.tile(obs, (map['neurons'].shape[0], 1)) @@ -452,8 +553,9 @@ def best_match(map, obs, full=False): def accuracy(map, sample, data_ix): - """ accuracy -- the topographic accuracy of a single sample is 1 is the best matching unit - and the second best matching unit are are neighbors otherwise it is 0 + """ accuracy -- the topographic accuracy of a single sample is 1 is the + best matching unit and the second best matching unit are + neighbors otherwise it is 0 """ @@ -503,10 +605,11 @@ def rowix(map, x, y): return rix -def plot_heat(map, heat, explicit=False, comp=True, merge=False, merge_range=0.25): - - """ plot_heat - plot a heat map based on a 'map', this plot also contains the connected - components of the map based on the landscape of the heat map +def plot_heat(map, heat, explicit=False, comp=True, + merge=False, merge_range=0.25): + """ plot_heat - plot a heat map based on a 'map', this plot also contains + the connected components of the map based on the landscape + of the heat map parameters: - map is an object if type 'map' @@ -515,9 +618,9 @@ def plot_heat(map, heat, explicit=False, comp=True, merge=False, merge_range=0.2 - explicit controls the shape of the connected components - comp controls whether we plot the connected components on the heat map - merge controls whether we merge the starbursts together. - - merge_range - a range that is used as a percentage of a certain distance in the code - to determine whether components are closer to their centroids or - centroids closer to each other. + - merge_range - a range that is used as a percentage of a certain distance + in the code to determine whether components are closer to + their centroids or centroids closer to each other. """ @@ -532,7 +635,7 @@ def plot_heat(map, heat, explicit=False, comp=True, merge=False, merge_range=0.2 # need to make sure the map doesn't have a dimension of 1 if (x <= 1 or y <= 1): - sys.exit("plot.heat: map dimensions too small") + sys.exit("plot_heat: map dimensions too small") tmp = pd.cut(heat, bins=100, labels=False) @@ -552,17 +655,26 @@ def plot_heat(map, heat, explicit=False, comp=True, merge=False, merge_range=0.2 if comp: if not merge: # find the centroid for each neuron on the map - centroids = compute_centroids(map, heat, explicit) + centroids = compute_centroids(map, + heat, + explicit) else: # find the unique centroids for the neurons on the map - centroids = compute_combined_clusters(map, umat, explicit, merge_range) + centroids = compute_combined_clusters(map, + umat, + explicit, + merge_range) # connect each neuron to its centroid for ix in range(x): for iy in range(y): cx = centroids['centroid_x'][ix, iy] cy = centroids['centroid_y'][ix, iy] - plt.plot([ix+0.5, cx+0.5], [iy+0.5, cy+0.5], color='grey', linestyle='-', linewidth=1.0) + plt.plot([ix+0.5, cx+0.5], + [iy+0.5, cy+0.5], + color='grey', + linestyle='-', + linewidth=1.0) # put the labels on the map if available if not (map['labels'] is None) and (len(map['labels']) != 0): @@ -853,9 +965,12 @@ def compute_centroid(ix, iy): min_y = iy # if successful - # move to the square with the smaller value, i_e_, call compute_centroid on this new square - # note the RETURNED x-y coords in the centroid.x and centroid.y matrix at the current location + # move to the square with the smaller value, i_e_, call + # compute_centroid on this new square + # note the RETURNED x-y coords in the centroid_x and + # centroid_y matrix at the current location # return the RETURNED x-y coordinates + if min_x != ix or min_y != iy: r_val = compute_centroid(min_x, min_y) @@ -890,10 +1005,11 @@ def compute_umat(map, smoothing=None): parameters: - map is an object if type 'map' - - smoothing is either NULL, 0, or a positive floating point value controlling the - smoothing of the umat representation + - smoothing is either NULL, 0, or a positive floating point value + controlling the smoothing of the umat representation return value: - - a matrix with the same x-y dims as the original map containing the umat values + - a matrix with the same x-y dims as the original map containing + the umat values """ @@ -904,13 +1020,14 @@ def compute_umat(map, smoothing=None): def compute_heat(map, d, smoothing=None): - """ compute_heat -- compute a heat value map representation of the given distance matrix + """ compute_heat -- compute a heat value map representation of the + given distance matrix parameters: - map is an object if type 'map' - d is a distance matrix computed via the 'dist' function - - smoothing is either NULL, 0, or a positive floating point value controlling the - smoothing of the umat representation + - smoothing is either NULL, 0, or a positive floating point value + controlling the smoothing of the umat representation return value: - a matrix with the same x-y dims as the original map containing the heat @@ -921,44 +1038,73 @@ def compute_heat(map, d, smoothing=None): heat = np.matrix([[0.0] * y for _ in range(x)]) if x == 1 or y == 1: - sys.exit("compute_heat: heat map can not be computed for a map with a dimension of 1") + sys.exit("compute_heat: heat map can not be computed for a map \ + with a dimension of 1") # this function translates our 2-dim map coordinates # into the 1-dim coordinates of the neurons def xl(ix, iy): - return ix + iy * x # Python start with 0, so we should minus 1 at the end + return ix + iy * x # check if the map is larger than 2 x 2 (otherwise it is only corners) if x > 2 and y > 2: # iterate over the inner nodes and compute their umat values for ix in range(1, x-1): for iy in range(1, y-1): - sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix-1, iy-1)] + + d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy)] + + d[xl(ix, iy), xl(ix+1, iy+1)] + + d[xl(ix, iy), xl(ix, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy)]) + heat[ix, iy] = sum/8 # iterate over bottom x axis for ix in range(1, x-1): iy = 0 - sum = d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix+1, iy)] + + d[xl(ix, iy), xl(ix+1, iy+1)] + + d[xl(ix, iy), xl(ix, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy)]) + heat[ix, iy] = sum/5 # iterate over top x axis for ix in range(1, x-1): iy = y-1 - sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix-1, iy-1)] + + d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy)] + + d[xl(ix, iy), xl(ix-1, iy)]) + heat[ix, iy] = sum/5 # iterate over the left y-axis for iy in range(1, y-1): ix = 0 - sum = d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + sum = (d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy)] + + d[xl(ix, iy), xl(ix+1, iy+1)] + + d[xl(ix, iy), xl(ix, iy+1)]) + heat[ix, iy] = sum/5 # iterate over the right y-axis for iy in range(1, y-1): ix = x-1 - sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix-1, iy-1)] + + d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy)]) + heat[ix, iy] = sum/5 # compute umat values for corners @@ -966,25 +1112,34 @@ def xl(ix, iy): # bottom left corner ix = 0 iy = 0 - sum = d[xl(ix, iy), xl(ix+1, iy)] + d[xl(ix, iy), xl(ix+1, iy+1)] + d[xl(ix, iy), xl(ix, iy+1)] + sum = (d[xl(ix, iy), xl(ix+1, iy)] + + d[xl(ix, iy), xl(ix+1, iy+1)] + + d[xl(ix, iy), xl(ix, iy+1)]) + heat[ix, iy] = sum/3 # bottom right corner ix = x-1 iy = 0 - sum = d[xl(ix, iy), xl(ix, iy+1)] + d[xl(ix, iy), xl(ix-1, iy+1)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy+1)] + + d[xl(ix, iy), xl(ix-1, iy)]) heat[ix, iy] = sum/3 # top left corner ix = 0 iy = y-1 - sum = d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix+1, iy-1)] + d[xl(ix, iy), xl(ix+1, iy)] + sum = (d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy-1)] + + d[xl(ix, iy), xl(ix+1, iy)]) heat[ix, iy] = sum/3 # top right corner ix = x-1 iy = y-1 - sum = d[xl(ix, iy), xl(ix-1, iy-1)] + d[xl(ix, iy), xl(ix, iy-1)] + d[xl(ix, iy), xl(ix-1, iy)] + sum = (d[xl(ix, iy), xl(ix-1, iy-1)] + + d[xl(ix, iy), xl(ix, iy-1)] + + d[xl(ix, iy), xl(ix-1, iy)]) heat[ix, iy] = sum/3 # smooth the heat map @@ -996,16 +1151,23 @@ def xl(ix, iy): if smoothing is not None: if smoothing == 0: - heat = smooth_2d(heat, nrow=x, ncol=y, surface=False) + heat = smooth_2d(heat, + nrow=x, + ncol=y, + surface=False) elif smoothing > 0: - heat = smooth_2d(heat, nrow=x, ncol=y, surface=False, theta=smoothing) + heat = smooth_2d(heat, + nrow=x, + ncol=y, + surface=False, + theta=smoothing) else: - sys.exit("compute.heat: bad value for smoothing parameter") + sys.exit("compute_heat: bad value for smoothing parameter") return heat -def df_var_test1(df1, df2, conf): +def df_var_test1(df1, df2, conf=.95): """ df_var_test -- a function that applies the F-test testing the ratio of the variances of the two data frames @@ -1016,7 +1178,7 @@ def df_var_test1(df1, df2, conf): """ if df1.shape[1] != df2.shape[1]: - sys.exit("df.var.test: cannot compare variances of data frames") + sys.exit("df_var_test: cannot compare variances of data frames") # init our working arrays var_ratio_v = [randint(1, 1) for _ in range(df1.shape[1])] @@ -1033,7 +1195,8 @@ def var_test(x, y, ratio=1, conf_level=0.95): ESTIMATE = V_x / V_y BETA = (1 - conf_level) / 2 - CINT = [ESTIMATE / f.ppf(1 - BETA, DF_x, DF_y), ESTIMATE / f.ppf(BETA, DF_x, DF_y)] + CINT = [ESTIMATE / f.ppf(1 - BETA, DF_x, DF_y), + ESTIMATE / f.ppf(BETA, DF_x, DF_y)] return {"estimate": ESTIMATE, "conf_int": CINT} @@ -1046,7 +1209,9 @@ def var_test(x, y, ratio=1, conf_level=0.95): var_confinthi_v[i] = t['conf_int'][1] # return a list with the ratios and conf intervals for each feature - return {"ratio": var_ratio_v, "conf_int_lo": var_confintlo_v, "conf_int_hi": var_confinthi_v} + return {"ratio": var_ratio_v, + "conf_int_lo": var_confintlo_v, + "conf_int_hi": var_confinthi_v} def df_mean_test(df1, df2, conf=0.95): @@ -1061,7 +1226,7 @@ def df_mean_test(df1, df2, conf=0.95): """ if df1.shape[1] != df2.shape[1]: - sys.exit("df.mean.test: cannot compare means of data frames") + sys.exit("df_mean_test: cannot compare means of data frames") # init our working arrays mean_diff_v = [randint(1, 1) for _ in range(df1.shape[1])] @@ -1075,7 +1240,8 @@ def t_test(x, y, conf_level=0.95): conf_int_lo = cm.tconfint_diff(alpha=1-conf_level, usevar='unequal')[0] conf_int_hi = cm.tconfint_diff(alpha=1-conf_level, usevar='unequal')[1] - return {"estimate": [estimate_x, estimate_y], "conf_int": [conf_int_lo, conf_int_hi]} + return {"estimate": [estimate_x, estimate_y], + "conf_int": [conf_int_lo, conf_int_hi]} # compute the F-test on each feature in our populations for i in range(df1.shape[1]): @@ -1085,12 +1251,14 @@ def t_test(x, y, conf_level=0.95): mean_confinthi_v[i] = t['conf_int'][1] # return a list with the ratios and conf intervals for each feature - return {"diff": mean_diff_v, "conf_int_lo": mean_confintlo_v, "conf_int_hi": mean_confinthi_v} + return {"diff": mean_diff_v, + "conf_int_lo": mean_confintlo_v, + "conf_int_hi": mean_confinthi_v} -def vsom_r(data, xdim, ydim, alpha, train): - """ vsom_r - vectorized, unoptimized version of the stochastic SOM - training algorithm written entirely in R +def vsom_p(data, xdim, ydim, alpha, train): + """ vsom_p - vectorized, unoptimized version of the stochastic SOM + training algorithm written entirely in python """ # some constants @@ -1186,12 +1354,21 @@ def vsom_f(data, xdim, ydim, alpha, train): # build and initialize the matrix holding the neurons cells = nr * nc # no. of neurons times number of data dimensions - v = np.random.uniform(-1, 1, cells) # vector with small init values for all neurons + + # vector with small init values for all neurons + v = np.random.uniform(-1, 1, cells) # NOTE: each row represents a neuron, each column represents a dimension. neurons = np.reshape(v, (nr, nc)) # rearrange the vector as matrix - neurons = vsom.vsom(neurons, np.array(data), xdim, ydim, alpha, train, dr, dc) + neurons = vsom.vsom(neurons, + np.array(data), + xdim, + ydim, + alpha, + train, + dr, + dc) return neurons @@ -1208,13 +1385,24 @@ def compute_combined_clusters(map, heat, explicit, rang): # Get unique centroids unique_centroids = get_unique_centroids(map, centroids) # Get distance from centroid to cluster elements for all centroids - within_cluster_dist = distance_from_centroids(map, centroids, unique_centroids, heat) + within_cluster_dist = distance_from_centroids(map, + centroids, + unique_centroids, + heat) # Get average pairwise distance between clusters - between_cluster_dist = distance_between_clusters(map, centroids, unique_centroids, heat) + between_cluster_dist = distance_between_clusters(map, + centroids, + unique_centroids, + heat) # Get a boolean matrix of whether two components should be combined - combine_cluster_bools = combine_decision(within_cluster_dist, between_cluster_dist, rang) + combine_cluster_bools = combine_decision(within_cluster_dist, + between_cluster_dist, + rang) # Create the modified connected components grid - ne_centroid = new_centroid(combine_cluster_bools, centroids, unique_centroids, map) + ne_centroid = new_centroid(combine_cluster_bools, + centroids, + unique_centroids, + map) return ne_centroid @@ -1326,15 +1514,15 @@ def distance_between_clusters(map, centroids, unique_centroids, umat): parameters: - map is an object of type 'map' - centroids - a matrix of the centroid locations in the map - - unique.centroids - a list of unique centroid locations + - unique_centroids - a list of unique centroid locations - umat - a unified distance matrix """ cluster_elements = list_clusters(map, centroids, unique_centroids, umat) - tmp_1 = np.zeros(shape=(max([len(cluster_elements[i]) for i in range(len(cluster_elements))]), len(cluster_elements))) - # tmp_2 = np.zeros(shape=(max([len(cluster_elements[i]) for i in range(len(cluster_elements))]),len(cluster_elements))) + tmp_1 = np.zeros(shape=(max([len(cluster_elements[i]) for i in range( + len(cluster_elements))]), len(cluster_elements))) for i in range(len(cluster_elements)): for j in range(len(cluster_elements[i])): @@ -1342,12 +1530,14 @@ def distance_between_clusters(map, centroids, unique_centroids, umat): columns = tmp_1.shape[1] - tmp = np.matrix.transpose(np.array(list(combinations([i for i in range(columns)], 2)))) + tmp = np.matrix.transpose(np.array(list(combinations( + [i for i in range(columns)], 2)))) tmp_3 = np.zeros(shape=(tmp_1.shape[0], tmp.shape[1])) for i in range(tmp.shape[1]): - tmp_3[:, i] = np.where(tmp_1[:, tmp[1, i]]*tmp_1[:, tmp[0, i]] != 0, abs(tmp_1[:, tmp[0, i]] - tmp_1[:, tmp[1, i]]), 0) + tmp_3[:, i] = np.where(tmp_1[:, tmp[1, i]]*tmp_1[:, tmp[0, i]] != 0, + abs(tmp_1[:, tmp[0, i]]-tmp_1[:, tmp[1, i]]), 0) # both are not equals 0 mean = np.true_divide(tmp_3.sum(0), (tmp_3 != 0).sum(0)) @@ -1443,7 +1633,8 @@ def combine_decision(within_cluster_dist, distance_between_clusters, rang): if cdist != 0: rx = centroid_dist[xi] * rang ry = centroid_dist[yi] * rang - if cdist < centroid_dist[xi] + rx or cdist < centroid_dist[yi] + ry: + if (cdist < centroid_dist[xi] + rx or + cdist < centroid_dist[yi] + ry): to_combine[xi, yi] = True return to_combine @@ -1552,75 +1743,14 @@ def exp_cov(x1, x2, theta=2, p=2, distMat=0): temp = np.zeros((weight_obj['M'], weight_obj['N'])) temp[0:m, 0:n] = Y - temp2 = np.fft.ifft2(np.fft.fft2(temp) * weight_obj['wght']).real[0:weight_obj['m'], 0:weight_obj['n']] + temp2 = np.fft.ifft2(np.fft.fft2(temp) * + weight_obj['wght']).real[0:weight_obj['m'], + 0:weight_obj['n']] temp = np.zeros((weight_obj['M'], weight_obj['N'])) temp[0:m, 0:n] = NN - temp3 = np.fft.ifft2(np.fft.fft2(temp) * weight_obj['wght']).real[0:weight_obj['m'], 0:weight_obj['n']] + temp3 = np.fft.ifft2(np.fft.fft2(temp) * + weight_obj['wght']).real[0:weight_obj['m'], + 0:weight_obj['n']] return temp2/temp3 - - -def som_init(data, xdim, ydim, init="linear"): - """ som_init -- initiate the neurals for iteration """ - - if (xdim == 1 and ydim == 1): - sys.exit("Need at least two map cells.") - - INIT = ["random", "linear"] - - try: - init_type = INIT.index(init) - except ValueError: - sys.exit("Init only supports `random', and `linear'") - - def RandomInit(xdim, ydim): - # uniformly random in (min, max) for each dimension - ans = np.zeros((xdim * ydim, data.shape[1])) - mi = data.min(axis=0) - ma = data.max(axis=0) - - for i in range(xdim*ydim): - ans[i, ] = mi + (ma - mi) * random.uniform(0, 1) - - return ans - - def LinearInit(xdim, ydim): - # get the first two principle components - - pcm = PCA(data) - pc = pcm.Wt[:, :2] - sd = pcm.sigma[0:2] - mn = data.mean(axis=0) - ans = np.zeros((xdim * ydim, data.shape[1])) - # give the 1st pc to the bigger dimension - if (xdim >= ydim): - xtick = sd[0] * pc[:, 0] - ytick = sd[1] * pc[:, 1] - else: - xtick = sd[1] * pc[:, 1] - ytick = sd[0] * pc[:, 0] - - if xdim == 1: - xis = np.linspace(0, 0, xdim-1) - else: - xis = np.linspace(-2, 2, xdim) - - if ydim == 1: - yis = np.linspace(0, 0, ydim-1) - else: - yis = np.linspace(-2, 2, ydim) - - for i in range(xdim*ydim): - xi = i % xdim - yi = i // xdim - ans[i, ] = mn + xis[xi] * xtick + yis[yi] * ytick - - return ans - - if init_type == 0: - code = RandomInit(xdim, ydim) - elif init_type == 1: - code = LinearInit(xdim, ydim) - - return code From e6b45a61522795b23eb46e87f008e3bd04c0f3f4 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 14:13:24 -0400 Subject: [PATCH 03/22] Add a line at the botton --- examples/som/build.py | 4 ++-- examples/som/convergence.py | 6 +++--- examples/som/embed.py | 6 +++--- examples/som/marginal.py | 8 ++++---- examples/som/neuron.py | 6 +++--- examples/som/projection.py | 6 +++--- examples/som/significance.py | 6 +++--- examples/som/starburst.py | 6 +++--- examples/som/topo.py | 6 +++--- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/examples/som/build.py b/examples/som/build.py index c8ede51a731c1..e9becb9165435 100644 --- a/examples/som/build.py +++ b/examples/som/build.py @@ -15,7 +15,7 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) \ No newline at end of file +m = som.build(data,labels,algorithm=algorithm) diff --git a/examples/som/convergence.py b/examples/som/convergence.py index 87e12e004ea0b..1835891da3d77 100644 --- a/examples/som/convergence.py +++ b/examples/som/convergence.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # map quality -som.convergence(m) \ No newline at end of file +som.convergence(m) diff --git a/examples/som/embed.py b/examples/som/embed.py index bd56c820ca2cf..bb638c864bd4f 100644 --- a/examples/som/embed.py +++ b/examples/som/embed.py @@ -15,13 +15,13 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display the embedding accuracy of the map som.embed(m) # display the embedding accuracies of the individual features -som.embed(m,verb=True) \ No newline at end of file +som.embed(m,verb=True) diff --git a/examples/som/marginal.py b/examples/som/marginal.py index 3abc4664e4daa..783c45c177987 100644 --- a/examples/som/marginal.py +++ b/examples/som/marginal.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) -# display marginal distribution of 3rd dimension -som.marginal(m,2) \ No newline at end of file +# display marginal distribution of 3rd dimension +som.marginal(m,2) diff --git a/examples/som/neuron.py b/examples/som/neuron.py index 89a3918b311ad..4c542b54fc8af 100644 --- a/examples/som/neuron.py +++ b/examples/som/neuron.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display the neuron at position (4,4) -som.neuron(m,3,3) \ No newline at end of file +som.neuron(m,3,3) diff --git a/examples/som/projection.py b/examples/som/projection.py index 78909799c7ad6..a1db18094dc5c 100644 --- a/examples/som/projection.py +++ b/examples/som/projection.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display the label association for the map -som.projection(m) \ No newline at end of file +som.projection(m) diff --git a/examples/som/significance.py b/examples/som/significance.py index 471f8920786c9..9ced204f496c8 100644 --- a/examples/som/significance.py +++ b/examples/som/significance.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display the relative feature significance graphically -som.significance(m) \ No newline at end of file +som.significance(m) diff --git a/examples/som/starburst.py b/examples/som/starburst.py index 058013bce323a..d8e123eaa5690 100644 --- a/examples/som/starburst.py +++ b/examples/som/starburst.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display the starburst for the map -som.starburst(m) \ No newline at end of file +som.starburst(m) diff --git a/examples/som/topo.py b/examples/som/topo.py index 9bcec69c2cce3..0d3992fff8118 100644 --- a/examples/som/topo.py +++ b/examples/som/topo.py @@ -15,10 +15,10 @@ # som written entirely in python, som_f written by fortran, # which is much faster than python -algorithm = "som" +algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data,labels,algorithm=algorithm) # display estimated topographical accuracy of the map -som.topo(m) \ No newline at end of file +som.topo(m) From b86e3cac9cf053927445bb50457849b7ffe4faa0 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 14:44:32 -0400 Subject: [PATCH 04/22] fix the matplotlib problem and flake8 warning as well --- examples/som/build.py | 5 ++--- examples/som/convergence.py | 5 ++--- examples/som/embed.py | 7 +++---- examples/som/marginal.py | 7 +++---- examples/som/neuron.py | 7 +++---- examples/som/projection.py | 5 ++--- examples/som/significance.py | 5 ++--- examples/som/starburst.py | 5 ++--- examples/som/topo.py | 5 ++--- 9 files changed, 21 insertions(+), 30 deletions(-) diff --git a/examples/som/build.py b/examples/som/build.py index e9becb9165435..2569901c64005 100644 --- a/examples/som/build.py +++ b/examples/som/build.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,4 +17,4 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) diff --git a/examples/som/convergence.py b/examples/som/convergence.py index 1835891da3d77..ef7852fe6366c 100644 --- a/examples/som/convergence.py +++ b/examples/som/convergence.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # map quality som.convergence(m) diff --git a/examples/som/embed.py b/examples/som/embed.py index bb638c864bd4f..06a1b290db6ac 100644 --- a/examples/som/embed.py +++ b/examples/som/embed.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,10 +17,10 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display the embedding accuracy of the map som.embed(m) # display the embedding accuracies of the individual features -som.embed(m,verb=True) +som.embed(m, verb=True) diff --git a/examples/som/marginal.py b/examples/som/marginal.py index 783c45c177987..004dfe0214625 100644 --- a/examples/som/marginal.py +++ b/examples/som/marginal.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display marginal distribution of 3rd dimension -som.marginal(m,2) +som.marginal(m, 2) diff --git a/examples/som/neuron.py b/examples/som/neuron.py index 4c542b54fc8af..325562fff2f41 100644 --- a/examples/som/neuron.py +++ b/examples/som/neuron.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display the neuron at position (4,4) -som.neuron(m,3,3) +som.neuron(m, 3, 3) diff --git a/examples/som/projection.py b/examples/som/projection.py index a1db18094dc5c..c6de9391be4cd 100644 --- a/examples/som/projection.py +++ b/examples/som/projection.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display the label association for the map som.projection(m) diff --git a/examples/som/significance.py b/examples/som/significance.py index 9ced204f496c8..7ee438a27e55a 100644 --- a/examples/som/significance.py +++ b/examples/som/significance.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display the relative feature significance graphically som.significance(m) diff --git a/examples/som/starburst.py b/examples/som/starburst.py index d8e123eaa5690..716d8c6aeafdf 100644 --- a/examples/som/starburst.py +++ b/examples/som/starburst.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display the starburst for the map som.starburst(m) diff --git a/examples/som/topo.py b/examples/som/topo.py index 0d3992fff8118..2da5402e207cf 100644 --- a/examples/som/topo.py +++ b/examples/som/topo.py @@ -1,7 +1,6 @@ import som import pandas as pd -import vsom -from sklearn import datasets +from sklearn import datasets # import iris datasets iris = datasets.load_iris() @@ -18,7 +17,7 @@ algorithm = "som" # Build a map -m = som.build(data,labels,algorithm=algorithm) +m = som.build(data, labels, algorithm=algorithm) # display estimated topographical accuracy of the map som.topo(m) From e0fc09107458aa554a1ce8609fda7f60e5937631 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 15:26:53 -0400 Subject: [PATCH 05/22] fix matplotlib problem --- sklearn/som/som.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index efe2cf24898b7..65626f09a3174 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -42,6 +42,9 @@ import numpy as np import pandas as pd +import matplotlib +matplotlib.use("Qt5Agg") +import matplotlib.pyplot as plt import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import vsom # Call vsom.f90 (Fortran package) From 65337e0fa0e90052106f38e5f1e3e275ae074cab Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 16:11:37 -0400 Subject: [PATCH 06/22] fix matplotlib problem --- sklearn/som/som.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 65626f09a3174..cb5867777600e 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -42,10 +42,8 @@ import numpy as np import pandas as pd -import matplotlib -matplotlib.use("Qt5Agg") -import matplotlib.pyplot as plt -import matplotlib.pyplot as plt +from matplotlib import pyplot as plt +#import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test From 89deff6e1e447e83014d63c284f2b9aa652a4d78 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 16:46:26 -0400 Subject: [PATCH 07/22] fix matplotlib problem --- sklearn/som/som.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index cb5867777600e..1a17e64a3d24e 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -38,12 +38,14 @@ # # ## +print(__doc__) + import sys import numpy as np import pandas as pd from matplotlib import pyplot as plt -#import matplotlib.pyplot as plt +# import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test From 5c7a96b9efe4441eb5df9d82f6e665f29cbbf567 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 17:17:52 -0400 Subject: [PATCH 08/22] fix problem --- sklearn/som/som.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 1a17e64a3d24e..6b517a6872568 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -38,8 +38,6 @@ # # ## -print(__doc__) - import sys import numpy as np From 7e26407d97daa6df1eae3f1a545b05c6b4b273a3 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 21:14:57 -0400 Subject: [PATCH 09/22] fix matplotlib problem --- sklearn/som/som.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 6b517a6872568..ed614706d66a6 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -42,8 +42,6 @@ import numpy as np import pandas as pd -from matplotlib import pyplot as plt -# import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test @@ -53,6 +51,7 @@ from scipy import stats # KS Test from scipy.stats import f # F-test from itertools import combinations +import matplotlib.pyplot as plt def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, From 1d2f30b0e87083a1dd55635e844ecae8dd5e0ae6 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 22:01:57 -0400 Subject: [PATCH 10/22] fix matplotlib --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2563b54dc6741..6c735874707ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -60,6 +60,9 @@ matrix: # We are using this to allow failures for DISTRIB=scipy-dev-wheels - python: 3.5 +before_install: + - sudo apt-get install python-matplotlib + install: source build_tools/travis/install.sh script: bash build_tools/travis/test_script.sh after_success: source build_tools/travis/after_success.sh From d3f112c5198d17749f131767443ce386f7b5a187 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 22:16:10 -0400 Subject: [PATCH 11/22] fix seaborn problem --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6c735874707ab..48fec61eb8588 100644 --- a/.travis.yml +++ b/.travis.yml @@ -62,6 +62,8 @@ matrix: before_install: - sudo apt-get install python-matplotlib + - sudo apt-get install python-scipy python-pandas + - sudo apt-get install seaborn install: source build_tools/travis/install.sh script: bash build_tools/travis/test_script.sh From 95cbf6bdc2501a8f3da47746b993bc4346fea89a Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 22:38:43 -0400 Subject: [PATCH 12/22] fix problem --- .travis.yml | 5 ----- build_tools/travis/install.sh | 2 ++ 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 48fec61eb8588..2563b54dc6741 100644 --- a/.travis.yml +++ b/.travis.yml @@ -60,11 +60,6 @@ matrix: # We are using this to allow failures for DISTRIB=scipy-dev-wheels - python: 3.5 -before_install: - - sudo apt-get install python-matplotlib - - sudo apt-get install python-scipy python-pandas - - sudo apt-get install seaborn - install: source build_tools/travis/install.sh script: bash build_tools/travis/test_script.sh after_success: source build_tools/travis/after_success.sh diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index 8cd774d649338..90527024a75c2 100755 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -56,6 +56,8 @@ if [[ "$DISTRIB" == "conda" ]]; then # Install nose-timer via pip pip install nose-timer + pip install matplotlib + elif [[ "$DISTRIB" == "ubuntu" ]]; then # At the time of writing numpy 1.9.1 is included in the travis # virtualenv but we want to use the numpy installed through apt-get From 8f6914443a6cd5f316d55f294da2cf0f033ac9e3 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 22:56:24 -0400 Subject: [PATCH 13/22] fix seaborn --- build_tools/travis/install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index 90527024a75c2..187c90937d68c 100755 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -58,6 +58,8 @@ if [[ "$DISTRIB" == "conda" ]]; then pip install matplotlib + pip install seaborn + elif [[ "$DISTRIB" == "ubuntu" ]]; then # At the time of writing numpy 1.9.1 is included in the travis # virtualenv but we want to use the numpy installed through apt-get From aaa3d28db9ead98c8355cc71e17e1b8688d84273 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 23:07:38 -0400 Subject: [PATCH 14/22] fix vsom --- sklearn/som/som.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index ed614706d66a6..94bd87bd3528a 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -43,7 +43,7 @@ import numpy as np import pandas as pd import seaborn as sns # Plot density by marginal -import vsom # Call vsom.f90 (Fortran package) +# import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test import statistics as stat # F-test from random import randint From 66060d9b5bc4f4749a8d594807057cede0de21bb Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 23:18:07 -0400 Subject: [PATCH 15/22] fix vsom --- sklearn/som/som.py | 47 +++------------------------------------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 94bd87bd3528a..dfbe718dd9ab4 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -43,7 +43,6 @@ import numpy as np import pandas as pd import seaborn as sns # Plot density by marginal -# import vsom # Call vsom.f90 (Fortran package) import statsmodels.stats.api as sms # t-test import statistics as stat # F-test from random import randint @@ -54,7 +53,7 @@ import matplotlib.pyplot as plt -def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, +def build(data, labels, xdim=10, ydim=5, alpha=.3, train=100, algorithm="som"): """ build -- construct a SOM, returns an object of class 'map' @@ -77,7 +76,7 @@ def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, NOTE: default algorithm: "som" also available: "som_f" """ - algorithms = ["som", "som_f"] + algorithms = ["som"] # check if the dims are reasonable if (xdim < 3 or ydim < 3): @@ -95,15 +94,8 @@ def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, alpha=alpha, train=train) - elif index_algorithm == 1: # som by Fortran - neurons = vsom_f(data, - xdim=xdim, - ydim=ydim, - alpha=alpha, - train=train) - else: - sys.exit("build only supports 'som','som_f'") + sys.exit("build only supports 'som'") map = { 'data': data, @@ -1340,39 +1332,6 @@ def Gamma(c): return neurons -def vsom_f(data, xdim, ydim, alpha, train): - """ vsom_f - vectorized and optimized version of the stochastic SOM - training algorithm written in Fortran90 - - """ - - # some constants - dr = data.shape[0] - dc = data.shape[1] - nr = xdim*ydim - nc = dc # dim of data and neurons is the same - - # build and initialize the matrix holding the neurons - cells = nr * nc # no. of neurons times number of data dimensions - - # vector with small init values for all neurons - v = np.random.uniform(-1, 1, cells) - - # NOTE: each row represents a neuron, each column represents a dimension. - neurons = np.reshape(v, (nr, nc)) # rearrange the vector as matrix - - neurons = vsom.vsom(neurons, - np.array(data), - xdim, - ydim, - alpha, - train, - dr, - dc) - - return neurons - - def compute_combined_clusters(map, heat, explicit, rang): """ compute_combined_clusters -- to Combine connected components that represent the same cluster From ac52359e823b28c94e403ddd34dd37a46b267a64 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 23:41:55 -0400 Subject: [PATCH 16/22] fix bugs --- build_tools/travis/install.sh | 6 ++++++ sklearn/som/som.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index 187c90937d68c..27fe875e14534 100755 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -60,6 +60,12 @@ if [[ "$DISTRIB" == "conda" ]]; then pip install seaborn + pip install statsmodels + + pip install statistics + + pip install itertools + elif [[ "$DISTRIB" == "ubuntu" ]]; then # At the time of writing numpy 1.9.1 is included in the travis # virtualenv but we want to use the numpy installed through apt-get diff --git a/sklearn/som/som.py b/sklearn/som/som.py index dfbe718dd9ab4..977e4ecf2b300 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -42,6 +42,7 @@ import numpy as np import pandas as pd +import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import statsmodels.stats.api as sms # t-test import statistics as stat # F-test @@ -50,10 +51,9 @@ from scipy import stats # KS Test from scipy.stats import f # F-test from itertools import combinations -import matplotlib.pyplot as plt -def build(data, labels, xdim=10, ydim=5, alpha=.3, train=100, +def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"): """ build -- construct a SOM, returns an object of class 'map' From 95edbc16e3bd01dc3f6d6cee776076b1ab5fff9e Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Thu, 14 Sep 2017 23:48:46 -0400 Subject: [PATCH 17/22] fix bug --- build_tools/travis/install.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index 27fe875e14534..a7097dff7bfcd 100755 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -64,7 +64,6 @@ if [[ "$DISTRIB" == "conda" ]]; then pip install statistics - pip install itertools elif [[ "$DISTRIB" == "ubuntu" ]]; then # At the time of writing numpy 1.9.1 is included in the travis From c59e6b36e3b0cb9d60a5f0a2041292bf71426cc8 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Fri, 15 Sep 2017 00:09:30 -0400 Subject: [PATCH 18/22] fix bug --- build_tools/travis/install.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index a7097dff7bfcd..2a0100c7ba147 100755 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -62,7 +62,6 @@ if [[ "$DISTRIB" == "conda" ]]; then pip install statsmodels - pip install statistics elif [[ "$DISTRIB" == "ubuntu" ]]; then From 855174d3cb4ef585649bbc6b1cd251a159d07511 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Fri, 15 Sep 2017 10:45:52 -0400 Subject: [PATCH 19/22] fix the display but --- sklearn/som/som.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 977e4ecf2b300..009182545c5fb 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -42,7 +42,6 @@ import numpy as np import pandas as pd -import matplotlib.pyplot as plt import seaborn as sns # Plot density by marginal import statsmodels.stats.api as sms # t-test import statistics as stat # F-test @@ -52,6 +51,8 @@ from scipy.stats import f # F-test from itertools import combinations +import matplotlib.pyplot as plt +matplotlib.use('Agg') def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"): From 3d908364cc61230d17088c8dd168797a97b67125 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Fri, 15 Sep 2017 10:51:42 -0400 Subject: [PATCH 20/22] fix the dsiplay bug --- sklearn/som/som.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 009182545c5fb..f9eda4393b96f 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -52,7 +52,10 @@ from itertools import combinations import matplotlib.pyplot as plt -matplotlib.use('Agg') + +if os.environ.get('DISPLAY','') == '': + matplotlib.use('Agg') + def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"): From 0ae35b78b7fb0cdc8443597921e9f4f740c6a08a Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Fri, 15 Sep 2017 11:17:13 -0400 Subject: [PATCH 21/22] fix bug --- sklearn/som/som.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index f9eda4393b96f..23bc4c448141d 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -39,7 +39,7 @@ # ## import sys - +import os import numpy as np import pandas as pd import seaborn as sns # Plot density by marginal @@ -50,10 +50,10 @@ from scipy import stats # KS Test from scipy.stats import f # F-test from itertools import combinations - +import matplotlib import matplotlib.pyplot as plt -if os.environ.get('DISPLAY','') == '': +if os.environ.get('DISPLAY', '') == '': matplotlib.use('Agg') From 42330bbfef874d74a2cfcb6fb2eb4e0c6326c7a0 Mon Sep 17 00:00:00 2001 From: Li Yuan Date: Fri, 15 Sep 2017 14:12:00 -0400 Subject: [PATCH 22/22] fix the bug --- sklearn/som/som.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sklearn/som/som.py b/sklearn/som/som.py index 23bc4c448141d..1ab2b0b56ff20 100644 --- a/sklearn/som/som.py +++ b/sklearn/som/som.py @@ -51,11 +51,12 @@ from scipy.stats import f # F-test from itertools import combinations import matplotlib -import matplotlib.pyplot as plt - if os.environ.get('DISPLAY', '') == '': matplotlib.use('Agg') +import matplotlib.pyplot as plt + + def build(data, labels, xdim=10, ydim=5, alpha=.3, train=1000, algorithm="som"):