"""
In this notebook, we will discuss a few specific examples of engineering features for use in machine learning algorithms, including:

- One-hot encoding for categorical data
- Frequency-based encoding for textual data
- Histogram-of-gradient (HOG) features for image data

In each of these, I will give a brief example of applying these features to a dataset in the course of a machine learning algorithm.

At the end of this notebook, you will:
- Understand several common approaches to feature engineering
- Gain exposure to more examples of Scikit-learn’s API applied to real-world datasets
"""
"""
We'll start with the standard imports:
"""
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
plt.style.use('seaborn')
"""
# Categorical Features

One common type of non-numerical data is *categorical* data.
For example, imagine you are exploring some data on housing prices, and along with numerical features like "price" and "rooms", you also have "neighborhood" information.
For example, your data might look something like this:
"""
data = [
    {'price': 850000, 'rooms': 4, 'neighborhood': 'Queen Anne'},
    {'price': 700000, 'rooms': 3, 'neighborhood': 'Fremont'},
    {'price': 650000, 'rooms': 3, 'neighborhood': 'Wallingford'},
    {'price': 600000, 'rooms': 2, 'neighborhood': 'Fremont'}
]
"""
You might be tempted to encode this data with a straightforward numerical mapping:
"""
{'Queen Anne': 1, 'Fremont': 2, 'Wallingford': 3};
"""
It turns out that this is not generally a useful approach in scikit-learn: the package's models make the fundamental assumption that numerical features reflect algebraic quantities.
Thus, such a mapping would imply, for example, that *Queen Anne < Fremont < Wallingford*, or even that *Wallingford - Queen Anne = Fremont*, which (niche demographic jokes aside) does not make much sense.

In this case, one proven technique is to use *one-hot encoding*, which effectively creates extra columns indicating the presence or absence of a category with a value of 1 or 0, respectively.
When your data comes as a list of dictionaries, scikit-learn's ``DictVectorizer`` will do this for you:
"""
from sklearn.feature_extraction import DictVectorizer
vec = DictVectorizer(sparse=False, dtype=int)
vec.fit_transform(data)
"""
Notice that the neighborhood column has been expanded into three separate columns, representing the three neighborhood labels, and that each row has a 1 in the column associated with its neighborhood.
With these categorical features thus encoded, you can proceed as normal with fitting a scikit-learn model.

To see the meaning of each column, you can inspect the feature names:
"""
vec.get_feature_names()
import pandas as pd
pd.DataFrame(vec.fit_transform(data),
             columns=vec.get_feature_names())
"""
There is one clear disadvantage of this approach: if your category has many possible values, this can *greatly* increase the size of your dataset.
However, because the encoded data contains mostly zeros, a sparse output can be a very efficient solution:
"""
vec = DictVectorizer(sparse=True, dtype=int)
vec.fit_transform(data)
"""
Many (though not yet all) of the scikit-learn estimators accept such sparse inputs when fitting and evaluating models. ``sklearn.preprocessing.OneHotEncoder`` and ``sklearn.feature_extraction.FeatureHasher`` are two additional tools that scikit-learn includes to support this type of encoding.
"""
"""
# Text Features

Another common need in feature engineering is to convert text to a set of representative numerical values.
For example, most automatic mining of social media data relies on some form of encoding the text as numbers.
One of the simplest methods of encoding data is by *word counts*: you take each snippet of text, count the occurrences of each word within it, and put the results in a table.

For example, consider the following set of three phrases:
"""
sample = ['problem of evil',
          'evil queen',
          'horizon problem']
"""
For a vectorization of this data based on word count, we could construct a column representing the word "problem," the word "evil," the word "horizon," and so on.
While doing this by hand would be possible, the tedium can be avoided by using scikit-learn's ``CountVectorizer``:
"""
from sklearn.feature_extraction.text import CountVectorizer

vec = CountVectorizer()
X = vec.fit_transform(sample)
X
"""
The result is a sparse matrix recording the number of times each word appears; it is easier to inspect if we convert this to a ``DataFrame`` with labeled columns:
"""
import pandas as pd
pd.DataFrame(X.toarray(), columns=vec.get_feature_names())
"""
There are some issues with this approach, however: the raw word counts lead to features that put too much weight on words that appear very frequently, and this can be suboptimal in some classification algorithms.
One approach to fix this is known as *term frequency-inverse document frequency* (*TF–IDF*), which weights the word counts by a measure of how often they appear in the documents.
The syntax for computing these features is similar to the previous example:
"""
from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer()
X = vec.fit_transform(sample)
pd.DataFrame(X.toarray(), columns=vec.get_feature_names())
"""
## Example: Text Classification
"""
"""
Let's take a look at classifying some text based on this approach.

We'll use the *20 newsgroups* dataset, available from the web via scikit-learn's dataset tools:
"""
from sklearn.datasets import fetch_20newsgroups
categories = ['talk.religion.misc', 'soc.religion.christian',
              'sci.space', 'comp.graphics']
