Fullstack NLP: Building & Deploying End-to-end Fake News Classifier
Tags: nlp, javascript, react, python
Published: 2019-12-07 10:44:09

---
published: true
title: Fullstack NLP: Building & Deploying End-to-end Fake News Classifier
tags: NLP, JavaScript, React, Python
canonical_url: https://iammowgoud.com/blog/fullstack-nlp-building-and-deploying-end-to-end-fake-news-classifier/
---


*Originally posted on my [personal blog](https://iammowgoud.com/blog/fullstack-nlp-building-and-deploying-end-to-end-fake-news-classifier/) (better formatting)*

> **TLDR:** This is a tutorial on building the API+UI of NLP text classification web application and deploying it to production.
>
> [Github](https://github.com/iammowgoud/NLP-fake-news-detection) | [Notebook](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/notebook) | [Demo](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/)

> **Note:** The classification model used is by no mean accurate and it's definitely overfitted over the training dataset. The focus of this post is implementing the fullstack web application. [Ref 1](https://www.fastcompany.com/90417625/machine-learning-isnt-effective-at-identifying-fake-news). [Ref 2](https://www.axios.com/machine-learning-cant-flag-false-news-55aeb82e-bcbb-4d5c-bfda-1af84c77003b.html).

While pursuing my Master's degree at [IE](https://www.ie.edu/school-human-sciences-technology/masters/master-business-analytics-big-data/), I worked on this [Text Classification model](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/notebook) as part of the NLP course. The notebook is extra messy but I will try to briefly explain what's going on in the first section.

# NLP Classifier

Our problem here is to define whether or not a certain ***news article*** is fake news. The [dataset](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/jupyter_project/fake_or_real_news_training.csv) is comprised of 3997 news articles each includes a ***title***, ***text***, and the target ***label*** as a REAL/FAKE binary label. Part of the course was also testing the model on a test dataset but I never received target for this dataset. The [accuracy score](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html) of cross validation testing within the training dataset was 94%.

![Test Dataset Target](https://iammowgoud.com/blog/assets/imgs/fake-news/target.png)

![CV Confusion Matrix](https://iammowgoud.com/blog/assets/imgs/fake-news/confusion-matrix.png)

I won't go through the coding details of the Notebook, but I will briefly describe the main steps taken before coming up with the final model.

## Text Preprocessing

We can clearly see that the target is balanced. So we jump ahead into the preprocessing by doing the following:

- **Lowercase the text:**
  This preprocessing step is done so words can later be cross checked with the stopwords and pos_tag dictionaries.
- **Remove**  words with just one letter
- **Remove** words that contain numbers
- [**Tokenize**](https://www.geeksforgeeks.org/nlp-how-tokenizing-text-sentence-words-works/) the text and remove punctuation
- **Remove *stopwords*:**
  Proper analysis of text usually relies on the most recurring words. **Stopwords** including words as "the", "as" and "and" appear a lot in a text, but eech word doesn't really give relevant explanation, so one of NLP comon practices is to remove such words.
- **Remove empty tokens:**
  After tokenization, we have to make sure all tokens taken into account contribute to the label prediction.
- **Lemmatize the text:**
  In order to normalize the text, we apply [**lemmatization**](https://www.datacamp.com/community/tutorials/stemming-lemmatization-python). In this way, words with the same root are processed equally e.g. when the words "took" or "taken" are found in the text, they are **lemmatized** to "take", infinitive of the verb.

You can find the `preprocess()` function in [python_helper.py:79](https://github.com/iammowgoud/NLP-fake-news-detection/blob/d879c65cda5854d63c2db80eaec641e571eb37a1/jupyter_project/python_helper.py#L79).

## Baselining

In [this part of the notebook](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/notebook#Baseline-Modelling), you can see how I split and vectorize the dataset and run several classifiers on the dataset and measure baseline accuracy.

There was a significant improvement in accuracy for [Max Entropy](https://blog.datumbox.com/machine-learning-tutorial-the-max-entropy-text-classifier/) text classifier.

## Feature Engineering & Pipeline

After we have a baseline score, we do some extra work:

- **Pos-tag text:**

  Adding a prefix to each word with its type (Noun, Verb, Adjective,...). e.g: I went to school => PRP-I VBD-went TO-to NN-school

  Also, after lemmatization it will be 'VB-go NN-school', which indicates the semantics and distinguishes the purpose of the sentence.

  This will help the classifier differentiate between different types of sentences.

![Pos-tagging](https://iammowgoud.com/blog/assets/imgs/fake-news/FE.svg)

- **TF-IDF weighting:**

  TF-IDF or [Term Frequency–Inverse Document Frequency](https://en.wikipedia.org/wiki/Tf%E2%80%93idf) tries to calculate the importance of each word based on its recurrence in text.

![TF-IDF](https://iammowgoud.com/blog/assets/imgs/fake-news/tfdif.jpg)

- **Use Trigram Vectorizer instead of regular vectorizer:**

  I opt to use a Trigram vectorizer here, which vectorizes triplets of words rather than each word separately. *<u>In this short example sentence</u>*, the trigrams are "In this short", "this short example" and "short example sentence".

Finally I do a [Grid Search](https://medium.com/datadriveninvestor/an-introduction-to-grid-search-ff57adcc0998) for different combinations of the past steps along with different models to [optimize for](https://en.wikipedia.org/wiki/Hyperparameter_optimization) the best [hyper parameters](https://en.wikipedia.org/wiki/Hyperparameter_(machine_learning)).

If you look just above the predictions [here](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/notebook#Predicting-on-test-dataset), you will find that this is the best performing model (Vectorizer + TF-IDF + Classifier):

```python
trigram_vectorizer = CountVectorizer(analyzer = "word", ngram_range=(1,3))
tf_idf = TfidfTransformer(norm="l2")
classifier = LogisticRegression(C=1000, class_weight=None, dual=False, fit_intercept=True,
          intercept_scaling=1, max_iter=100, multi_class='warn',
          n_jobs=None, penalty='l2', random_state=None, solver='warn',
          tol=0.0001, verbose=0, warm_start=False)
```

I wrap everything inside a [**Pipeline**](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html) that can train (`fit()`) and classify (`predict()`)  input easily.

```python
from sklearn.pipeline import Pipeline
pipeline = Pipeline([
     ('trigram_vectorizer', trigram_vectorizer),
     ('tfidf', tf_idf),
     ('clf', classifier),
 ])
```

## Pickling

Next step is fitting the pipeline on train data like so:

```python
pipeline.fit(train.clean_and_pos_tagged_text, encoder.fit_transform(train.label.values))
```

At this stage, the `pipeline` object can `predict()` new input. You can find the test predicting step in the notebook [here](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/notebook#2.-Predicting-on-test).

Now comes the magic. If you want to encapsulate your whole model along with the training data in one single file to be used anywhere, you use **[Pickle library](https://docs.python.org/3/library/pickle.html)**.

```python
import pickle
pickle.dump( pipeline, open( "pipeline.pkl", "wb" ) )
```

Any object in python can be pickled to be saved on disk. Then it can be transferred to be imported in another python environment. What pickle does is that it **serializes** the Python object before writing it to file. Pickling is just a way to convert any python object (list, dict, etc.) into a character stream. The whole idea behind it is that this serialized textual stream contains everything needed to reconstruct the object in another python script.

In this case we are pickling our `pipeline` object so we can use it later in the API to `pipeline.predict()`.

This `pipeline.pkl` file is now ready to predict any news article and classify it to either `REAL` or `FAKE`.

# Web Application Architecture

![Application Architecture](https://iammowgoud.com/blog/assets/imgs/fake-news/architecture.svg)

So we need a web interface in which the user can enter some news text and click on a button for the application to ***preprocess*** the input and feed it to the ***trained model*** and show the classification back on screen.

Keep in mind that for the ***pickled pipeline*** to predict correctly we need to feed it with text preprocessed *exactly in the same way* the train dataset was preprocessed.

- **Frontend/UI:**

  This is going to be a simple one page with one input and a button. No need to complicate stuff. As I mentioned in this [post](https://iammowgoud.com/blog/What-I-learned-in-40-hours-switching-from-Angular-to-React), React would be a better fit for such straightforward function.

- **Backend/Server:**

  Since we need to **"unpickle"** or `pickle.load()` our model (the `pipeline`) to use it, the best choice would be a python web server that can receive the input over HTTP and return back the prediction result. One of the easiest and most straightforward frameworks for this is [Flask](https://www.fullstackpython.com/flask.html).

  Another addition to the server would be implementing a **"random picker"** that can fetch one random news article at a time from the test dataset. This will be used to populate the UI input field.

  The goal is to make it easier for the user to test the application, without actually writing a news article.

## Flask API Server

Assuming you have a ready Python environment and are familiar with Python development in general, installing pythons is as easy as:

```bash
pip install flask
```

If you want to use a `virtualenv` you can check the steps [here](https://flask.palletsprojects.com/en/1.1.x/installation/).

A simple Flask API that serves the static React app (next section) when you hit root path `/` would look like this:

```python
from flask import Flask, render_template

app = Flask(__name__, static_folder="./public/static", template_folder="./public")

@app.route("/")
def home():
    return render_template('index.html')

# Only for local running
if __name__ == '__main__':
    app.run()
```

You can only make use of `template_folder` now in which you can add a simple hello world HTML file. In the next section when we build the React app we will use the `static_folder` store assets like CSS and JavaScript files.

If you run the command `python app.py` and hit `http://localhost:5000` you should see the `/public/index.html` displayed in your browser.

As discussed in previous section, we mainly have 2 functions to implement here: **predict** and **random**. Let's start with the easier random picker endpoint.

**`random/`:**

Since we have the test dataset in the [`/data`](https://github.com/iammowgoud/NLP-fake-news-detection/tree/master/data) folder, we just need to load it and pick a random index and send it back to UI.

```python
from flask import jsonify
import pandas as pd
from random import randrange

@app.route('/random', methods=['GET'])
def random():
    data = pd.read_csv("data/fake_or_real_news_test.csv")
    index = randrange(0, len(data)-1, 1)
    return jsonify({'title': data.loc[index].title, 'text': data.loc[index].text})
```

We use Pandas here to read the CSV file and then send back the data in JSON format using flask `jsonify()` helper function.

Now whenever you send a GET request to `http://localhost:5000/random/` using Postman or even open the link [in your browser](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/random) you should see a random news entry from the test dataset file.

**`predict/`:**

For the prediction function we need to do some work than just exposing the endpoint. We need to take the input, preprocess it, pos-tag it, then feed it to the pipeline and return back the result.

So we create a PredictionModel class in a [separate file](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/predictionModel.py):

```python
from nltk.corpus import wordnet
import string
import nltk
nltk.data.path.append('./nltk_data') # local NLTK data
from nltk.corpus import stopwords
from nltk import pos_tag
from nltk.stem import WordNetLemmatizer

class PredictionModel:
    output = {}

    # constructor
    def __init__(self, text):
        self.output['original'] = text

    def predict(self):

        self.preprocess()
        self.pos_tag_words()

        # Merge text
        clean_and_pos_tagged_text = self.output['preprocessed'] + \
            ' ' + self.output['pos_tagged']

        self.output['prediction'] = 'FAKE' if pipeline.predict(
            [clean_and_pos_tagged_text])[0] == 0 else 'REAL'

        return self.output

    # Helper methods
    def preprocess(self):
        # check file on github

    def pos_tag_words(self):
        # check file on github
```

And then we use the class in `app.py` endpoint like so:

```python
@app.route('/predict', methods=['POST'])
def predict():
    model = PredictionModel(request.json)
    return jsonify(model.predict())
```

So basically when you send a POST request to `http://localhost:5000/predict`, the script will initialize a new `PredictionModel` instance with the input text and return the prediction.

For example `PredictionModel("Some news article text")`, will return a JSON object like this:

```json
{
  "original":"Some news article text",
  "preprocessed":"news article text",
  "pos_tagged":"NN-news NN-article IN-text",
  "prediction":"FAKE"
}
```

***Note:*** I exported the NLTK data and you can see I'm loading it from a local path here. The reason for this is both for better version management as I had issues with installing the right NLTK corpse on Docker.

*But we used the `pipeline` object without actually declaring it...*

**Loading the pickle:**

The key idea we need to understand here is that we need to load the [.pickle file](https://github.com/iammowgoud/NLP-fake-news-detection/tree/master/pickle) before using it predict. *Note that the pickle file is **~350 MB**.*

In the top of the file we can add:

```python
with open("pickle/pipeline.pkl", 'rb') as f:
        pipeline = pickle.load(f)
```

Keep in mind this loads when you first run the `app.py` script and it loads the 350MB pipeline object into your machine RAM.

**After** initial run and when the script is in standby mde waiting for requests, Whenever you call `pipeline.predict()` in your script it will run instantly because the object is already in your RAM.

***Note:*** This will work fine when you run it locally, but when you deploy it into production you need to consider your server RAM and what happens if the script crashes. I will discuss this in the **Deployment** section.

## React User Interface

To initialize the React app we will use `create-react-app`. You can install that globally by running `npm i -g create-react-app`.

Then to initialize the actual code template we run `create-react-app fake-news-client`. This will create a folder with the name "fake-news-client" and npm install the needed packages inside it.

If you cd into the folder and run a simple `react-scripts start` you should have a simple react app built and served in your browser on `http://localhost:3000/`.

> Note that the backend files will be the root folder of the project and the frontend will live in a subdirectory inside it with the name `webapp`.

Now we need to install an extra package we will use in the project. cd into the folder and run `npm i -s node-sass`.

I'm using `node-sass` to write the app styles which means you need to rename all the `.css` files into `.scss` and change the reference in the `index.js` file.

**Let's build the UI**

In the [`/src/index.js`](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/webapp/src/index.js) file we can start adding the state object along with the HTML template that consists of:

- **Input:** textarea for the news text
- **Actions:** button to get `/random` data and another to `/predict`
- **State Labels:** App can be in one of theses states: idle, loading, or error.
- **Result label:** Prediction, REAL/FAKE.

```jsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.scss';

class NLPInterface extends React.Component {

  state = {
    text: '',         // Textarea value
    loading: false,   // Loading state flag
    error: false,     // Error state flag
    prediction: null, // Prediction result
  }

  render() {
    return (
      <div className="container">
        <h1>NLP Fake News Classifier</h1>

        <textarea disabled={this.state.loading} value={this.state.text} onChange={this.updateInput}>
        </textarea>

        <button disabled={this.state.loading} className="random" onClick={this.random}>
          Load random News from test dataset ? <span>Click here</span>.
        </button>

        <button disabled={this.state.loading} className="cta" onClick={this.predict}> Predict </button>

        {this.state.loading ? <h1>Classifying ...</h1> : ''}

        {this.state.error ? <h1>ERROR</h1> : ''}

        <h1>
          {this.state.prediction}
        </h1>

      </div>
    )
  }
}

ReactDOM.render(
  <NLPInterface />,
  document.getElementById('root'));
```

You can find the styles in the [SCSS file](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/webapp/src/index.scss).

Next step would be writing down the UI logic needed to to wire the template with the backend.

Let's start by the **textarea**, we can see it reads it's value from `this.state.text`, and `onChange` we are binding the `this.updateInput` event handler which will update `state.text`

```jsx
  updateInput = ({ target: { value } }) => this.setState({
      text: value,
      prediction: null // To reset previous prediction whenever the text in the textarea changes
    });
```

Next we have the **Random** news picker button, which fires the following function `onClick`:

```jsx
  random = () => fetch("/random")
    .then(response => response.json())
    .then(article => article.title + '\n\n' + article.text)
    .then(text => this.setState({
      text,
      prediction: null
    }))
    .catch(error => this.setState({ error: true }));
```

Finally, we have the actual prediction function binded to another button:

```jsx
  predict = () => {
    this.setState({ loading: true, prediction: null }); // Start loading

    fetch("/predict", {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(this.state.text)
    })
      .then(response => response.json())
      .then(({ prediction }) => {
        this.setState({ loading: false, prediction })
      })
      .catch(error => this.setState({ loading: false, error: true }))
  }
```

This will get the prediction result, store it to `state.prediction` which is displayed in the `H1` tag at the bottom of the `render()` function.

***Voila!*** we have a working webpage that can communicate with our Flask backend.

## Bundling

To bundle the frontend app and move it to the `/public` folder which the Flask app will serve, we will edit the [**package.json**](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/webapp/package.json) file:

```json
  "scripts": {
    "prebuild": "rm -r ../public",
    "build": "react-scripts build",
    "postbuild": "mv build/ ../public",
  }
```

**Now if you run `npm run build` inside webapp folder then go up one directory <br> and run `python app.py` then visit `http://localhost:5000` you will find the app up and running 🎉🎉**

# Dockerizing & Deployment

To run this app successfully on a remote server we need to consider that this will be a service with a relatively slow boot time, as we load the 350 MB pickle once the script runs.

For this we will use [gunicorn](https://gunicorn.org/) HTTP server to run the python app. gunicorn will basically:

- Run the server script several times in parallel using multiple [async workers](https://docs.gunicorn.org/en/stable/design.html#async-workers)
- Make sure the server is always running and accepting requests

After running `pip install gunicorn` you can run the following to run the script using gunicorn:

```bash
gunicorn -t 120 -b :8080 app:app
```

This will run the server on port 8080 and I added the `-t 120` flag to tell it to wait 120 seconds before timing out.

**Now we move to deployment.** I chose to use [**Google Cloud Run**](https://cloud.google.com/run/) for this application *also* because our application has a heavy stateless workload as it needs to load the large pickle file.

To use Cloud Run we need to [**containerize**](https://www.docker.com/resources/what-container) our application by writing down the **Dockerfile**:

```docker
FROM python:3.7-stretch

RUN apt-get update -y
RUN apt-get install -y python-pip python-dev build-essential

COPY . /app
WORKDIR /app

RUN python -m pip install --upgrade pip

RUN pip install -r requirements.txt

CMD gunicorn -t 120 -b :$PORT app:app

```

You can see also that I exported all of the needed python packages into a [`requirments.txt`](https://github.com/iammowgoud/NLP-fake-news-detection/blob/master/requirements.txt) file.

Finally, to get this to work on Google Cloud you need to:

  1. Start a Google Cloud Project
  2. Activate billing
  3. Open Google online console
  4. Pull the code into your account server
  5. `cd` into the project folder and run `gcloud builds submit --tag gcr.io/[your project ID]/fake-news-service`
  This will deploy the docker container image into that URL.
  6. Go to the Cloud Run dashboard and click on "Create Service"
  7. Type the image URL you created in step 5
  8. Set the memory allocated to 1GB
  9. **"Create"**

![Google Cloud Run configuration](https://iammowgoud.com/blog/assets/imgs/fake-news/config.png)

<br>

 💃🏼 💃🏼 ***The app will be up and running in less than 5 minutes.*** 💃🏼 💃🏼

[***Demo***](https://fakenewsservice-ih2ql4rjjq-ew.a.run.app/)

***Related Links:***

- [Text tokenization](https://www.geeksforgeeks.org/nlp-how-tokenizing-text-sentence-words-works/)
- [Text lemmatization](https://www.datacamp.com/community/tutorials/stemming-lemmatization-python)
- [Max Entropy Text Classifier](https://blog.datumbox.com/machine-learning-tutorial-the-max-entropy-text-classifier/)
- [TF-IDF: Term Frequency–Inverse Document Frequency](https://en.wikipedia.org/wiki/Tf%E2%80%93idf)
- [Grid Search](https://medium.com/datadriveninvestor/an-introduction-to-grid-search-ff57adcc0998)
- [Hyperparameter Optimization](https://en.wikipedia.org/wiki/Hyperparameter_optimization)
- [Pickle](https://docs.python.org/3/library/pickle.html)
- [Flask](https://www.fullstackpython.com/flask.html)
- [gunicorn](https://gunicorn.org/)
- [Google Cloud Run](https://cloud.google.com/run/)