%matplotlib inline
import os
SHOGUN_DATA_DIR=os.getenv('SHOGUN_DATA_DIR', '../../../data')
from shogun import StringCharFeatures, RAWBYTE, HashedDocDotFeatures, NGramTokenizer

"""
Explanation: Large scale document classification with the Shogun Machine Learning Toolbox
By Evangelos Anagnostopoulos (GitHub ID: <a href="http://github.com/van51">van51</a>)
This notebook is about <a href="http://en.wikipedia.org/wiki/Document_classification">Document classification</a> in Shogun. After providing a semi-formal introduction to the <a href="http://en.wikipedia.org/wiki/Bag-of-words_model">Bag of Words</a> model and its limitations, we illustrate the <a href="http://en.wikipedia.org/wiki/Feature_hashing">hashing trick</a>. This is consolidated by performing experiments on the large-scale webspam data set.
Document Classification
<h3>Background</h3>
Document classification consists of assigning documents to specific predefined categories. This usually works by transforming the documents into a vector space that is acceptable by common classifiers, like SVMs, and then learning a model on that new representation.
The most common and the most widely used representation of document collections is the <a href="http://en.wikipedia.org/wiki/Bag-of-words_model">Bag of Words</a> model.The BoW representation considers each document as a collection of tokens. A token is defined as the minimum arbitrary sequence of characters that can be considered as atomic. Tokens depending on the choice of the user can be either whole words or n-grams, etc. A simple approach is to consider every possible token in your document collection as a different dimension in a feature space and then vectorize each document by assigning 1 to every dimension that corresponds to a token contained in that document and 0 to every other. This is the simpler approach and is known as the Boolean Vector Space Model. Another approach is, instead of using {1,0}, to use a weight for every term, like their frequencies, but this is out of our scope.<br>
Let's now consider a document collection consisting of the following documents:<br>
&nbsp;&nbsp;&nbsp;&nbsp;<b>Document 1</b> = "This is the first document"<br>
&nbsp;&nbsp;&nbsp;&nbsp;<b>Document 2</b> = "Document classification: Introduction"<br>
&nbsp;&nbsp;&nbsp;&nbsp;<b>Document 3</b> = "A third document about classification"<br>
and suppose that we consider as tokens, every word that appears in the collection.<br>
Then we would arrive to the following representation:
<table><tr><td></td><td><b>Document 1</b></td><td><b>Document 2</b></td><td><b>Document 3</b></td></tr>
    <tr><td>[0] this</td><td>1</td><td>0</td><td>0</td></tr>
    <tr><td>[1] is</td><td>1</td><td>0</td><td>0</td></tr>
    <tr><td>[2] the</td><td>1</td><td>0</td><td>0</td></tr>
    <tr><td>[3] first</td><td>1</td><td>0</td><td>0</td></tr>
    <tr><td>[4] document</td><td>1</td><td>1</td><td>1</td></tr>
    <tr><td>[5] classification</td><td>0</td><td>1</td><td>1</td></tr>
    <tr><td>[6] introduction</td><td>0</td><td>1</td><td>0</td></tr>
    <tr><td>[7] a</td><td>0</td><td>0</td><td>1</td></tr>
    <tr><td>[8] third</td><td>0</td><td>0</td><td>1</td></tr>
    <tr><td>[9] about</td><td>0</td><td>0</td><td>1</td></tr>
</table>
The above matrix is called the <a href="http://en.wikipedia.org/wiki/Document-term_matrix">document-term matrix</a> and is widely used in Information Retrieval applications. In our case since every document is now represented by a numerical vector, we can just pass this as a dataset to common classifiers, including kernel machines. <br>
<h3>Limitations on large scale collections</h3>
Although the aformentioned procedure is pretty intuitive and straight-forward it has some drawbacks when considered on large collections of documents.<br>
<ul><li>First of all, the dimensionality of the feature space (or else, the number of distinct tokens in the collection) is usually <b>not known beforehand</b> and requires a pass over the entire dataset to be calculated. This can make the creation of the document-term matrix trickier, especially in online learning scenarios.</li>
    <li>Secondly, this dimensionality can grow to be <b>very large</b>; imagine considering as tokens the set of every possible word, or every possible 8-gram.</li>
    <li>Thirdly, this approach requires an <b>additional dictionary</b> in order to work that maps every token to its appropriate dimension, for instance every appearance of 'this' above should always be mapped to dimension 0, and this mapping data structure will also grow to be <b>very large.</b></li></ul>
<br>
<h3>The hashing response</h3>
A convenient approach that eliminates all the problems that the BoW representation introduced is to use a hash function to transform all the tokens into numerical entities and then restrict those values into a range [0, n-1] using the modulo function. The values in the [0, n-1] range will correspond to indices on a n-dimensional document-term matrix.<br>This way: <ul>
<li>the dimensionality of the target feature space is now <b>known beforehand</b>, as it's simply the number of all possible outcomes of the hash functions, restricted to a specific range [0, n-1] by using the modulo function. No extra passes over the dataset are needed this way.</li>
<li>In addition, the dimension can now be <b>restricted to a certain size</b> that is convenient depending on the limitations of the available hardware</li>
<li>The dictionary is also <b>not needed</b> anymore since each token is directly mapped to the appropriate dimension through the outcome of the hash function.</li>
</ul>
The above approach will result in a hashed document-term matrix, similar to the one shown above, of a pre-defined dimension size (number of rows).<br>
Each token will now be assigned to a random dimension, although always the same for the same token, that is determined from the hash function.<br> This will probably incure some collisions, but if the hash functions is good enough and our choice for the dimension size is sufficiently large it will not be a problem. Especially if we are considering large collections. <br>
After the creation of the hashed document-term matrix, the intuition is to pass it as a dataset to a linear classifier that will take care of the rest!<br>
<h3>On-the-fly Hashing</h3>
A naive way to implement the hashing procedure is to pre-hash every token of the collection and store it to the disk for later access. However, this approach is not very convenient since it will require space analogous to the size of the original dimension. It will also make the experimentation on different parameters, like the size of the hash function or the choice of tokens, a bit trickier since one would have to re-convert the entire collection based on the choice of those parameters.<br>
The response to that is to read our collection as it is and compute the hash of every token only when it's required, on-the-fly.<br>
<h3>On-the-fly Hashing with Shogun</h3>
We will now have a look at how the above idea is represented in the Shogun Toolbox. That is we will see how we can load our document collection in memory and consider a hashed document-term matrix with the hashing of every document (or token more specifically) happening on-the-fly, only when it's required to be computed. Altough it may sound a bit tricky, it's actually pretty straightforward and here is how.<br><br>
First of all we import the required components from the shogun library.
End of explanation
"""