train = fetch_20newsgroups(subset='train', categories=categories)
test = fetch_20newsgroups(subset='test', categories=categories)
"""
The data consist of the full text content of messages posted to early internet newsgroups. For example:
"""
print(train.data[5])
"""
The target is a set of numbers encoding which newsgroup the messages belong to:
"""
train.target[:5]
train.target_names
"""
Let's use the ``TfidfVectorizer`` and put it in a pipeline with a ``MultinomialNB`` classifier:
"""
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline

model = make_pipeline(TfidfVectorizer(), MultinomialNB())
"""
We can now fit the model to the training data, and predict the labels of the test data:
"""
model.fit(train.data, train.target)
labels = model.predict(test.data)
"""
Let's evaluate this with a confusion matrix:
"""
from sklearn.metrics import confusion_matrix
import seaborn as sns

mat = confusion_matrix(test.target, labels)
sns.heatmap(mat.T, square=True, annot=True, fmt='d', cbar=False, cmap='Blues',
            xticklabels=train.target_names, yticklabels=train.target_names)
plt.xlabel('true label')
plt.ylabel('predicted label');
"""
More interestingly, we can now use this classifier to predict, given some phrase, what general topic the phrase might be referring to.

We'll use the following quick convenience function:
"""
def predict_category(s, train=train, model=model):
    pred = model.predict([s])
    return train.target_names[pred[0]]
predict_category('sending a payload to the ISS')
predict_category('discussing islam vs atheism')
predict_category('determining the screen resolution')
"""
Remember that this is nothing more sophisticated than a simple probability model for the (weighted) frequency of each word in the string; nevertheless, the result is striking.
Even a very naive algorithm, when used carefully and trained on a large set of high-dimensional data, can be surprisingly effective.
"""
"""
# Image Features

We've seen an approach to image classification that relies on the pixels themselves as features.
The problem is that the relationships between pixels are often more meaningful: for example, lines and gradients tell us much more about the content of the image, and are much more robust to lighting levels and other incidental differences between images.

For more complicated tasks involving images, it can be useful to derive other features that are more informative.
There are many approaches to this, but the one we'll demo here is known as *histograms of gradients* (HOG).
HOG involves the following steps:

1. Optionally prenormalize images. This leads to features that resist dependence on variations in illumination.
2. Convolve the image with two filters that are sensitive to horizontal and vertical brightness gradients. These capture edge, contour, and texture information.
3. Subdivide the image into cells of a predetermined size, and compute a histogram of the gradient orientations within each cell.
4. Normalize the histograms in each cell by comparing to the block of neighboring cells. This further suppresses the effect of illumination across the image.
5. Construct a one-dimensional feature vector from the information in each cell.

A fast HOG extractor is built into the scikit-image project, and we can try it out relatively quickly and visualize the oriented gradients within each cell:
"""
from skimage import data, color, feature
import skimage.data

image = color.rgb2gray(data.chelsea())
hog_vec, hog_vis = feature.hog(image, visualize=True)

fig, ax = plt.subplots(1, 2, figsize=(12, 6),
                       subplot_kw=dict(xticks=[], yticks=[]))
ax[0].imshow(image, cmap='gray')
ax[0].set_title('input image')

