Saturday, October 22, 2016

OLS-primed PLA: From linear regression to linear classifier to PLA

PLA-with-OLS


Jump-starting the PLA with OLS

In this post, we will explore an old statistics staple --the ever-useful linear regression. It is mathematically similar to how the PLA behaves, or vice versa. Linear regression is of course real-valued, whereas a Perceptron is by definition binary-based, so there are obvious differences also. We explore the possibility of using linear regression (ordinary least squares linear regression) to help the PLA. As an aside, we can also compare the performance of an OLS-assisted PLA with our Border PLA, which we know by now (here and here) to be faster than an ordinary PLA.

Let's jump right into linear regression.

Linear regression

Linear regression is comparable to minimizing the gap between a mean and a sample point, in other words, the gap to the average in each dimension for all dimensions $d$:

$$\sum_{i=1}^d \left(w_ix_i-\bar x_i\right)^2$$

If we apply this to $N$ multiple points, we get:

$$\sum_{i=1}^N \sum_{j=1}^d \left(w_{j}x_{ij}-\bar x_j\right)^2$$

In the context of a vector $\mathbf x$, the single-point error is the squared error between an estimate $h(\mathbf x)$ and a target function $f(\mathbf x)$:

$$(h(\mathbf x)-f(\mathbf x))^2$$

So the linear regression error over $N$ multiples points, which is equivalent to the in-sample error $E_\text{in}$ in our PLA (here), is:

$$E_\text{in}(h(\mathbf x_{1...N})) = \frac{1}{N} \sum_{i=1}^{N} \left((h(\mathbf x_i)-f(\mathbf x_i))^2\right) $$

From our PLA discussion, we of course know that:

$$h(\mathbf x)=\mathbf w^\mathsf T\mathbf x$$$$f(\mathbf x)=\mathbf w_f^\mathsf T\mathbf x$$

Expressing this in vector form in terms of $\mathbf x$, we get:

$$E_\text{in}(\mathbf w) = \frac{1}{N} \sum_{i=1}^{N} \left(\mathbf w^\mathsf T\mathbf x_i-\mathbf w_f^\mathsf T\mathbf x_i\right) = \frac{1}{N} \sum_{i=1}^{N} \left(\mathbf w^\mathsf T\mathbf x-y_i\right) $$

We can simplify the notation further by noting that the matrix $\mathbf X$ is composed of different $\mathbf x$ vectors, that is, $\mathbf X=\{\mathbf x_1,\mathbf x_2,...,\mathbf x_N\}$, and $\mathbf y=\{y_1,y_2,...,y_N\}$:

$$E_\text{in}(\mathbf w) = \frac{1}{N} \lVert\mathbf X\mathbf w-\mathbf y\rVert^2$$



Closed form solution

Since the goal of a linear regression is to find a line that minimizes the sum of the individual squared errors (the residuals, and the sum of residuals is familiar in statistics as SSE), we therefore have to do a little bit of matrix calculus to find the smallest squared error (hence, ordinary least squares, OLS):

$$\nabla E_\text{in}(\mathbf w) = \frac{2}{N}\mathbf X^\mathsf T (\mathbf X\mathbf w-\mathbf y)$$

Setting it to zero (i.e., the closed form solution) and collecting terms,

$$\mathbf X^\mathsf T \mathbf X\mathbf w=\mathbf X^\mathsf T\mathbf y$$

Extracting $\mathbf w$,

$$\bbox[yellow,6px,border:1px solid red]{\mathbf w=(\mathbf X^\mathsf T\mathbf X)^{-1}\mathbf X^\mathsf T\mathbf y}$$

And there we have the solution to finding $\mathbf w$ via linear regression. This looks like cheating, isn't it? It barely looks like 'learning'. It is a very direct and exact method, as opposed to trial-and-error to finding the final value for $\mathbf w$.

Before we leave this part, note the presence of a matrix inversion, which is not always possible when the matrix is not invertible (singular or degenerate, which I will not describe here). In cases like this, we have to apply a pseudo-inverse. In code, this is simply using different matrix function. For instance, "pinv(X)" instead of "inv(X)" in Numpy.

Connecting linear regression to the PLA

If we could use linear regression to do the Perceptron's work, we could have a one-step solution. But linear regression uses and produces real values, that is $y\in \Bbb R$. The Perceptron is a classifier, and while its inputs are real values, its output is $y\in\{-1,+1\}$. If we use a linear regression but force it to minimize errors against $y\in\{-1,+1\}$, it will produce a solution based on {-1,+1}. We could then set a cut-off: anything above 0 becomes a +1, anything below -1 (i.e., use a sign function on top of the linear regression output).

Let's write the OLS classifier code below.

In [1]:
%matplotlib inline
import numpy as np
import time
import matplotlib.pyplot as plt
from sklearn.neighbors import KNeighborsClassifier