doc_1 = "this is the first document"
doc_2 = "document classification introduction"
doc_3 = "a third document about classification"
document_collection = [doc_1, doc_2, doc_3]

"""
Explanation: The <a href="http://shogun-toolbox.org/doc/en/current/classshogun_1_1CStringFeatures.html">StringCharFeatures</a> are nothing more than a collection of char vectors, where each one represents a single document.<br>
<a href="http://shogun-toolbox.org/doc/en/current/namespaceshogun.html#a7fffbfce3d76cf49bde3916d2">RAWBYTE</a> is simply an enum that specifies that every possible character can be found in the collection.<br>
The HashedDocDotFeatures is where all the magic happens! This class is responsible for encapsulating the document collection and providing the calculation of the hashed representation of each document whenever it is required. <br>
The NGramTokenizer is the tokenizer we will use on our collection. Its job is to parse a document and create the tokens. As its name suggests, it creates a sequence of ngrams. Another option would be the DelimiterTokenizer, which allows the user to specify the delimiter that will be used in order to create the tokens. <br><br><br>
Suppose now that we have the following documents:
End of explanation
"""


from shogun import BinaryLabels
from numpy import array

labels = BinaryLabels(array([-1, 1, 1]))

"""
Explanation: We will take some time off now to assign each document to a category, to ease our work later on. Since the two last documents refer to classification we will label them as "Relevant" and the first document as "Not relevant". Since we only have two categories, this makes it a binary problem and we will represent "Relevant" as 1 and "Not relevant" as -1.
End of explanation
"""


string_features = StringCharFeatures(document_collection, RAWBYTE)

"""
Explanation: We now create our document collection:
End of explanation
"""


hash_size = 8
tokenizer = NGramTokenizer(5)
normalize = True

hashed_feats = HashedDocDotFeatures(hash_size, string_features, tokenizer, normalize)

"""
Explanation: Now we will create the object responsible for the hashed BoW representation. We are going to specify that we want a hash size of 8 bits, which will be translated to a dimension of size 2^8 = 256 (powers of 2 are considered to speed up computatins) and a tokenizer that creates 5-grams. We will also specify that we want to
normalize each hashed vector with regards to the square root of the document length, which is a common approach:
End of explanation
"""


from shogun import SVMOcas

C = 0.1
epsilon = 0.01
svm = SVMOcas(C, hashed_feats, labels)
svm.set_epsilon(epsilon)