ax[1].imshow(hog_vis, cmap='binary')
ax[1].set_title('visualization of HOG features');
"""
## HOG in Action: A Simple Face Detector

Using these HOG features, we can build up a simple facial detection algorithm with any scikit-learn estimator; here we will use a linear support vector machine (refer back to _Machine Learning with Scikit-Learn: Support Vector Machines_ if you need a refresher on this).
The steps are as follows:

1. Obtain a set of image thumbnails of faces to constitute "positive" training samples.
2. Obtain a set of image thumbnails of non-faces to constitute "negative" training samples.
3. Extract HOG features from these training samples.
4. Train a linear SVM classifier on these samples.
5. For an "unknown" image, pass a sliding window across the image, using the model to evaluate whether that window contains a face or not.
6. If detections overlap, combine them into a single window.

Let's go through these steps and try it out.
"""
"""
## 1. Obtain a Set of Positive Training Samples

Let's start by finding some positive training samples that show a variety of faces.
We have one easy set of data to work with—-the Labeled Faces in the Wild dataset, which can be downloaded by scikit-learn:
"""
from sklearn.datasets import fetch_lfw_people
faces = fetch_lfw_people()
positive_patches = faces.images
positive_patches.shape
"""
This gives us a sample of 13,000 face images to use for training.
"""
"""
## 2. Obtain a Set of Negative Training Samples

Next, we need a set of similarly sized thumbnails that *do not* have a face in them.
One way to do this is to take any corpus of input images and extract thumbnails from them at a variety of scales.
Here we can use some of the images shipped with scikit-image, along with scikit-learn's ``PatchExtractor``:
"""
from skimage import data, transform

imgs_to_use = ['camera', 'text', 'coins', 'moon',
               'page', 'clock', 'immunohistochemistry',
               'chelsea', 'coffee', 'hubble_deep_field']
images = [color.rgb2gray(getattr(data, name)())
          for name in imgs_to_use]
from sklearn.feature_extraction.image import PatchExtractor

def extract_patches(img, N, scale=1.0, patch_size=positive_patches[0].shape):
    extracted_patch_size = tuple((scale * np.array(patch_size)).astype(int))
    extractor = PatchExtractor(patch_size=extracted_patch_size,
                               max_patches=N, random_state=0)
    patches = extractor.transform(img[np.newaxis])
    if scale != 1:
        patches = np.array([transform.resize(patch, patch_size, mode='constant')
                            for patch in patches])
    return patches

negative_patches = np.vstack([extract_patches(im, 1000, scale)
                              for im in images for scale in [0.5, 1.0, 2.0]])
negative_patches.shape
"""
We now have 30,000 suitable image patches that do not contain faces.
Let's take a look at a few of them to get an idea of what they look like:
"""
fig, ax = plt.subplots(6, 10)
for i, axi in enumerate(ax.flat):
    axi.imshow(negative_patches[500 * i], cmap='gray')
    axi.axis('off')
"""
Our hope is that these would sufficiently cover the space of non-faces that our algorithm is likely to see.
"""
"""
## 3. Combine Sets and Extract HOG Features

Now that we have these positive samples and negative samples, we can combine them and compute HOG features.
This step takes a little while, because the HOG features involve a nontrivial computation for each image:
"""
from itertools import chain
X_train = np.array([feature.hog(im)
                    for im in chain(positive_patches,
                                    negative_patches)])
y_train = np.zeros(X_train.shape[0])
y_train[:positive_patches.shape[0]] = 1
X_train.shape
"""
We are left with 43,000 training samples in 1,215 dimensions, and we now have our data in a form that we can feed into scikit-learn!
"""
"""
## 4. Training a Support Vector Machine

Next, we use the tools we have been exploring in this Oriole to create a classifier of thumbnail patches.
For such a high-dimensional binary classification task, a linear support vector machine is a good choice.
We will use scikit-learn's ``LinearSVC``, because in comparison to ``SVC``, it often has better scaling for a large number of samples.

First, though, let's use a simple Gaussian naive Bayes classifier to get a quick baseline:
"""
from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import cross_val_score

cross_val_score(GaussianNB(), X_train, y_train)
"""
We see that on our training data, even a simple naive Bayes algorithm gets us upward of 90% accuracy.
Let's try the support vector machine, with a grid search over a few choices of the C parameter:
"""
from sklearn.svm import LinearSVC
from sklearn.model_selection import GridSearchCV
grid = GridSearchCV(LinearSVC(), {'C': [1.0, 2.0, 4.0, 8.0]})
grid.fit(X_train, y_train)
grid.best_score_
grid.best_params_
"""
Let's take the best estimator and retrain it on the full dataset:
"""
model = grid.best_estimator_
model.fit(X_train, y_train)
"""
This ``fit`` step finds our best estimator for detecting the presence of a face in a small image thumbnail.
"""
"""
## 5. Find Faces in a New Image

Now that we have this model in place, let's grab a new image and see how the model does.
We will use one portion of the astronaut image for simplicity (see a discussion of this below in "Caveats and Improvements," and run a sliding window over it and evaluate each patch:
"""
test_image = skimage.data.astronaut()
test_image = skimage.color.rgb2gray(test_image)
test_image = skimage.transform.rescale(test_image, 0.5, mode='constant')
test_image = test_image[:160, 40:180]