def generate_data(N,n,d):  # N is population size, n is sample size
    # generate n random sample using a uniform distribution from -1 to +1
    P_N=np.array(np.random.uniform(-1,+1,(N+n,d)))
    X_n=np.array(P_N[:n,:])
    P_N=np.array(P_N[n:N+n,:])
    
    # random dividing plane
    w_div=np.array(np.random.uniform(-1,+1,d+1))
    w_div[-1]=-1
    
    # add x0=1 to feature vector X and training vectors in P
    X=np.ones(shape=(n,len(X_n[0])+1))
    P=np.ones(shape=(N,len(P_N[0])+1))
    X[:,1:]=X_n
    P[:,1:]=P_N
    
    # classify the training and test data set
    y=np.sign(np.dot(X,w_div))
    y_N=np.sign(np.dot(P,w_div))
    
    return X_n,y,P_N,y_N,w_div

def run_OLS(X,y,P,y_N,w_div):
    # add x0=1 to X and P
    X_temp=np.ones(shape=(len(X),len(X[0])+1))
    P_temp=np.ones(shape=(len(P),len(P[0])+1))
    X_temp[:,1:]=X
    P_temp[:,1:]=P
    X=X_temp
    P=P_temp
    
    # normal equations, ordinary least squares
    X_pseudo=np.dot(np.linalg.pinv(np.dot(X.T,X)),X.T)
    w=np.dot(X_pseudo,y)
    #w=np.dot(np.linalg.pinv(X_pseudo),y)    # same effect
    
    # classify X using w
    h=np.array(np.sign(np.dot(X,w)))
    error_mask=h!=y
    
    # classify P using w
    h_N=np.array(np.sign(np.dot(P,w)))
    error_mask_N=h_N!=y_N
    
    # error rates
    in_err=1 if len(error_mask)==0 else np.count_nonzero(error_mask)/len(error_mask)
    out_err=1 if len(error_mask_N)==0 else np.count_nonzero(error_mask_N)/len(error_mask_N)
    
    return in_err,out_err,w

def setup_OLS_experiment(N,sample_sizes,d,reps):
    print('Running linear regression experiment, N=',N,', samples sizes=',sample_sizes,', dimensions=',d,'...')
    for n in sample_sizes:
        total_in_accuracy=[]
        total_out_accuracy=[]
        start_time=time.time()
        for i in range(reps):
            X_in,y_in,X_out,y_out,w_f=generate_data(N,n,d)
            in_err_ols,out_err_ols,w_ols=run_OLS(X_in,y_in,X_out,y_out,w_f)
            total_in_accuracy.append(in_err_ols)
            total_out_accuracy.append(out_err_ols)
        print('training sample size:',n)
        print('... elapsed time:',time.time()-start_time)
        print('... average in-sample error:',np.mean(total_in_accuracy))
        print('... average out-of-sample error:',np.mean(total_out_accuracy))

########## main code ##########
setup_OLS_experiment(20,(10,100,1000),2,10)
Running linear regression experiment, N= 20 , samples sizes= (10, 100, 1000) , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.005002260208129883
... average in-sample error: 0.01
... average out-of-sample error: 0.095
training sample size: 100
... elapsed time: 0.0010006427764892578
... average in-sample error: 0.057
... average out-of-sample error: 0.085
training sample size: 1000
... elapsed time: 0.0015010833740234375
... average in-sample error: 0.0473
... average out-of-sample error: 0.05


It works! We did not have to use our old PLA code that iterates so many times.

But hang on a second, we seem to have high out-of-sample error. Worse still, we seem to be getting some in-sample error! What's going on? A linear regression should find a $\mathbf w$ that cleanly separates the training points, but we are seeing errors even on the training points!

Well, this is because the linear regression is trying its best to minimize the squared error against either a -1 or a +1. Consider a training point that causes an output of +5. Against the sign(+5) threshold, it will correctly classify into a +1. However, for the linear regression, the +5 is too far from +1, and is therefore a 'huge' error! So it will adjust the 'average' regressor line to make that error smaller. The same happens with extreme negative training sample points. The net effect is that the OLS line does not perfectly separate the training points, much less the unknown test points.

OLS-primed PLA

So what else can we do with OLS? Well, the OLS line is already pretty close a perfect separator, so can we use it as the initial line for a PLA? Absolutely, let's try that below.

We first make a couple of small changes to our previous PLA algorithm. We add the option to add an initial vector $\mathbf w$ and a record trail of these $w$s.

In [2]:
def record_PLA(X,y,P,y_N,w_div,w_init=None,limit=100,record_limit=1):
    # add x0=1 to X and P
    X_temp=np.ones(shape=(len(X),len(X[0])+1))
    P_temp=np.ones(shape=(len(P),len(P[0])+1))
    X_temp[:,1:]=X
    P_temp[:,1:]=P
    X=X_temp
    P=P_temp
    
    # initial weights vector w
    if w_init is None:
        w=np.zeros(len(X[0]))
    else:
        w=w_init
    
    # classify X using w
    h=np.array(np.sign(np.dot(X,w)))
    error_mask=h!=y
    error=True if error_mask.any() else False
    
    alpha=1
    pla_iter=0
    w_list=[]
    w_list.append(w.copy())
    while error and pla_iter<limit:
        # find a misclassified point
        miss=error_mask.nonzero()[0]            
        miss_index=np.random.choice(miss)
        
        # update w
        w+=y[miss_index]*X[miss_index]*alpha
        if pla_iter<record_limit: w_list.append(w.copy())
        pla_iter+=1
        
        # classify X using w
        h=np.sign(np.dot(X,w))
        error_mask=h!=y
        error=True if error_mask.any() else False
        
    # classify P using w
    h_N=np.array(np.sign(np.dot(P,w)))
    error_mask_N=h_N!=y_N
    
    # error rates
    in_err=np.count_nonzero(error_mask)/len(error_mask)
    out_err=np.count_nonzero(error_mask_N)/len(error_mask_N)
    
    return pla_iter,in_err,out_err,w,w_list