"""
Explanation: And that was it!<br>
The hashed_feats object now has all the required parameters and knows how to communicate and provide the hashed representation <b>only</b> when it is needed by the various algorithms of Shogun. <br><br>
So how do we proceed to actually learn a model that can classify documents?<br>
We said before that the idea after we have taken care of the hashing is to use a linear classifier.<br>
Shogun has many <a href="http://shogun-toolbox.org/doc/en/current/classshogun_1_1CLinearMachine.html#a13f46585aefbfa0376c051817d6f5c46">linear</a> classifiers available, but for the moment we will consider <a href="http://shogun-toolbox.org/doc/en/current/classshogun_1_1CSVMOcas.html">SVMOcas</a> and we will see how to use it:
End of explanation
"""


_=svm.train()

"""
Explanation: We have now created our svm. The parameter C specifies the regularization constant. The best choice for this parameter will usually be selected after a model selection process.<br>
Now, let's train our svm:
End of explanation
"""


predicted_labels = svm.apply()
print (predicted_labels.get_labels())

"""
Explanation: When the execution finishes, we will have learned our so desired linear model!  Mind that for large collections the above call can take hours.<br>
Now that we have a model ready, we would like to see how well it does. Let's see what it predicts for our training data:
End of explanation
"""


k = 3 # number of tokens, up to which we allow it to skip
n = 3 # number of tokens, up to which we allow it to combine

hashed_feats_quad = HashedDocDotFeatures(hash_size, string_features, tokenizer, normalize, n, k)

"""
Explanation: We can see that it misclassified the first document. This has to do with the nature of our overly-simplified toy dataset which doesn't provide enough information. However, another option of the HashedDocDotFeatures class will allow us to extract some more information from the same dataset!<br>
<br>
<h3>Quadratic Features</h3>
Generally, the quadratic features refer to the production of new features from our current dataset, by multiplying currently existing features.
<br>For example, if we have a dataset consisting of the following example vector:
<table><tr><td></td><td>A</td><td>B</td><td>C</td></tr>
    <tr><td>x:</td><td>2</td><td>3</td><td>1</td></tr></table>
We can create the following extra quadratic features:
<table><tr><td></td><td>A</td><td>B</td><td>C</td><td>AA</td><td>AB</td><td>AC</td><td>BB</td><td>BC</td><td>CC</td></tr>
    <tr><td>x:</td><td>2</td><td>3</td><td>1</td><td>2*2=4</td><td>2*3=6</td><td>2*1=2</td><td>3*3=9</td><td>3*1=3</td><td>1*1=1</td></tr></table>
The creation of these features can be very useful, however it should be used with caution since it will also increase the training time and the size requirements. <br>
The idea behind the quadratic features is very simple and can be easily implemented for numerical features. However in the case of text collections, where we have tokens instead of numbers, things are not so straightforward.<br><br>
The approach we have selected in Shogun for the text collections does not compute all the quadratic features, but only a subset of them defined using a k-skip n-grams rule. N-grams in this context refer to a collection of up to n consecutive tokens and should not be confused with our choice for what consists a token, where it can be whole words or ngrams themselves. The k-skip in front allows us to skip up to k tokens when we group them in up-to n sized groups. Although this is a simple idea it can be a bit confusing when introduced through a definition, so let's have a look at an example:
Suppose we have the following <b>consecutive</b> tokens:<br>
Tokens : ["a", "b", "c", "d"]<br>
and that we want to consider a 2-skip 2-grams approach. This means that we can combine up to 2 tokens (that is a single token or two tokens) while skipping up to 2 tokens between them (that means we can skip 0,1 and 2 tokens). Therefore, we obtain the following combinations: <br>
["a" (0-skips, 1-gram), "ab" (0-skips, 2-gram), "ac" (1-skip 2-gram), "ad" (2-skips, 2-gram), "b" (0-skips, 1-gram), "bc" (0-skips, 2-gram),
"bd" (1-skip, 2-gram), "c" (0-skip, 1-gram), "cd" (0-skip, 2-gram), "d" (0-skip, 1-gram)] <br>
<br>These newly created tokens are then treated like our regular ones and hashed to find their appropriate dimension.
<br>As discussed above this idea may enhance our information on the dataset, but it also requires more time to be computed. So if used, the choice of k and n should be kept to small numbers.<br>
<h3>Quadratic Features with HashedDocDotFeatures</h3>

In order to use the k-skip n-gram approach explained above, one only has to specify his choice for k and n to the HashedDocDotFeatures object he is creating.<br>
So, by keeping our previous choice of parameters intact and introducing the k and n we obtaing the following piece of code :
End of explanation
"""


svm.set_features(hashed_feats_quad)
svm.train()
predicted_labels = svm.apply()
print(predicted_labels.get_labels())

"""
Explanation: If we do not specify these numbers, as we did not do before, then, (you maybe have guessed it!) they are set by default to the following values, n=1, k=0!
<br>
Let's see if there was any improvement in our predicting capabilities.<br>
By keeping the same svm settings, we will train on the new HashedDocDotFeatures object we have just created. Although we have the same document collection underneath, we are now considering a different representation model because of the quadratic features.
End of explanation
"""