plt.imshow(test_image, cmap='gray')
plt.axis('off');
"""
Next, let's create a window that iterates over patches of this image, and compute HOG features for each patch:
"""
def sliding_window(img, patch_size=positive_patches[0].shape,
                   istep=2, jstep=2, scale=1.0):
    Ni, Nj = (int(scale * s) for s in patch_size)
    for i in range(0, img.shape[0] - Ni, istep):
        for j in range(0, img.shape[1] - Ni, jstep):
            patch = img[i:i + Ni, j:j + Nj]
            if scale != 1:
                patch = transform.resize(patch, patch_size)
            yield (i, j), patch
            
indices, patches = zip(*sliding_window(test_image))
patches_hog = np.array([feature.hog(patch)
                        for patch in patches])
patches_hog.shape
"""
Finally, we can take these HOG-featured patches and use our model to evaluate whether each patch contains a face:
"""
labels = model.predict(patches_hog)
labels.sum()
"""
We see that out of nearly 2,000 patches, we have found 30 detections.
Let's use the information we have about these patches to show where they lie on our test image, drawing them as rectangles:
"""
fig, ax = plt.subplots()
ax.imshow(test_image, cmap='gray')
ax.axis('off')

Ni, Nj = positive_patches[0].shape
indices = np.array(indices)

for i, j in indices[labels == 1]:
    ax.add_patch(plt.Rectangle((j, i), Nj, Ni, edgecolor='red',
                               alpha=0.3, lw=2, facecolor='none'))
"""
All of the detected patches overlap and found the face in the image!
Not bad for a few lines of Python.
"""
"""
## Caveats and Improvements

If you dig a bit deeper into the preceding code and examples, you'll see that we still have a bit of work before we can claim a production-ready face detector.
There are several issues with what we've done, and several improvements that could be made. In particular:

### Our training set, especially for negative features, is not very complete

The central issue is that there are many face-like textures that are not in the training set, and so our current model is very prone to false positives.
You can see this if you try out the above algorithm on the *full* astronaut image: the current model leads to many false detections in other regions of the image.

We might imagine addressing this by adding a wider variety of images to the negative training set, and this would probably yield some improvement.
Another way to address this is to use a more directed approach, such as *hard negative mining*.
In hard negative mining, we take a new set of images that our classifier has not seen, find all the patches representing false positives, and explicitly add them as negative instances in the training set before retraining the classifier.

###  Our current pipeline searches at only one scale

As currently written, our algorithm will miss faces that are not approximately 62×47 pixels.
This can be straightforwardly addressed by using sliding windows of a variety of sizes, and resizing each patch with ``skimage.transform.resize`` before feeding it into the model.
In fact, the ``sliding_window()`` utility used here is already built with this in mind.

###  We should combine overlapped detection patches

For a production-ready pipeline, we would prefer not to have 30 detections of the same face, but to somehow reduce overlapping groups of detections down to a single detection.
This could be done via an unsupervised clustering approach (mean shift clustering is one good candidate for this), or via a procedural approach like *non-maximum suppression*, an algorithm common in machine vision.

###  The pipeline should be streamlined

Once we address these issues, it would also be nice to create a more streamlined pipeline for ingesting training images and predicting sliding-window outputs.
This is where Python as a data science tool really shines: with a bit of work, we can take our prototype code and package it with a well-designed object-oriented API that gives the user the ability to easily use it.
I will leave this as a proverbial "exercise for the reader."

###  More recent advances: deep learning

Finally, I should add that HOG and other procedural feature extraction methods for images are no longer state-of-the-art techniques.
Instead, many modern object detection pipelines use variants of deep neural networks: one way to think of neural networks is that they are an estimator that determines optimal feature extraction strategies from the data, rather than relying on the intuition of the user.
An intro to these deep neural net methods is conceptually (and computationally!) beyond the scope of this notebook, although open tools like Google's <a href="https://www.tensorflow.org/" target="_blank">TensorFlow</a> have recently made deep learning approaches much more accessible than they once were.
"""
"""
# Summary

In this notebook we've taken a look at a few approaches to *feature engineering*, the process of taking raw data and turning it into the ``n_samples`` x ``n_features`` matrix that we need in order to model the data with scikit-learn. Feature engineering can range from simple (mapping categories to binary columns) to complex (specific operations on images to find gradients and other useful features). This lesson showed a few examples to illustrate how this might be done in practice.
"""