def setup_PLA_experiment(N,sample_sizes,d,reps):
    print('Running PLA experiment, N=',N,', samples sizes=',sample_sizes,', dimensions=',d,'...')
    for n in sample_sizes:
        total_iter=[]
        total_in_accuracy=[]
        total_out_accuracy=[]
        start_time=time.time()
        for i in range(reps):
            X_in,y_in,X_out,y_out,w_f=generate_data(N,n,d)
            w_ols=None
            in_err_ols,out_err_ols,w_ols=run_OLS(X_in,y_in,X_out,y_out,w_f)
            max_iter=X_in.shape[0]*2
            record_limit=10
            pla_iter,in_acc,out_acc,w,w_list=record_PLA(X_in,y_in,X_out,y_out,w_f,w_ols,max_iter,record_limit)
            total_iter.append(pla_iter)
            total_in_accuracy.append(in_acc)
            total_out_accuracy.append(out_acc)
        print('training sample size:',n)
        print('... elapsed time:',time.time()-start_time)
        print('... average iteration:',np.mean(total_iter))
        print('... average in-sample error:',np.mean(total_in_accuracy))
        print('... average out-of-sample error:',np.mean(total_out_accuracy))

########## main code ##########
setup_PLA_experiment(20,(10,100,1000),2,10)
Running PLA experiment, N= 20 , samples sizes= (10, 100, 1000) , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.0
... average iteration: 0.2
... average in-sample error: 0.0
... average out-of-sample error: 0.08
training sample size: 100
... elapsed time: 0.015711069107055664
... average iteration: 65.7
... average in-sample error: 0.007
... average out-of-sample error: 0.01
training sample size: 1000
... elapsed time: 0.08463215827941895
... average iteration: 456.2
... average in-sample error: 0.0
... average out-of-sample error: 0.0


That looks better, we get no training error, and a reasonable small test error. We further notice that the average iteration appears to be lower. For example, on the $n=10$ experiment, the average is about one iteration, vs. the typical 10 iterations. The OLS line is almost always near the final PLA line, needing only one more adjustment. This iteration savings appear to carry over to the other experiments with larger training sizes, although less pronounced.

Visualizing a PLA, OLS-primed PLA, and a Border PLA

To help us understand how an OLS-primed PLA works, it is useful to show how the hypothesis line moves through each iteration, in comparison to others we previously tested.

However, unlike the first posts on the PLA, we won't be attaching a GIF (too much work to stitch together the images :) ). Instead, we will just show the first 10 iterations, hence the earlier code changes to record the $\mathbf w$ values. We will also exclude the test data points for clarity.

We will also run across different values of $n$ and review the output later. Note that the red line is the target function, while the black line is the current hypothesis.

In [14]:
import matplotlib.pyplot as plt

def plot_2D_points(ax,X,y,X_b,y_b):
    ax.set_aspect('equal')
    ax.axis([-1,+1,-1,+1])
    type_mask=y==1
    type_mask_b=y_b==1
    ax.scatter(X[type_mask,0],X[type_mask,1],marker='+',label='Type -1',c='r')
    ax.scatter(X[np.invert(type_mask),0],X[np.invert(type_mask),1],marker='x',label='Type +1',c='g')

def plot_points(ax,X,y):
    ax.set_xlim(-1,+1)
    ax.set_ylim(-1,+1)
    ax.set_zlim(-1,+1)
    y1=y==1
    not_y1=np.invert(y1)
    ax.scatter(X[:,0][y1],X[:,1][y1],X[:,2][y1], c='r', marker='+')
    ax.scatter(X[:,0][not_y1],X[:,1][not_y1],X[:,2][not_y1], c='g', marker='x')
    
def plot_lines(ax,w_f,w):
    ax.plot([-1,+1],[-(w_f[0]+w_f[1]*-1)/w_f[2],-(w_f[0]+w_f[1]*+1)/w_f[2]],'k-',c='r',label='target f',linewidth=1.0)
    ax.plot([-1,+1],[-(w[0]+w[1]*-1)/w[2],-(w[0]+w[1]*+1)/w[2]],'k-',c='k',label='hypothesis h',linewidth=1.0)
In [15]:
import time

def find_borders_local_field(X,y):
    kNN=KNeighborsClassifier(n_neighbors=1).fit(X,y)
    neighbors=kNN.kneighbors(n_neighbors=5,return_distance=False)
    
    b1=y[neighbors[:,0]]!=y
    b2=y[neighbors[:,1]]!=y
    b3=y[neighbors[:,2]]!=y
    b4=y[neighbors[:,3]]!=y
    b5=y[neighbors[:,4]]!=y
    border_rows=b1+b2+b3+b4+b5
    new_X=X[border_rows]
    new_y=y[border_rows]
    return new_X,new_y,np.count_nonzero(border_rows)