from pylab import *

# HashedDocDotFeatures results
hashed_training_examples = [5000, 10000, 15000, 20000, 25000, 30000, 50000, 100000]

# For C=1
hashed_C_1_sec = [2682.750000,5202.690000,8120.460000,10846.410000,13944.200000,17016.840000,30496.720000,66302.950000]
hashed_C_1_roc = [0.980730,0.986382,0.988894,0.990666,0.991602,0.991957,0.993680,0.995184]

# For C=0.1
hashed_C_01_sec = [1074.130000,2142.390000,3434.710000,4641.380000,5984.530000,7206.040000,12864.270000,28393.540000]
hashed_C_01_roc = [0.976560,0.982660,0.985251,0.987380,0.988368,0.989022,0.990950,0.993197]

# Spectrum kernel results
kernel_training_examples = [5000, 10000, 15000, 20000, 25000]

# For C=1
kernel_C_1_sec = [2912.410000,6543.220000,10840.550000,16108.360000,19899.610000]
kernel_C_1_roc = [0.971284,0.976628,0.979715,0.982084,0.984355]

# For C=0.1
kernel_C_01_sec = [1441.380000,3261.870000,5071.040000,7568.130000,10436.430000]
kernel_C_01_roc = [0.946308,0.955245,0.961576,0.965204,0.968264]

figure(figsize=(12,6))
subplot(1,2,1)
plot(hashed_training_examples, hashed_C_1_sec, 'b')
plot(kernel_training_examples, kernel_C_1_sec, 'r')
title("Time comparison for C=1")
xlabel("Number of examples")
ylabel("Time in seconds")
legend(["HashedDocDotFeatures", "Spectrum Kernel"], loc=2)

subplot(1,2,2)
plot(hashed_training_examples, hashed_C_1_roc, 'b')
plot(kernel_training_examples, kernel_C_1_roc, 'r')
title("Area under ROC comparison for C=1")
xlabel("Number of examples")
ylabel("auROC")
_=legend(["HashedDocDotFeatures", "Spectrum Kernel"], loc=4)

clf
figure(figsize=(12,6))
subplot(1,2,1)
plot(hashed_training_examples, hashed_C_01_sec, 'b')
plot(kernel_training_examples, kernel_C_01_sec, 'r')
title("Time comparison for C=0.1")
xlabel("Number of examples")
ylabel("Time in seconds")
ylim((0,70000))
legend(["HashedDocDotFeatures", "Spectrum Kernel"], loc=2)

subplot(1,2,2)
plot(hashed_training_examples, hashed_C_01_roc, 'b')
plot(kernel_training_examples, kernel_C_01_roc, 'r')
title("Area under ROC comparison for C=0.1")
xlabel("Number of examples")
ylabel("auROC")
_=legend(["HashedDocDotFeatures", "Spectrum Kernel"], loc=4)

"""
Explanation: Better!<br>
<b>Attention!</b> Some clarifications now will follow on the Quadratic Features to clear any misunderstandings:
<ul><li>The term "ngram" can come up on two different levels. <br>The first one is in the tokenization process of the documents, where we have a raw stream of characters and our job there is to split the document on sequences of characters that are considered atomic. For instance, we may decide that sequences of three characters are the basic units which make up a document and therefore we are considering 3-grams (for tokens).<br>
The second level, is when we are combining the tokens generated in the previous phase to create quadratic features, by following the aforementioned k-skip n-grams rule. Now we are not considering anymore the document as a sequence of characters, but rather as a sequence of tokens. And it is on those tokens that we are now applying the rule. Therefore, if we decide to apply a 0-skip 3-grams rule then we may combine up to 3 tokens (not characters).
    In conclusion, the way the quadratic features work is completely independent of the Tokenizer used on the documents. We could have chosen a DelimiterTokenizer and still consider ngrams (of tokens) on the quadratic level.</li>
    <li>The "quadratic" in front of "quadratic features" also refers to the way the computing time increases when we select this approach! So it should only be used when it is necessary.</li></ul>

Experiments on real datasets
The example that was demonstrated before was very small and simple and was manipulated for demonstration purposes, to show the capabilities of the HashedDocDotFeatures class. <br> <br><h3>Webspam</h3>Now let's have a look at the results the HashedDocDotFeatures class together with SVMOcas produced when faced against the <a href="ftp://largescale.ml.tu-berlin.de/largescale/webspam/">webspam</a> dataset, which consists of 350000 documents labelled either as "Spam" or as "Not spam".<br>We demonstrate results when compared against SVMLight, using the Spectrum Kernel. <br>
We consider the following settings: <br>number of bits in hash = 16, 8-grams for tokens, SVM epsilon = 0.01.
End of explanation
"""