def compare_PLAs(n):
    N=2000
    #n=10
    d=2
    
    X_in,y_in,X_out,y_out,w_f=generate_data(N,n,d)
    w_ols=None
    max_iter=X_in.shape[0]*2
    record_limit=10

    # plan PLA
    print('Running PLA experiment, N=',N,', sample size=',n,', dimensions=',d,'...')
    start_time=time.time()
    pla_iter,in_acc,out_acc,w,w_list_pla=record_PLA(X_in,y_in,X_out,y_out,w_f,w_ols,max_iter,record_limit)
    print('training sample size:',X_in.shape[0])
    print('... elapsed time:',time.time()-start_time)
    print('... iteration:',pla_iter)
    print('... in-sample error:',in_acc)
    print('... out-of-sample error:',out_acc)
    print('... showing first 10 iterations (or convergence) below...')
    
    # PLA with OLS
    print('Running PLA (OLS) experiment, N=',N,', sample size=',n,', dimensions=',d,'...')
    start_time=time.time()
    in_err_ols,out_err_ols,w_ols=run_OLS(X_in,y_in,X_out,y_out,w_f)
    pla_iter,in_acc,out_acc,w,w_list_ols=record_PLA(X_in,y_in,X_out,y_out,w_f,w_ols,max_iter,record_limit)
    print('training sample size:',X_in.shape[0])
    print('... elapsed time:',time.time()-start_time)
    print('... iteration:',pla_iter)
    print('... in-sample error:',in_acc)
    print('... out-of-sample error:',out_acc)
    print('... showing first 10 iterations (or convergence) below...')
    
    # border-only PLA
    print('Running PLA (border-only) experiment, N=',N,', sample size=',n,', dimensions=',d,'...')
    start_time=time.time()
    X_b,y_b,border_N=find_borders_local_field(X_in,y_in)
    w_ols=None
    pla_iter,in_acc,out_acc,w,w_list_border=record_PLA(X_b,y_b,X_out,y_out,w_f,w_ols,max_iter,record_limit)
    print('... training sample size:',X_b.shape[0])
    print('... elapsed time:',time.time()-start_time)
    print('... iteration:',pla_iter)
    print('... in-sample error:',in_acc)
    print('... out-of-sample error:',out_acc)
    print('... showing first 10 iterations (or convergence) below...')
    
    w_list_pla=w_list_pla[1:]
    fig=plt.figure(figsize=(15,9),facecolor='0.75')
    for i in range(len(w_list_pla)):
        if i==10: break
        ax=plt.subplot2grid((3,10),(0,i))
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        plot_2D_points(ax,X_in,y_in,X_in,y_in)
        plot_lines(ax,w_f,w_list_pla[i])
    plt.show()    
    
    #w_list_ols=w_list_ols[1:]
    fig=plt.figure(figsize=(15,9),facecolor='0.5')
    for i in range(len(w_list_ols)):
        if i==10: break
        ax=plt.subplot2grid((3,10),(1,i))
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        plot_2D_points(ax,X_in,y_in,X_in,y_in)
        plot_lines(ax,w_f,w_list_ols[i])
    plt.show()
    
    w_list_border=w_list_border[1:]
    fig=plt.figure(figsize=(15,9),facecolor='0.25')
    for i in range(len(w_list_border)):
        if i==10: break
        ax=plt.subplot2grid((3,10),(2,i))
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        plot_2D_points(ax,X_b,y_b,X_b,y_b)
        plot_lines(ax,w_f,w_list_border[i])

    plt.show()

compare_PLAs(n=10)
Running PLA experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.0
... iteration: 20
... in-sample error: 0.2
... out-of-sample error: 0.152
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.0
... iteration: 0
... in-sample error: 0.0
... out-of-sample error: 0.088
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
... training sample size: 10
... elapsed time: 0.0
... iteration: 15
... in-sample error: 0.0
... out-of-sample error: 0.0645
... showing first 10 iterations (or convergence) below...
In [16]:
compare_PLAs(10)
Running PLA experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.0005004405975341797
... iteration: 4
... in-sample error: 0.0
... out-of-sample error: 0.2015
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
training sample size: 10
... elapsed time: 0.000499725341796875
... iteration: 0
... in-sample error: 0.0
... out-of-sample error: 0.2205
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 10 , dimensions= 2 ...
... training sample size: 7
... elapsed time: 0.001001596450805664
... iteration: 6
... in-sample error: 0.0
... out-of-sample error: 0.202
... showing first 10 iterations (or convergence) below...
In [17]:
compare_PLAs(100)
Running PLA experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
training sample size: 100
... elapsed time: 0.0010027885437011719
... iteration: 16
... in-sample error: 0.0
... out-of-sample error: 0.0175
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
training sample size: 100
... elapsed time: 0.0014972686767578125
... iteration: 55
... in-sample error: 0.0
... out-of-sample error: 0.0075
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
... training sample size: 11
... elapsed time: 0.0020172595977783203
... iteration: 71
... in-sample error: 0.0
... out-of-sample error: 0.013
... showing first 10 iterations (or convergence) below...
In [18]:
compare_PLAs(100)
Running PLA experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
training sample size: 100
... elapsed time: 0.0010006427764892578
... iteration: 37
... in-sample error: 0.0
... out-of-sample error: 0.0045
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
training sample size: 100
... elapsed time: 0.0035021305084228516
... iteration: 130
... in-sample error: 0.0
... out-of-sample error: 0.005
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 100 , dimensions= 2 ...
... training sample size: 14
... elapsed time: 0.00250244140625
... iteration: 46
... in-sample error: 0.0
... out-of-sample error: 0.0095
... showing first 10 iterations (or convergence) below...
In [19]:
compare_PLAs(1000)
Running PLA experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
training sample size: 1000
... elapsed time: 0.004515171051025391
... iteration: 205
... in-sample error: 0.0
... out-of-sample error: 0.0045
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
training sample size: 1000
... elapsed time: 0.0029883384704589844
... iteration: 114
... in-sample error: 0.0
... out-of-sample error: 0.0045
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
... training sample size: 32
... elapsed time: 0.009007692337036133
... iteration: 405
... in-sample error: 0.0
... out-of-sample error: 0.0045
... showing first 10 iterations (or convergence) below...
In [20]:
compare_PLAs(1000)
Running PLA experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
training sample size: 1000
... elapsed time: 0.03775954246520996
... iteration: 2000
... in-sample error: 0.01
... out-of-sample error: 0.008
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
training sample size: 1000
... elapsed time: 0.03124856948852539
... iteration: 2000
... in-sample error: 0.028
... out-of-sample error: 0.0195
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 1000 , dimensions= 2 ...
... training sample size: 69
... elapsed time: 0.03778266906738281
... iteration: 2000
... in-sample error: 0.15942028985507245
... out-of-sample error: 0.0045
... showing first 10 iterations (or convergence) below...
In [21]:
compare_PLAs(10000)
Running PLA experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
training sample size: 10000
... elapsed time: 0.3325328826904297
... iteration: 3762
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
training sample size: 10000
... elapsed time: 0.2029876708984375
... iteration: 2163
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
... training sample size: 181
... elapsed time: 0.08005595207214355
... iteration: 2822
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
In [22]:
compare_PLAs(10000)
Running PLA experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
training sample size: 10000
... elapsed time: 1.006901502609253
... iteration: 13683
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
training sample size: 10000
... elapsed time: 0.71750807762146
... iteration: 9918
... in-sample error: 0.0
... out-of-sample error: 0.0005
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 10000 , dimensions= 2 ...
... training sample size: 166
... elapsed time: 0.21164941787719727
... iteration: 13339
... in-sample error: 0.0
... out-of-sample error: 0.0005
... showing first 10 iterations (or convergence) below...
In [23]:
compare_PLAs(100000)
Running PLA experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
training sample size: 100000
... elapsed time: 54.138582706451416
... iteration: 72616
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
training sample size: 100000
... elapsed time: 51.66406607627869
... iteration: 69561
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
... training sample size: 580
... elapsed time: 1.890852689743042
... iteration: 74863
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
In [24]:
compare_PLAs(100000)
Running PLA experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
training sample size: 100000
... elapsed time: 44.96293926239014
... iteration: 79837
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (OLS) experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
training sample size: 100000
... elapsed time: 27.52830982208252
... iteration: 50317
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...
Running PLA (border-only) experiment, N= 2000 , sample size= 100000 , dimensions= 2 ...
... training sample size: 437
... elapsed time: 2.13401198387146
... iteration: 104963
... in-sample error: 0.0
... out-of-sample error: 0.0
... showing first 10 iterations (or convergence) below...


Performance comparison of the three PLAs

It could be misleading to generate conclusions based on a limited run (two runs per $n$), so we will limit to the more obvious and in general terms. We will conduct a longer comparison in a future experiment (repeated runs on 100k training data takes some time, so best done another time).

  • At low n (n=10), the OLS-derived hypothesis often matches location and orientation of the converged PLA line, or would just need a few iterations to converge. This gives the OLS-primed PLA a processing speed advantage over both the plain PLA and the Border PLA.
  • However, this advantage diminishes with increasing n. At large training sizes, the OLS-primed PLA takes roughly the same time as the plain PLA, both slower than the Border PLA. This suggests the proximity of the initial hypothesis to the target line is not a guarantee of processing speed, at least at high n. This is due to the way a PLA performs an update (with a +1 or -1 factor, regardless of the actual error differential, which standard gradient descent algorithms take into account).
  • The Border PLA is comparatively slower at low n, but definitively beats the execution time of both the OLS-primed PLA and the plain PLA (as was shown in previous posts) at high n.

To solidify these points, we will explore some of these generalizations in a future experiment by using more runs/repetitions to minimize the possibility of random results.

Monday, July 18, 2016

Getting to know the (nearest) neighbors

kNN_intro


kNN background

The Nearest Neighbor algorithm (NN) is a simple algorithm that works well in classifying previously unseen data (see this post). It simply compares an unknown data against all known data. It then concludes that the unknown data is the same as the closest known data, hence the nearest neighbor designation. More precisely, the algorithm presumes that the type or class ("type" and "class" are used interchangeably throughout this post) of the unknown data has to be the type/class of its nearest neighbor. Due to this very direct dependency on prior known training data, the more training data one has, the better the approximation.

Expressed in mathematical form, NN is described as follows. Given a set of $m$ classes $C = \{c_1,c_2,...,c_m\}$, a set of training points $\mathbf D=\{\mathbf x_0,\mathbf x_1,...,\mathbf x_n\}$ where $\mathbf x\in\Bbb R^d$, and a set of corresponding classes $\{y_0,y_1,...,y_n\}$ for each $\mathbf x_i$ in $\mathbf D$ where $y_i\in C$, the NN algorithm finds the the class $y_i$ whose distance to the query/test data $\mathbf x'$ is minimized. If we define $z(\mathbf x_i)=y_i$, the NN algorithm becomes:

$$y'=z(\underset{\mathbf x_i\in D}{\operatorname{argmin}}(d(\mathbf x',\mathbf x_n)))$$

where $d(\mathbf x',\mathbf x_n)$ is some distance calculation.

On a set of data points whose classes are clearly delineated from each other --that is, no overlaps in the classes-- this method will work well. Any errors will come from classifying test points around class boundaries. If there are few examples of one type (say Type A) in a particular region near a boundary between two types, but a lot more examples for another type (say Type B) across such boundary, the nearest neighbor of an unknown point (a point that is in fact a Type A point) might be from across the boundary. This will cause the unknown point to be incorrectly identified as Type B.

To hedge against a misleading nearest neighbor, we could query more points and average the types, concluding that the most dominant type around a neighborhood is the correct class. This method of looking at the majority class over several nearest neighbors is called $k$-NN (or kNN), where $k$ is the number of neighboring data points to query.

The original function above changes slightly. We define $z(k,\mathbf x')$ as the classes of the $k$ training points in $\mathbf D$ that are closest to query point $\mathbf x'$:

$$\hat y=\underset{y\in C}{\operatorname{argmax}}\left(\sum_i^k \mathbf I(y,z(k,\mathbf x'))\right)$$$$\mathbf I(a,b)= \begin{cases} +1 & \text{if }a=b\\ 0 & \text{otherwise} \end{cases}$$

kNN can handle cases where classes are known to overlap, e.g., each class is a Gaussian distribution (or some other kind of distribution) that extends outward from a class center or centers regardless of the presence of other classes in the vicinity. At different points between the ‘centers’ of the class distributions, there will be different likelihoods of being a Type A or some other type (Type B, C, and so on).

We hope to guess this likelihood of belonging to the correct type by examining the class density around the immediate neighborhood of our sample, with the class 'density' estimated as the majority class within $k$ neighbors. A kNN estimate is clearly not perfect, but gets better with more data. The intuition is that we should choose the type that is most dense, which should imply that we are nearest that type's center. However, there is no guarantee that the type of the nearest neighbor is the actual correct type for the unknown data point (recall the assumed Gaussian distribution overlap). All we get is a higher likelihood.

Fine-tuning kNN

If we think through how the kNN algorithm works, we should be able to figure out that the algorithm is affected by --and could be improved by fine tuning-- the following factors:

  • The number of neighbors to consider in the calculation, $k$
  • The distance measure, $d(\mathbf x',\mathbf x_n)$
  • The weights of each closest neighbor(s) within $k$, $\mathbf w^k\in\Bbb R^k$
  • The weight of each feature, $\mathbf w^f\in\Bbb R^d$
  • The weight of each instance/sample, $\mathbf w^s\in\Bbb R^n$

(Note that the variable notation for the last three parameters is my arbitrary choice. The superscripts are to identify each parameter, not exponents. Formal literature might have different standard variable designation.)

The first three parameters have been well explored by researchers, and intuitive choices produce good results without a lot of testing. In practice, most of these choices are set, with k as the only parameter that requires finetuning. The distance measure is typically the square of the error and the nearest neighbor weighing is uniform (each neighbor has the same effect). The value of k is determined by cross-validation (and k=3 works well without tuning), while additional tests might uncover if a specialized neighbor weighing system (such as an inverse distance weighing) could work better.

The last two parameters are typically left untouched, meaning equal weights are assumed, once training features and instances are finalized. However, as the concept of nearest neighbors become diluted in high dimensions, especially with data that has many irrelevant features, it is often desirable to consider techniques that filter out the more important features, i.e., feature selection, during pre-processing. These last two parameters will be the focus of our experiments.

I have tested changing the weights of each feature, but through an indirect approach. Instead of ranking the relative value or relevance of a feature over all others, I reduced the number of features (dimensionality reduction), effectively setting to zero the weights of ‘irrelevant’ features while holding the remaining features at equal weights. I could still explore a weighted feature kNN, but will leave this as a future experiment.

I have no clear plan of attack on the last one, except perhaps to deploy anomaly detection algorithms on the training data (yet another experiment). In other words, training samples that are atypical (outside some normality threshold) could be discarded or assigned less contribution to the summed kNN distance, as they might be causing incorrect classification. I am not (yet) as well-versed on anomaly detection limitations, specially on data for which a class might have multiple kinds of a normal representation, that I will do this experiment at a later time.

If we add these parameters to the equations above, we will get, for NN:

$$d(\mathbf x, \mathbf y)=\sum_{i=1}^n w^f_i(d(x_i,y_i))$$$$y'=z(w^s_{i'},i')$$

where $i'=\underset{\mathbf x_i\in D}{\operatorname{argmin}}(d(\mathbf x',\mathbf x_n))$.

And for kNN:

$$\hat y=\underset{y\in C}{\operatorname{argmax}}\left(\sum_i^k w^k_i \mathbf I(y,z(k,\mathbf x'))\right)$$


Number of neighbors

We already described the number of nearest neighbors in the kNN post referenced above. We simply take a survey of several points near our unknown point, and make a judgment based on these neighbor points. For instance, we could decide that the class of an unknown point should be the majority class of the k=5 nearest neighbors, even if the nearest neighbor is of a different class.

This approach typically avoids the noise caused by outliers, e.g., a solo Type A surrounded by many Type Bs. This loner Type A datapoint might represent an island of Type As, or it could simply be noise. Without more data, it would be hard to conclude. If this were a valid Type A class, with more training data sample, more data points would cluster around this island, thus making its neighborhood more Type A. If it were truly an outlier, the solo Type A would not have more Type A neighbors, justifying our original majority vote.

Generally, k=3 works well. Depending on the problem and the available training data, other values of k (odd k, to break ties) might work better, but often flatlines at higher values. The best k for a machine learning problem is mostly determined via cross-validation, a topic for another day.

For now, from an experimentation point of view, we will focus on k=1, using k=3 only to test if the performance improves significantly. The thinking is that k=3 should outperform k=1, but we want the performance of our experiments to improve on k=1 first.

Distance measure

The distance measure is typically the distance between two points in space. In 2d space, we are familiar with using the Pythagorean Theorem to calculate the distance between two points (the hypotenuse $c$ of a triangle), $c^2=a^2+b^2$. If we extend this to many dimensions between two points $\mathbf x$ and $\mathbf y$, we get the following, called Euclidean distance:

$$\mathbf x=(x_1,x_2,...,x_n) \in \mathbb R^n$$$$\mathbf y=(y_1,y_2,...,y_n) \in \mathbb R^n$$$$d_2(\mathbf x, \mathbf y)=\sqrt {{\sum_{i=1}^n ({x_i-y_i})^2}}$$

There is a simpler version. If we do not use squared difference, instead adding the absolute values of the gaps, we get the so-called taxicab distance, or the Manhattan distance:

$$d_1(\mathbf x, \mathbf y)={\sum_{i=1}^n \left\lvert{x_i-y_i}\right\rvert}$$

Taxicab is an apt description. It is the distance we get when we look at a city map and determine the number of blocks to travel from one point to another. This distance measure is also called a city block distance.

Notice that this is similar to the original Euclidean formula, except that we do not have the power of 2 and squareroot. If we replace the “2” with “1,” we change from the Euclidean distance to the taxicab distance. In machine learning terminology, you will hear $\mathsf L^2$ or $\mathscr l_2$ distance. This is the Euclidean distance. The $\mathsf L^1$ or $\mathscr l_1$ distance is the taxicab distance.

More generally, both are specific variations of a general case, the Minkowski distance metric:

$$d_p(\mathbf x, \mathbf y)=\left({\sum_{i=1}^n \left\lvert{x_i-y_i}\right\rvert^p}\right)^{1/p}$$

There are a few more cautionary points when calculating distance in kNN. The data has to be of a consistent scale on all dimensions. For example, a feature that scales from 0 to 100 will overpower a feature whose scale is from 0 to 1. The squared errors will be huge for the first feature, artificially suggesting that the first feature is more important, which might not be the case. Thus, each feature has to be normalized to a consistent scale first.

This focus on consistent distance measurements also implies the formula satisfy what is known as a metric, i.e., a distance metric (not a distance measure). The term metric has a precise mathematical definition. For a distance calculation to be considered a metric, it has to satify the following:

  • the distance between two points is zero or positive (non-negative; $d(\mathbf x,\mathbf y)\ge 0$)
  • if two points are equal, the distance between them is zero (identity; $d(\mathbf x,\mathbf y)=0 \Leftrightarrow \mathbf x=\mathbf y$)
  • the distance from point A to point B should be equal to the distance from point B to point A (symmetry; $d(\mathbf x,\mathbf y)=d(\mathbf y,\mathbf x)$)
  • the distance from point A to point C has to be less than or equal to the combined distance from point A to an interim point B and the distance from the interim point B to point C (triangle inequality; $d(\mathbf x,\mathbf z)\le d(\mathbf x,\mathbf y)+d(\mathbf y,\mathbf z)$)

Extending the scale constraint further, kNN is also a bit clunky to use on purely categorical data (e.g., hot, warm, neutral, cool, cold) where real number values do not make sense, or datasets that contain a mix of continuous numerical data and categorical or binary data. There are ways to use kNN in those cases still, but we will not cover that here in detail.

Binary features are easy. The distance calculation above still works. With binary features, the feature vector becomes a binary vector. We can therefore use well-known binary/set similarity measures such as Hamming, Jaccard, Tanimoto (not a metric), Cosine similarity (not strictly for binary vectors) and so on. To handle categorical data, we can extend the binary feature concept. This approach is known as one-hot encoding, where each possible value of a categorical feature is encoded as on/off (e.g., one binary feature for hot, another for warm, yet another for neutral, and so on). This is almost similar to the concept of dummy variables in statistics.

Different scales and categorical data is not an issue in our MNIST dataset. All datapoints are of the same type (pixel intensity) and range (0-255). For our test purposes, we will use the Euclidean distance.

Weights of neighbors

If we are to use k>1, we have to decide how we value the classes suggested by several neighbors. The standard kNN calculation treats each neighbor equally, relying on the training data local class densities within the first k neighbors to drive the majority decision. Intuitively however, the data point closest to our test point has to matter more (i.e., more likely to be of the class that governs the unknown point), than those farther away. Hence, we could define the weight based on the distance to the unknown point.

There is a version of kNN that incorporates distance weighing to the neighboring points. Unsurprisingly, it is called distance-weighed kNN. It assign a value of 1 to the nearest neighbor, 0 to the farthest, and some linear interpolation for the rest of the neighbors. The literature also has other methods for calculate the weights, including other modifications to the distance-weighed kNN. These modification tend to improve kNN performance on UCI datasets.

For our MNIST tests, we will assume k=1. Whenever we use k>1 in our experiments, we will make the weight of each neighbor equal to simplify the calculations.

Weights of features

The basic kNN algorithm assumes that the features contribute equally, hence the summation is over all features. In reality, certain types of objects are identifiable by a subset of its many features. For instance, relative to other sports balls, an (American) football’s overriding and consistent feature is its oblong shape. It does not have to be of a specific color, although a leather shade helps identification. For a basketball or a football (soccer), the stitches and panels are very distinct. The same could be said of the digits in MNIST where each digit has features to itself or a subset of all 10 digits. Irrelevant features tend to cause misclassifications.

The literature on kNN provides several improvements on the basic kNN where a parameter weight is added for each feature based on some calculation. We will not explore those techniques here. Instead, we will explore our own ideas of assigning weights to features, without regard to existing literature (for the joy of exploration, of course, plus we all know that literature search is not as much fun as figuring things out!).

Note also that some distance metric might already incorporate some implicit feature weighing, such as the Mahalanobis distance:

$$d_m(\mathbf x, \mathbf y)=\sqrt {(\mathbf x-\mathbf y)^T C^{-1}(\mathbf x-\mathbf y)}$$

Notice that it is similar to the Euclidian distance (in vector form):

$$d_2(\mathbf x, \mathbf y)=\sqrt {(\mathbf x-\mathbf y)^T (\mathbf x-\mathbf y)}$$

The only difference is the presence of $C$, which is the covariance matrix of $\mathbf x$.

Other ML algorithms prioritize certain features by automatically adjusting the weights of each feature, lending more weight to important elements and reducing the noisy features. For example, the back-propagation and gradient descent of a neural network will automatically adjust the value of a feature weight such that the overall accuracy increases (or minimizes a loss function). Unfortunately, in standard kNN, we are stuck with a uniform weight value for each feature.

In our experiment, unless the subject of a trial algorithm, we will assume features have equal weights.

Weights of instances

The weight of instances and the neighbors sound similar. However, whereas the weight of the neighbor affects the weight of the vote for each class, the weight of the instance affects the distance calculation for that instance. In other words, if a query point lands closer to Point B, we could bias the final distance calculation to favor Point A.

But why would we assume one point is 'better' than another? We could think of outliers, where some training data points might not be very informative, even misleading. This is not strictly an issue for the algorithm, rather an issue on preparing the training data perhaps due to labeling errors or including data points that could be interpreted as one type, even if factually of another type (for example, due to a very weird writing style). This parameter is not typically explored however in improving kNN. Each data point is equally considered for the final distance calculation.

One technique where instance weights are implicitly calculated is through the use of class prototypes (mean features of the class) constructed from training set data, then running a kNN against the prototypes instead of the original training data. The original nearest neighbor instances, while no longer used directly, is implicitly weighed into the distance calculation against the class prototypes.

In our experiment, we will assume each instance is correct and equally likely to exist.

Conclusions

The literature on kNN is vast. It is an old technique. It remains a reliable baseline when presented with enough training data. Being a memory-based learner, it is memory-intensive and slow relative to other algorithms. Being an instance-based classifier, it tends to overfit and its structure is highly dependent on the subset of data used for training (i.e., high variance). It could be argued that some ML algorithms reduce to a variant of the kNN algorithm, or the kNN turns out to be a special case of other algorithms. Despite its weaknesses, the feasibility of kNN as a good ML algorithm is well-documented. For its simplicity and logic, it is a good tool to run experiments, which we will do in future posts.