# Notebook from kalleknast/AIND-NLP-Bookowrm
Path: .ipynb_checkpoints/bookworm-checkpoint.ipynb

# Bookworm

## Overview

In this project, you will build a simple question-answering agent that is able to learn from any text data you provide, and answer queries posed in natural language. You will use IBM Watson's cloud-based services to process the input text data and find relevant responses.

## Learning Objectives

By completing this project, you will learn how to:

- Create a cloud-based NLP service instance and configure it.
- Ingest a set of text documents using the service and analyze the results.
- Accept questions in natural language and parse them.
- Find relevant answers from the preprocessed text data.

## Getting Started

In order to use Watson's cloud-based services, you first need to create an account on the [IBM Bluemix platform](https://console.ng.bluemix.net/).

Then, for each service you want to use, you have to create an instance of that service. You can continue with the tasks below, and create a service instance when indicated._____no_output_____## 1. Create and configure Discovery service

Create an instance of the **Discovery** service. You will use this to process a set of text documents, and _discover_ relevant facts and relationships.

- Go to the [IBM Bluemix Catalog](https://console.bluemix.net/catalog/).
- Select [Discovery](https://console.bluemix.net/catalog/services/discovery) service under the [AI](https://console.bluemix.net/catalog/?category=ai) category.
- Enter a Service Name for that instance, e.g. `Discovery-Bookworm` and click **`Create`** button on the bottom right hand corner of the screen.
- You should be able to see your newly-created service in your [Bluemix Apps Dashboard](https://console.bluemix.net/dashboard/apps).

- Open the `Discovery-Bookworm` service instance and find your `Url` and `API Key` in **Credentials** section.

_Note: you will need the username and password when connecting to the service in the next steps shortly.______no_output_____### Connect to the service instance

Let's connect to the service instance you just created using IBM Watson's [Python SDK](https://github.com/watson-developer-cloud/python-sdk). You will first need to install the SDK:
```bash
pip install watson-developer-cloud
```

Now execute each code cell below using **`Shift+Enter`**, and complete any steps indicated by a **`TODO`** comment. For more information on the Discovery service, please read the [Documentation](https://www.ibm.com/watson/developercloud/doc/discovery/index.html) and look at the [API Reference](https://www.ibm.com/watson/developercloud/discovery/api/v1/?python) as needed._____no_output_____

# Install watson-developer-cloud
# This takes about a minute

# !pip install --upgrade "watson-developer-cloud>=2.4.1"_____no_output_____!pip install --upgrade "ibm-watson>=4.2.1"
!pip install --upgrade "ibm-cloud-sdk-core==1.5.1"_____no_output_____# Usual Python imports
import sys
import os
import glob
import json

# BeautifulSoup, for parsing HTML
from bs4 import BeautifulSoup

# Matplotlib, for plotting
import matplotlib.pyplot as plt
%matplotlib inline

# Watson Python SDK
from ibm_watson import DiscoveryV1
from ibm_cloud_sdk_core.authenticators import IAMAuthenticator

# Utility functions
%load_ext autoreload
%aimport helper
%autoreload 1_____no_output_____

###  Using the Service Credentials

Before you can connect to Watson Service, you need to copy and paste `Username` and `Password` credentials from Bluemix Service console to this notebook.
_Note: these credentials are different from your IBM Bluemix login, and are specific to the service instance._

1. Open `service-credentials.json` file:
    * from this Jupyter Notebook top navigation, click `File` → `Open` → `service-credentials.json`
2. Copy your `API Key` and `Url` from Discovery service console.
3. Paste the credentials into `apikey` and `url` values in `Discovery` object.

_____no_output_____

discovery_creds = helper.fetch_credentials('discovery')

authenticator = IAMAuthenticator(apikey=discovery_creds['apikey'])

discovery = DiscoveryV1(version='2018-12-03',
                        authenticator=authenticator)
discovery.set_service_url(discovery_creds['url'])_____no_output_____

### Create an environment

The Discovery service organizes everything needed for a particular application in an [_environment_](https://www.ibm.com/watson/developercloud/discovery/api/v1/curl.html?curl#environments-api). An environment must be created before collections of private data can be created.

Let's create one called `Bookworm` for this project.

> _**Note**: It is okay to run this block multiple times - it will not create duplicate environments with the same name.______no_output_____

# Prepare an environment to work in
env, env_id = helper.fetch_object(
    discovery, "environment", "Bookworm",
    create=True, create_args=dict(
        description="A space to read and understand stories"  # feel free to edit
    ))_____no_output_____

### Verify configuration options

A [_configuration_](https://www.ibm.com/watson/developercloud/discovery/api/v1/curl.html?curl#configurations-api) defines what natural language processing routines are applied to any documents that are submitted to the service. Each environment gets a default configuration when it is created.

You can fetch the default configuration and view the different options using the following piece of code.
_____no_output_____

# Lists existing configurations for the service instance and store default configuration id
configurations = discovery.list_configurations(environment_id=env_id).get_result()
cfg_id =  configurations['configurations'][0]['configuration_id']
print(json.dumps(configurations, indent=2))_____no_output_____# Get default configuration details
config = discovery.get_configuration(environment_id=env_id, configuration_id=cfg_id).get_result()
print(json.dumps(config, indent=2))_____no_output_____

There are 3 main configuration blocks that affect how input documents are processed:

1. **conversions**: How to convert documents in various formats (Word, PDF, HTML) and extract elements that indicate some structure (e.g. headings).
2. **enrichments**: What NLP output results are we interested in (keywords, entities, sentiment, etc.).
3. **normalizations**: Post-processing steps to be applied to the output. This can be left empty in most cases, unless you need the output to be normalized into a very specific format.

_**Note**: The default configuration for an environment cannot be modified. If you need to change any of the options, you will need to create a new one, and then edit it. The easiest way to do this is using the service dashboard, which is described later.______no_output_____## 2. Ingest documents

### Create a collection

A _collection_ is used to organize documents of the same kind. For instance, you may want to create a collection of book reviews, or a collection of Wikipedia articles, but it may not make much sense to mix the two groups. This allows Watson to make meaningful inferences over the set of documents, find commonalities and identify important concepts.

Let's create a collection called `Story Chunks` in the Discovery service environment._____no_output_____

# Prepare a collection of documents to use
col, col_id = helper.fetch_object(discovery, "collection", "Story Chunks", environment_id=env_id,
    create=True, create_args=dict(
        environment_id=env_id, configuration_id=cfg_id,
        description="Stories and plots split up into chunks suitable for answering")
    )_____no_output_____

Once you have created a collection, you should be able to view it using the Discovery Service tool. Select the Discovery instance from your BlueMix dashboard.  To open, click the **`Launch tool`** button.

_____no_output_____Here you should see the `Story Chunks` collection you just created.

You can open the collection to view more details about it. If you need to modify configuration options, click the **Switch** link and create a new configuration (the default one cannot be changed)._____no_output_____### Add documents

Okay, now that we have everything set up, let's add a set of "documents" we want Watson to look up answers from, using the Python SDK. Note that Watson treats each "document" as a unit of text that is returned as the result of a query. But we want to retrieve a paragraph of text for each question. So, let's split each file up into individual paragraphs. We will use the [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) library for this purpose.

_**Note**: You could also add and manage documents in the collection using the Discovery tool, but you would have to split paragraphs up into separate files._

_**Note**: We have provided a set of files (`data/Star-Wars/*.html`) with summary plots for Star Wars movies, but you are free to use a collection of your choice. Open one of the files in a text editor to see how the paragraphs are delimited using `<p>...</p>` tags - this is how the code block below split paragraphs into separate "documents".______no_output_____

# Add documents to collection
doc_ids = []  # to store the generated id for each document added
for filename in glob.glob(os.path.join(data_dir, "Star-Wars", "*.html")):
    print("Adding file:", filename)
    with open(filename, "r") as f:
        # Split each individual
 into its own "document"
        doc = f.read()
        soup = BeautifulSoup(doc, 'html.parser')
        for i, p in enumerate(soup.find_all('p')):
            doc_info = discovery.add_document(
                environment_id=env_id,
                collection_id=col_id,
                file=json.dumps({"text": p.get_text(strip=True)}),
                filename='n',
                file_content_type="application/json",
                metadata=json.dumps({"title": soup.title.get_text(strip=True)})
            ).get_result() # add get_result() here

            doc_ids.append(doc_info["document_id"])
print("Total", len(doc_ids), "documents added.")_____no_output_____

If you look at the collection details, you may notice that the `"document_counts"` field now shows some documents as `available` or `processing`. Once processing is complete, you should see all the documents under the `available` count._____no_output_____

# View collection details to verify all documents have been processed
col, col_id = helper.fetch_object(discovery, "collection", "Story Chunks", environment_id=env_id)_____no_output_____

So, what did the Discovery service learn? If you list the fields extracted from the set of documents in the collection as part of the enrichment process, you'll see familiar fields like `concepts`, `entities` and `keywords` that were returned in the test analysis._____no_output_____

# List all fields extracted
discovery.list_collection_fields(environment_id=env_id, collection_id=col_id).get_result()_____no_output_____

### Test query

Let's perform a simple query to see if the service can fetch the proper document for us:
> _Look for all paragraphs that have a relation (sentence) with "Jar Jar" as the subject, and return the title and text._
_____no_output_____

# A simple query
results = discovery.query(environment_id=env_id, collection_id=col_id,
    query_options={
        "query": "enriched_text.relations.subject.text:\"Jar Jar\"",
        "return": "metadata.title,text"
    }).get_result()
print(json.dumps(results, indent=2))_____no_output_____

Change the above query and see what results you get! Try to find one that returns relevant results, and keep that (along with the output) for review.

> See [Query building reference](https://www.ibm.com/watson/developercloud/doc/discovery/query-reference.html) for descriptions of all possible parameters, operators and aggregations. You can also choose to build the query using the web interface (click the "Story Chunks" collection to query it), and then reproduce the query here.

Then answer the questions below:

**Q**: What query did you try? Express it in plain words below.

**A**:

**Q**: What answer did you get back from Watson? You only need to mention the relevant snippet of text fro mthe paragraph(s) returned.

**A**:
_____no_output_____## 3. Parse natural language questions

In order to understand questions posed in natural language, we'll use another AI service called [Watson Assistant](https://console.bluemix.net/catalog/services/watson-assistant-formerly-conversation) (Formerly 'Conversation'). It can be used to design conversational agents or _chatbots_ that exhibit complex behavior, but for the purpose of this project, we'll only use it to parse certain kinds of queries._____no_output_____### Create a Assistant service instance

Just like you did for the Discovery service, create an instance of the Assistant service. Then launch the associated tool from the service dashboard.

- Go to the [IBM Bluemix Catalog](https://console.bluemix.net/catalog/).
- Select [Watson Assistant (formerly Conversation)](https://console.bluemix.net/catalog/services/watson-assistant-formerly-conversation) service under the [AI](https://console.bluemix.net/catalog/?category=ai) category.
- Enter a Service Name for that instance, e.g. `Assistant-Bookworm` and click **`Create`** button on the bottom right hand corner of the screen.
- You should be able to see your newly-created service in your [Bluemix Apps Dashboard](https://console.bluemix.net/dashboard/apps).
- Open the service instance and find your `Url` and `API Key` in **Credentials** section.
- Copy *API Key* and *URL* into `service-credentials.json` file in this notebook:

_____no_output_____### Create a Workspace from Watson Assistant console

A [_workspace_](https://www.ibm.com/watson/developercloud/assistant/api/v1/python.html?python#workspaces-api) allows you to keep all the items you need for a particular application in one place, just like an _environment_ in case of the Discovery service.

From Watson Assistant console, please follow these steps:

1) Click `Launch Tool` to start Watson Assistant.

2) Click `Skills` tab on the navigation menu and click `Create new` button.

3) Create new skills called `Bookworm` with a suitable description, such as "I know a lot of stories. Ask me a question!".
_____no_output_____Clicking on `Bookworm` workspace should open up a blank workspace, where you can add intents, define the entities you want the agent to idenitfy and structure the overall dialog.

_____no_output_____### Add intents

An [_intent_](https://www.ibm.com/watson/developercloud/assistant/api/v1/python.html?python#intents-api) is the goal or purpose of a user's input. Intent will determine the dialog flows with the users and allow Watson Assistant to provide a useful response. Please read Watson Assistant documentation on [_Planning Your Intents and Entities_](https://console.bluemix.net/docs/services/conversation/intents-entities.html#planning-your-entities).

Your task is to create a set of intents (at least 3) that capture the different kinds of questions that you want the system to answer, e.g. _who_, _what_ and _where_. Along with each intent, add a list of user examples or _utterances_ that map to that intent.

For instance, you could enter the following examples for the _where_ intent:

- Where is the Jedi temple located?
- Where was Luke born?

_Intent user examples should represent typical sentences that end users will use to interact with the application. The more examples you can provide for each intent, the better Watson Assistant will respond to the end user._

> See [**Defining intents**](https://console.bluemix.net/docs/services/conversation/intents.html#defining-intents) for a helpful video and further instructions.

**Q**: What intents did you add to the Assistant service instance?

**A**:

**Q**: Pick one of these intents, and list at least 5 examples for the intent that you entered.

**A**:_____no_output_____### Add entities

Once you have your intents set, let's tell the service what [_entities_](https://www.ibm.com/watson/developercloud/assistant/api/v1/python.html?python#entities-api) we want it to identify. One way to do this is using the `Entities` tool on Watson Assistant console, and entering them one-by-one to the blank `My entities` page.

> Go to [**Defining entities**](https://console.bluemix.net/docs/services/conversation/entities.html#defining-entities) to see how that is done.

But that can be tedious! So let's refer back to the entities that the Discovery service identified, and load them in programmatically.

As before, let's connect to the Assistant service first. Remember to enter your service credentials below._____no_output_____

# Connect to the Assistant service instance
# TODO: Enter your username and password from the Service Credentials tab in service-credentials.json

assistant_cred = helper.fetch_credentials('assistant')

assistant = watson_developer_cloud.AssistantV1(
                        version='2018-08-01',
                        url=assistant_cred['url'],
                        iam_apikey=assistant_cred['apikey'])_____no_output_____

Fetch the workspace you just created called "Bookworm"._____no_output_____

wrk, wrk_id = helper.fetch_object(assistant, "workspace", "Bookworm")_____no_output_____

### Exporting entities from Discovery service to Assistant service

Next step is to collect all the entities from the `Star Wars` documents that we added to the **Discovery** service collection and group them by entities type._____no_output_____

# Get all the entities from the collection and group them by type
response = discovery.query(environment_id=env_id, collection_id=col_id,
    query_options={
        "return": "enriched_text.entities.type,enriched_text.entities.text"
    }).get_result()

# Group individual entities by type ("Person", "Location", etc.)
entities_by_type = {}
for document in response["results"]:
    for entity in document["enriched_text"]["entities"]:
        if entity["type"] not in entities_by_type:
            entities_by_type[entity["type"]] = set()
        entities_by_type[entity["type"]].add(entity["text"])

# Ignore case to avoid duplicates
for entity_type in entities_by_type:
    entities_by_type[entity_type] = {
        e.lower(): e for e in entities_by_type[entity_type]
    }.values()

# Restructure for loading into Assistant workspace
entities_grouped = [{
    "entity": entity_type,
    "values": [{"value": entity} for entity in entities]}
        for entity_type, entities in entities_by_type.items()]
entities_grouped
_____no_output_____

Update the workspace with these entities and verify that have been added correctly._____no_output_____

# Add these grouped entities to the Assistant workspace
assistant.update_workspace(workspace_id=wrk_id, entities=entities_grouped).get_result()

workspace_details = assistant.get_workspace(workspace_id=wrk_id, export=True).get_result()
print(json.dumps(workspace_details["entities"], indent=2))_____no_output_____

_**Note**: Ensure that at least 3 entity types, with at least 1 example entity each have been added._

Here is what the list of entities should look like through the Assistant tool.

**Q**: Name 3 entity types that were added, with at least 1 example entity each (e.g. entity type: _City_, example: _Los Angeles_).

**A**: _____no_output_____### Design dialog flow

As a final step in creating the Assistant interface, let's design a typical dialog with a user. The most intuitive way to do this is to use the Dialog tab in the tool. Here, you can add _nodes_ that capture different stages in the dialog flow, and connect them in a meaningful way.

Go ahead and add at least 3 dialog nodes. Specify the triggers in terms of the intents and entities that you'd like to match, and an optional intermediate response like "Let me find that out for you." The actual response will be fetched by querying the Discovery service.

Here is what the dialog nodes should look like.

**Q**: Specify 3 dialog nodes you added, along with the trigger (intent and/or entities) for each.

**A**: _____no_output_____### Test dialog

Let's run through a test dialog to demonstrate how the system transitions to one of the nodes you defined above._____no_output_____

# Testing the dialog flow

# Start conversation with a blank message
results = assistant.message(workspace_id=wrk_id).get_result()
context = results["context"]

# Then ask a sample question
question= "Who is Luke's father?"
results = assistant.message(workspace_id=wrk_id,
                            input={'text': question}, context=context).get_result()

print(json.dumps(results, indent=2))_____no_output_____

## 4. Query document collection to fetch answers

The Discovery service includes a simple mechanism to make queries against your enriched collection of documents. But you have a lot of control over what fields are searched, how results are aggregated and values are returned.

### Process sample question

Choose a sample nautal language question to ask, and run it through the Assistant service, just like you did above when testing dialog flow._____no_output_____

# TODO: Run a sample question through Assistant service

# Then ask a sample question
question= "Where was Luke born?"
results = assistant.message(workspace_id=wrk_id,
                            input={'text': question}, context=context).get_result()

print(json.dumps(results, indent=2))_____no_output_____

Now extract the intent and entities identified in the question, and optionally what dialog node was triggered (in case you need it later to customize your response). Some sample code is provided below, but you may need to modify it._____no_output_____

# TODO: Identify the intent(s) the user expressed (typically a single one)
query_intents = [intent["intent"] for intent in results["intents"]]
print("Intent(s):", query_intents)

# TODO: Extract the entities found in the question text
query_entities = [entity["value"] for entity in results["entities"]]
print("Entities:", query_entities)

# TODO: (optional) Find out what dialog node was triggered_____no_output_____

### Query the collection

Design a query based on the information extracted above, and run it against the document collection. The sample query provided below simple looks for all the entities in the raw `text` field. Modify it to suit your needs.

Take a look at the [API Reference](https://www.ibm.com/watson/developercloud/discovery/api/v1/?python#query-collection) to learn more about the query options available, and for more guidance see this [documentation page](https://www.ibm.com/watson/developercloud/doc/discovery/using.html).

_**Note**: You may want to design different queries based on the intent / dialog node that was triggered.______no_output_____

# TODO: Query the Discovery service based on the intent and entities
query_results = discovery.query(environment_id=env_id, collection_id=col_id,
    query_options={
        "query": "text:{}".format(",".join("\"{}\"".format(e) for e in query_entities)),
        "return": "text"
    }).get_result()
print(json.dumps(query_results, indent=2))_____no_output_____

### Process returned results

If you properly structure the query, Watson is able to do a pretty good job of finding the relevant information. But the result returned is a JSON object. Now your task is to convert that result into an appropriate response that best addresses the original natural language question that was asked.

E.g. if the question was "Who saved Han Solo from Jabba the Hutt?" the answer should ideally just be "The Rebels" and not the entire paragraph describing Han Solo's rescue. But that can be a backup response if you cannot be more specific.

_**Note**: You may have to go back to the previous step and modify the query, especially what you want the Discovery service to return, and this may depend on the intent / dialog node triggered. E.g. study the different parts of a "relation" structure to see how you might construct queries to match them.______no_output_____

# TODO: Process returned results and compose an appropriate response_____no_output_____

## 5. Reflections

**Q**: Now that you have gone through this exercise of designing a system that uses two IBM Watson services, what did you learn? What were some of the strengths and weaknesses of this approach?

**A**:

## (Optional) Extensions

We have provided a set of sample data files containing Star Wars plot summaries. But as mentioned before, you are free to use your own dataset. In fact, a larger dataset maybe more suitable for use with IBM Watson's NLP services. If you used your own dataset, answer the following questions.

**Q**: What dataset did you use, and in what ways is it different from the sample files provided?

**A**:

**Q**: Either include your dataset in the .zip file or repository you submit, or provide clear instructions on how to obtain the dataset, so that your reviewer can run your notebook or inspect the data to verify your results.

**A**:

_You can also design a web-based application that utilizes these services and deploy that on Bluemix! If you do, please share with your instructors and peers.______no_output_____


# Notebook from scikit-quant/scikit-quant
Path: tutorials/hubbard_model_vqe_ansatz_solution.ipynb

An Ansatz for the Hubbard Model
===========================

Prerequisites: OpenFermion, Qiskit, SciPy (which will pull in NumPy), and matplotlib, e.g. install with pip:

`$ python -m pip install qiskit openfermion scipy matplotlib`_____no_output_____Introduction
--------------

This tutorial discusses possible Ansätze for use with the _Variational Quantum Eigensolver_ (VQE) algorithm to find the ground state for configurations of the __[Hubbard Model](hubbard_model_intro.ipynb)__ (HM). VQE uses a classical optimizer to suggest trial states and a quantum chip to calculate the expectation values of the Hamiltonian at those states. This process iterates, with the optimizer refining the trial state until a minimum is found, which provides an upper bound to the ground state of the Hamiltonian.

An _Ansatz_ is a parametrized description of quantum states, covering in effect a subset of the full Hilbert space. It is used in VQE to reduce the total search space by considering only those states that satisfy the constraints to which a potential solution has to conform, e.g. states that have the correct symmetries, number of particles, parity, etc. An Ansatz can also be used to simplify the problem: resolving symmetries can remove parameters, drop physical but unlikely solutions (e.g. those with higher excitations), or only restrict trial states to ones that can be easily constructed with the native gate set of the hardware. Such simplification may mean that the exact solution can not be found, because the Ansatz can not describe it. However, VQE by design provides an upper limit to the ground state, thus even an inexact solution can be useful, for example as a stepping stone to refinement of the problem specification.

Ansatz design is (still) an art and the best approach depends on the specific problem (e.g. because of its symmetries) and hardware used (e.g. because of connectivity or specialized gates). The discussion below is therefore meant to be read within the context of the Hubbard Model tutorial and whereas the methods can be applied to other problems, the conclusions are most certainly _not_ portable._____no_output_____## Unitary Coupled Cluster Ansatz

The Unitary Coupled Cluster (UCC) Ansatz originates from the coupled cluster numerical method$^1$ as used in quantum chemistry. Electrons affect each other because they are negatively charged and like-charged particles repel. Their movements are thus correlated and can not be considered as independent. The coupled cluster method builds on multi-electron wave functions to capture the electron correlations and their effect on energy levels. Here, we consider only single (S) and double (D) excitations in the UCC Ansatz, or UCCSD for short:

\begin{equation*}
    T(\vec{\theta}) = \sum_{ij} \theta_{ij} a_i^\dagger a_j + \frac{1}{2}\sum_{ijkl} \theta_{ijkl}a_i^\dagger a_j^\dagger a_k a_l
\end{equation*}

where $\vec{\theta}$ represents the vector of optimization parameters, describing amplitudes for the single (first sum) and double (second sum) excitations. Their interpretation is rather straightforward: single excitations are movements of single electrons from one occupied to one unoccupied site (compare the hopping term in the Hubbard Model); double excitations are correlated movements of two electrons from two occupied to two unoccupied sites.$^2$

The Ansatz is then implemented as an evolution operator, allowing an initial state of electrons in well defined positions $|\psi_0\rangle$, which is simple to construct (e.g. in the case of a Jordan-Wigner mapping, this is a simple matter of flipping a number of qubits equal to the number of electrons from the computational basis state $|0\rangle$ to $|1\rangle$ using an X-gate, see the __[Hubbard Model](hubbard_model_intro.ipynb)__ notebook), to evolve to the parametrized _trial wave function_:

\begin{equation*}
    U(\vec{\theta}) |\psi_0\rangle = e^{i({T(\vec{\theta}) - T^\dagger(\vec{\theta})})}|\psi_0\rangle
\end{equation*}

Since all excitations are by design reversible (simply move the electrons back to the original sites), only a single step of size `1` needs to be considered in the construction of the evolution operator, with the parameters $\vec{\theta}$ capturing the precise evolution step size required to reach the ground state. When the expectation value of the Hamiltonian on a trial wave function, given some $\vec{\theta}$, reaches the global minimum, then that state represents (or is an upper bound to) the ground state.

### Qiskit UCCSD

The _general_ UCCSD Ansatz in Qiskit$^3$ constructs a circuit parametrized with rotation gates for the classical minimizer in VQE to optimize. As a practical matter, this general implementation is not a good match for the Hubbard Model used in this tutorial, because it does not capture all symmetries, resulting in more optimization parameters than necessary and in dependencies between the parameters. Such dependencies manifest themselves as saddle points in the localized surface as seen by the minimizer, because changes in one parameter can be undone with equivalent changes in another. Combined with their periodicity $(-\pi, \pi)$; all being rotations), saddle points become local minima where the minimizer can get trapped. (Note that this is much less of a problem for solving molecular problems as these usually have a good initial starting point from classical approximations.)

VQE Ansätze in Qiskit are designed as callbacks, with Qiskit taking over the main loop. This allows submission of the full algorithm (quantum _and_ classical) as a single job. However, within the context of this tutorial, it is more convenient to keep the loop and the Ansatz (in the form of an objective function) implementation accessible, such that it is easy to modify and see test in simulation. Thus, for purely practical reasons, a different approach here is preferred here.

If you're interested, however, a complete example implementation can be found __[here](hubbard_vqe_qiskit_ansatz.py)__. (Note that it requires the `qiskit_nature` package in addition to the prerequisites mentioned above.)

### OpenFermion UCCSD

The OpenFermion implementation of UCCSD lets the minimizer choose the amplitudes (as expressed in the equation above) directly, which captures the symmetries in the HM better, leading to fewer parameters. Note that the two approaches are mathematically equivalent, as the relative amplitudes are what matter, which in a circuit become relative phase differences, setup by rotations. The amplitudes are not periodic, which further greatly simplifies the optimization surface and simplifies the use of bounds.

In what follows, we will only use the OpenFermion implementation of UCCSD.

Note: Although there are some issues here specific to the HM, it is generally true that global searches in a multi-dimensional surface spanned with trigonometric functions are not easy on optimizers. Although the wavy patterns produced by such functions are beautiful and pleasing to the eye, there are many places for an optimizer to get stuck. Periodicity and especially NIQS-era noise only serve to make matters worse.

_____no_output_____Objective Function
----------------------

The following couple of cells show how to construct an objective function from the Ansatz. This will be a simplified form of what is used in the included __[hubbard.py](hubbard.py)__ module (which provides caching, an optional noise model, BQSKit optimization, etc., etc.) in order to show and highlight the important structural elements and not be lost in the details.

An objective function is implemented as a callback that will receive a set of parameters ($\vec{\theta}$) from the optimizer and is expected to return a corresponding value (and possible an uncertainty estimate and/or an evaluation cost) to minimize. Here, the parameters are a set of amplitudes in the "packed" form that OpenFermion wants (a flat array of single excitation amplitudes, followed by the ones for double excitations). For convenience, to keep the interface the same such that this implementation can work with an optimizer, we pull in the other relevant parameters from the global space (see __[hubbard.py](hubbard.py)__ for an alternative implementation).

As a first step, create a Hubbard Model setup._____no_output_____

import hubbard as hb
import openfermion as of
import qiskit as qk
import numpy as np
import scipy

from matplotlib import pyplot as plt

# local version of the OpenFermion UCCSD Ansatz that works with Qiskit
import uccsd_evolution_____no_output_____# Select a model appropriate for the machine used:
#    laptop -> use small model
#    server -> use medium model

MODEL = hb.small_model
#MODEL = hb.medium_model

# Hubbard model for fermions (Fermi-Hubbard) required parameters
xdim, ydim, t, U, chem, magf, periodic, spinless = MODEL()

# Number of electrons to add to the system
n_electrons_up   = 1
n_electrons_down = 1
n_electrons = n_electrons_up + n_electrons_down

# Total number of "sites", with each qubit representing occupied or not
spinfactor = spinless and 1 or 2
n_qubits = n_sites = xdim * ydim * spinfactor

# Create the Hubbard Model for use with Qiskit
hubbard_op = hb.hamiltonian_qiskit(
    x_dimension        = xdim,
    y_dimension        = ydim,
    tunneling          = t,
    coulomb            = U,
    chemical_potential = chem,
    magnetic_field     = magf,
    periodic           = periodic,
    spinless           = spinless,
    fermion_transform  = 'jordan-wigner'
)_____no_output_____

We will start with the simplest of objective implementations. The implementation details of the objective are taken as a given here, because this is done using Qiskit's _operator flow_ and thus highly specific to that framework. There is therefore little to be gained from constructing it as an exercise (unless you were trying to learn Qiskit). You should, however, be able to recognize the need for each of the parts and it may be useful, as an exercise, to identify their full implementations in the __[hubbard.py](hubbard.py)__ module.

First, setup the configuration, assuming the Jordan-Wigner transform will be used (`Step 1` in the next cell). Create a qubit register of the required number of qubits (taken from the Hamiltonian), then set the representative qubits to $1$ using an X-gate. That is, indices 0, 2, 4, etc. for the spin-up, and indices 1, 3, 5, etc. for the spin-down electrons:

```python
    reg = qk.QuantumCircuit(hubbard_op.num_qubits)
    for i in range(n_electrons_up):
        reg.x(i*2)
    for i in range(n_electrons_down):
        reg.x(i*2+1)
```

Next, in `Step 2`, we use OpenFermion to construct an evolution operator from the Ansatz, given the input parameters (`packed_amplitudes`, see description above), the number of qubits used, the number of electrons in the system, and the transform to use. In addition, the helper implementation in the __[uccsd_evolution.py](uccsd_evolution.py)__ module converts the result from OpenFermion `QubitOperator`s into Qiskit operator flow:

```python
    n_electrons = n_electrons_up + n_electrons_down
    evolution_op = uccsd_evolution.singlet_evolution(
                       packed_amplitudes, hubbard_op.num_qubits, n_electrons,
                       fermion_transform=of.transforms.jordan_wigner)
```

Note: spin is conserved by the singlet generator, which makes the implicit assumption that in filling, up and down indices alternate with up coming first. This behavior can be changed with helpers from the `of.utils.indexing` module (look for the `up_index` and `down_index` functions), e.g. if alternate labeling was used because of chip topology.

`Step 3` prepares the Hamiltonian operator to allow measurements, i.e. the calculation of expectation values. Note that it takes a short-cut: rather than splitting the components of the model into Pauli strings and computing them separately, the expectation value of the model is calculated in one step. This is a common approach in simulated examples (where the final state does not actually collapse on "measurement"), but this is of course not possible in reality. (See __[hubbard.py](hubbard.py)__ for the full component-based implementation.)

Finally, in `Step 4`, Qiskit's operator flow is used to do the actual expectation value calculation.

The final, full code is in the cell below._____no_output_____

def objective(packed_amplitudes):
  # Pull in the hamiltonian and its configuration
    global hubbard_op, n_electrons_up, n_electrons_down

  # Step 1: create the input state (Jordan-Wigner)
    reg = qk.QuantumCircuit(hubbard_op.num_qubits)
    for i in range(n_electrons_up):
        reg.x(i*2)
    for i in range(n_electrons_down):
        reg.x(i*2+1)

    input_state = qk.opflow.CircuitStateFn(reg)

  # Step 2: create the Ansatz operator from OpenFermion's UCCSD
    n_electrons = n_electrons_up + n_electrons_down
    evolution_op = uccsd_evolution.singlet_evolution(
                       packed_amplitudes, hubbard_op.num_qubits, n_electrons,
                       fermion_transform=of.transforms.jordan_wigner)

  # Step 3: create an observable from the Hamiltonian
    meas_op = qk.opflow.StateFn(hubbard_op, is_measurement=True)

  # Step 4: combine the parts and calculate the expectation
    expect_op = qk.opflow.PauliExpectation().convert(
                    meas_op @ evolution_op @ input_state
                )
    energy = np.real(expect_op.eval())
    return energy_____no_output_____

To try out the `objective` function, we need to figure out the number of parameters. This is fully determined by the Ansatz and thus in turn by the number and type of particles simulated (see the definition of $T(\vec{\theta})$ above). As explained in the UCCSD description, it is the implementation that determines how many symmetries are resolved and thus the final number of parameters. We are therefore reduced to asking the implementation for their number._____no_output_____

npar = uccsd_evolution.singlet_paramsize(n_qubits, n_electrons)
print('number of parameters used in the UCCSD Ansatz:', npar)number of parameters used in the UCCSD Ansatz: 2

With the number of parameters known, an initial set can be created to play with; the exact values do not matter. We can also pull a pre-calculated solution from the model, to confirm that the objective function can generate the correct solution.

**Exercise:** evaluate the objective function with some randomly generated array of parameter values, and at the point returned by `MODEL.optimal(n_electrons_up, n_electrons_down, transform='jordan-wigner')`._____no_output_____

# random trial point
rng = np.random.default_rng(42)   # for reproducibility while debugging
trial_amplitudes = np.array(-0.05+0.1*rng.random(size=npar))
print('trial amplitudes:', trial_amplitudes)
print('objective at trial:', objective(trial_amplitudes))
print()

# classically calculated exact solution
print('exact solution:', hb.exact(hubbard_op, n_electrons_up, n_electrons_down))
print()

# value at optimal parameters
at_opt = MODEL.optimal(n_electrons_up, n_electrons_down, transform='jordan-wigner')
print('amplitudes at optimal:', at_opt)
print('objective at optimal:', objective(at_opt))trial amplitudes: [ 0.0273956  -0.00611216]
objective at trial: 2.107214744858958

exact solution: -1.2360679774997896

amplitudes at optimal: [-0.86866234  0.18526051]
objective at optimal: -1.2360679602264795

**Optional Exercise:** scan the region around the optimal parameters to show that it is indeed a minimum.

**Exercise:** use the above objective function to solve the constructed Hubbard Model, for example using `scipy.optimize` (https://docs.scipy.org/doc/scipy/reference/optimize.html). Compare your results with the pre-calculated ones. Below is another possible example, based on Qiskit._____no_output_____

# Optional Exercise: calculate the objective at offsets from optimal
d_offset = 0.02
n_offsets = 21

plt.figure()
plt.xlabel('delta from optimal')
plt.ylabel('expectation')

X = [d_offset*(i-n_offsets//2) for i in range(n_offsets)]
y = [objective(at_opt+x) for x in X]

plt.plot(X, y)
plt.show()

# SciPy solution
result = scipy.optimize.minimize(objective, trial_amplitudes, method='BFGS')
print('SciPy:', result)

# Alternative, use Qiskit; see its documentation for more:
#   https://qiskit-community.github.io/qiskit-translations/id_ID/stubs/qiskit.algorithms.optimizers.html
from qiskit.algorithms.optimizers import COBYLA

optimizer = COBYLA(maxiter=100)

result = optimizer.optimize(
    num_vars = npar,
    objective_function=objective,
    initial_point = trial_amplitudes
)

print('Qiskit:', result)_____no_output_____

Unfortunately, this is not enough. In order to create a circuit to execute on a quantum computer, the matrix exponentials in the evolution operator need to be approximated. For this, a process called _Trotterization_$^{4,5}$ is used. Although we are not considered with time evolution here, the principles are the same for evolution from the computational zero state to the intended trial state.$^{6}$ That is, the evolution is approximated using a number of steps ("time slices").

Since Trotterization is a basic and common operation, Qiskit operation flow provides it for use. With trotterization, the `objective` becomes as follows (we're adding the number of Trotter steps as an argument for the next exercise)._____no_output_____

def objective(packed_amplitudes, trotter_steps=2):
  # Pull in the hamiltonian and its configuration
    global hubbard_op, n_electrons_up, n_electrons_down

  # Step 1: create the input state (Jordan-Wigner)
    reg = qk.QuantumCircuit(hubbard_op.num_qubits)
    for i in range(n_electrons_up):
        reg.x(i*2)
    for i in range(n_electrons_down):
        reg.x(i*2+1)

    input_state = qk.opflow.CircuitStateFn(reg)

  # Step 2: create the Ansatz operator from OpenFermion's UCCSD
    n_electrons = n_electrons_up + n_electrons_down
    evolution_op = uccsd_evolution.singlet_evolution(
                       packed_amplitudes, hubbard_op.num_qubits, n_electrons,
                       fermion_transform=of.transforms.jordan_wigner)

  # Step 2a: Trotterize the Ansatz operator
    trotterized_ev_op = qk.opflow.PauliTrotterEvolution(
        trotter_mode='trotter', reps=trotter_steps).convert(evolution_op)

  # Step 3: create an observable from the Hamiltonian
    meas_op = qk.opflow.StateFn(hubbard_op, is_measurement=True)

  # Step 4: combine the parts and calculate the expectation (note the ev_op update)
    expect_op = qk.opflow.PauliExpectation().convert(
                    meas_op @ trotterized_ev_op @ input_state
                )
    energy = np.real(expect_op.eval())
    return energy_____no_output_____

**Exercise:** scan the region around the optimal parameters. Do this for 1-4 Trotter steps. Plot the results. What is the conclusion?_____no_output_____

# adjust the number of offsets from optimal as needed and evaluate the
# objective at those offsets (as an alternate, modify the code to change
# only a single parameter at a time, rather than all); run over different
# ranges of Trotter steps and figure a reasonable number
d_offset = 0.02
n_offsets = 21
n_trotter_steps = 5

plt.figure()
plt.xlabel('delta from optimal')

X = [d_offset*(i-n_offsets//2) for i in range(n_offsets)]

for t in range(1, n_trotter_steps+1):
    y = []
    for x in X:
        y.append(objective(at_opt+x, t))
    plt.plot(X, y, label='%s Trotter steps' % t)
    print('Trotter step %d done' % t)

plt.legend(loc='upper left')
plt.ylabel('expectation')
plt.show()Trotter step 1 done
Trotter step 2 done
Trotter step 3 done
Trotter step 4 done
Trotter step 5 done

If the produced plot is correct, then there are two important conclusions to be made here:

   1. A single Trotter step is insufficient to accurately calculate the minimum: at least 3 are needed. However, there is a trade-off as each additional Trotter steps greatly increases the depth of the circuit. As we will see in the __[Effect of Noise on VQE](hubbard_vqe_noise.ipynb)__ notebook, increased depth will "lift" the expectation value due to added noise, counter-acting the benefit of the extra Trotter step.

   2. The optimal parameters for each are reasonably close, meaning that we can safely solve the problem for fewer Trotter steps first (with the benefit of less noise, because of shorter circuits), then refine._____no_output_____References
-------------

[1] https://en.wikipedia.org/wiki/Coupled_cluster

[2] Xu et al., "Test of the unitary coupled-cluster variational quantum eigensolver for a simple strongly correlated condensed-matter system", Mod. Phys. Lett. B, Vol. 34, Nos. 19 & 20 (2020)

[3] https://qiskit.org/textbook/ch-applications/vqe-molecules.html#VQE-Implementation-in-Qiskit

[4] H.F. Trotter, "On the product of semi-groups of operators" Proc. Am. Math. Phys., 10:545, 1959

[5] M. Suzuki, "Improved Trotter-like formula", Phys. Lett. A, 180(3):232 – 234, 1993

[6] https://arxiv.org/abs/1004.5528, K.L. Brown, et al.,, "Using Quantum Computers for Quantum Simulation"_____no_output_____


# Notebook from gianlucacovini/opt4ds
Path: aa2021/notebooks/Python_e_Pyomo_soluzione.ipynb

# Domande di Python, esempi di modelli in Pyomo_____no_output_____## 1. Domande ricevute
### Domanda 1: Il problema di *aliasing* nelle liste
Le liste in Python sono delle strutture dati *mutable*, ovvero che possono essere modificate, a differenze delle tuple che sono una struttura dati *read only*, ovvero che una volta che sono inizializzate non possono essere più modificate.

Quando si usano le liste, insieme all'operatore di assegnamento `=` (da non confondere con l'operatore logico di confronto `==`), quello che succedde è che non viene copiata la lista in una nuova variabile, ma viene creato un *handle* (un puntatore) alla stessa lista. Vediamo un esempio concreto di seguito._____no_output_____

As = [1, 2, 3, 4]
Bs = As

Bs.pop(2)

print("As:", As)
print("Bs:", Bs)_____no_output_____

Si noti che a tutti gli effetti, `Bs` reindrezza l'operazione `pop` alla struttura dati a cui si riferesce `As`. Usando una notazione **box-and-pointer**, quello che succede dovrebbe essere subito chiaro.

Per maggiori dettagli, si veda il paragrafo 4.4.1 del notebook seguente:
[Link al corso di Python](http://www-dimat.unipv.it/gualandi/programming/notebooks/Lab11_TupleListeDizionari.html)_____no_output_____Se uno avesse veramente voluto creare una copia della lista `As` in `Bs`, avrebbe potuto usare il costruttore delle list, o semplicemente un operatore di slicing, come nell'esempio seguente._____no_output_____

As = [1, 2, 3, 4, 5]
Bs = list(As)
Cs = Bs[:]

Bs.pop(2)
Cs.pop(3)

print("As:", As)
print("Bs:", Bs)
print("Cs:", Cs)_____no_output_____

### Domanda 2: Differenza tra modello e programma
Domanda ricevuta:

> [...] l'idea che mi sono fatto è che, in questo caso, sia sbagliato il modello di partenza, l'idea con cui intendo risolvere il problema. Ma poi mi chiedo: siccome non è la prima volta che riscontro un problema di questo tipo con pyomo, io non posso in alcun modo definire nuove variabili a partire dal valore che altre variabili assumono?

La difficoltà principale nell'esercitazione sul CVRP è riuscire a mettere a fuoco la differenza tra l'implementare un **algoritmo** che risolve un problema, e invece tra troverro un **modello di programmazione lineare intera** che formalizza un problema usando solo

1. Variabili decisionali continue o intere
2. Funzione obiettivo lineare
3. Vincoli lineari

In particolare, sulla parte di domanda:

> non posso in alcun modo definire nuove variabili a partire dal valore che altre variabili assumono?

La risposta è *yes, you can!*, ma devi riuscire a scrivere la relazione che vuoi esprimere tra le variabili in termini di vincoli lineari. Non devi implementare un algoritmo che calcola quella relazione.

Una volta scritto il modello, sarà un risolutore generico di programmazione lineare intera ad eseguire i suoi algoritmi, per cercare di risolvere il problema, e dichiarare il problema come (e dovete controllare lo status):

1. Non ammissibile
2. Illimitato
3. Risolto all'ottimo.
4. Oppure: *raggiunto un time limit, o un raggiunto iteration limit, o esaurita le memoria...)_____no_output_____### Domanda 3: Variabili, parametri, assegnamento iniziale
Consideriamo il codice seguente:_____no_output_____

# Dati letti dal file:
Xs = [(266, 235), (295, 272), (301, 258), (309, 260), (217, 274), (218, 278), (282, 267), (242, 249), (230, 262), (249, 268), (256, 267), (265, 257), (267, 242), (259, 265), (315, 233), (329, 252), (318, 252), (329, 224), (267, 213), (275, 192), (303, 201), (208, 217), (326, 181)]
Ws = [0, 125, 84, 60, 500, 300, 175, 350, 150, 1100, 4100, 225, 300, 250, 500, 150, 100, 250, 120, 600, 500, 175, 75]

# Funzione distanza
from math import sqrt
def Distance(A, B):
    return sqrt((A[0]-B[0])**2 + (A[1] - B[1])**2)_____no_output_____from pyomo.environ import ConcreteModel, Var, Objective, Constraint, SolverFactory, ConstraintList
from pyomo.environ import Binary, RangeSet, NonNegativeReals, NonNegativeIntegers, Reals, Maximize

model = ConcreteModel()

model.I = RangeSet(len(Xs))
model.J = RangeSet(len(Ws))

model.x = Var(model.I, model.J, within=Binary)
model.c = Var(model.I, model.J, within=NonNegativeReals)
for i in model.I:
    for j in model.J:
        if j is not i:
            model.x[i,j]()==0
            model.c[i,j]()==Distance(Xs[i],Xs[j]) _____no_output_____

## 2. Modelli in Pyomo
Di seguito vediamo come sia possibile scrivere alcuni modelli visti a lezione in Pyomo._____no_output_____### 2.1 Problema di programmazione lineare casuale
Consideriamo la funzione seguente che crea un problema di Programmazione Lineare Intera (PLI) casuale.

Un problema di PLI è definito dai due vettori $c$ e $b$ e dalla matrice $A$:

$$
    z = \min \,\{ c x \mid Ax \geq b, x \geq 0 \,\}
$$

Scriviamo prima una funzione per generare i dati in modo casuale._____no_output_____

from numpy import random
def RandomILP(n, m, seed=13):
    random.seed(seed)
    c = random.randint(1, 10, size=n)
    b = random.random(m)
    A = random.randint(1, 10, size=(m,n))

    return c, b, A

print(RandomILP(2,3))_____no_output_____

Scriviamo ora una funzione che presi in input i dati di un'istanza, costruisce il modello, lo risolve, controlla lo status, e restituisce un eventuale soluzione._____no_output_____

def SolveILP(c, b, A):
    m = len(b)
    n = len(c)

    model = ConcreteModel()

    model.I = RangeSet(n)
    model.J = RangeSet(m)

    model.x = Var(model.I, within=NonNegativeIntegers)

    model.obj = Objective(expr = sum(c[i-1]*model.x[i] for i in model.I))

    model.vincoli = ConstraintList()
    for j in model.J:
        model.vincoli.add(expr = sum(A[j-1,i-1]*model.x[i] for i in model.I) >= b[j-1])

    sol = SolverFactory('glpk').solve(model, tee=False)

    for info in sol['Solver']:
        print(info)

    xbar = [model.x[i]() for i in model.I]

    return 'optimal', model.obj(), xbar
_____no_output_____c, b, A = RandomILP(2, 3, 1717)
print(SolveILP(c, b, A))_____no_output_____

### 2.2 Turnistica infermieri
Vediamo ora come possiamo scrivere il modello che rappresenta i turni settimanali degli infermieri del San Matteo di Pavia.

Per la descrizione del modello si vedano le slides del corso.
_____no_output_____

def StaffScheduling():
    # Data
    d = 7
    w = 5
    demand = [5, 6, 7, 3, 3, 2, 2]

    # Dati sui turni
    A = np.zeros((7,7))
    for i in range(7):
        for j in range(5):
            A[(j+i)%7, i] = 1

    # Model
    model = ConcreteModel()

    model.Day = RangeSet(d)

    model.x = Var(model.Day, within=NonNegativeIntegers)

    model.obj = Objective(expr = sum(model.x[i] for i in model.Day))

    model.cover = ConstraintList()
    for day in model.Day:
        model.cover.add(expr = sum(A[day-1, i-1]*model.x[i] for i in model.Day) >= demand[day-1])

    model.write("staff.lp")

    sol = SolverFactory('glpk').solve(model, tee=True)

    for info in sol['Solver']:
        print(info)

    xbar = [model.x[i]() for i in model.Day]

    return 'optimal', model.obj(), xbar
    _____no_output_____print(StaffScheduling())_____no_output_____A = np.zeros((7,7))
for i in range(7):
    for j in range(5):
        A[(j+i)%7, i] = 1

A_____no_output_____

### 2.3 RNA folding
Si consideri il modello visto a lezione nelle slides `RNA folding`, vediamo come implementare quel modello in Pyomo._____no_output_____

from pyomo.environ import maximize

def RNAfold(s):
    C1 = ('A', 'U')
    C2 = ('C', 'G')

    ns = len(s)

    E = []
    for i in range(ns):
        for j in range(ns):
            if i < j:
                E.append( (i,j) )

    n = len(E)

    # Model
    model = ConcreteModel()

    model.P = RangeSet(n)

    model.x = Var(model.P, within=Binary)

    model.obj = Objective(expr = sum(model.x[p] for p in model.P),
                          sense = maximize)

    # Set variable to zero
    model.setzero = ConstraintList()
    for p in model.P:
        i,j = E[p-1]
        if not ((s[i],s[j]) == C1 or (s[j],s[i]) == C1
                or (s[i],s[j]) == C2 or (s[j],s[i]) == C1):
            model.setzero.add(expr = model.x[p] == 0)

    # At most a single pair
    model.atmost = ConstraintList()
    for j in range(ns):
        L1 = []
        L2 = []
        for p in model.P:
            v,w = E[p-1]
            if j == w:
                L1.append( p )
            if j == v:
                L2.append( p )
        Ls = L1 + L2
        model.atmost.add(expr = sum(model.x[p] for p in Ls) <= 1)

    # Conflicts
    model.conf = ConstraintList()
    for p in model.P:
        i, j = E[p-1]
        for q in model.P:
            v, w = E[q-1]
            if i < v < j < w:
                model.conf.add(expr = model.x[p] + model.x[q] <= 1)

    sol = SolverFactory('glpk').solve(model, tee=True)

    for info in sol['Solver']:
        print(info)

    xbar = [model.x[i]() for i in model.P]

    return 'optimal', model.obj(), xbar
_____no_output_____s = 'ACGUGCCCGAU'
print(RNAfold(s))_____no_output_____


# Notebook from eRuaro/heart-disease-classification
Path: notebooks/heart-disease-classification.ipynb

# Prediction Heart Disease Using Machine Learning

This notebook showcases an end to end machine learning framework using data on heart disease from the University of California Irvine. _____no_output_____## Problem definition

In a statement,
> Given clinical parameters about a patient, can we predict whether or not a person has a heart disease?
_____no_output_____## Evaluation
>If we can reach 95% accuracy at predicting whether or not a patient has heart disease in the proof of concept. We will pursue the project._____no_output_____### Preparing tools

> Numpy

> Pandas

> Matplotlib

> Seaborn

> Scikit Learn_____no_output_____

import numpy as np
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier

from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.model_selection import RandomizedSearchCV, GridSearchCV
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.metrics import precision_score, recall_score, f1_score
from sklearn.metrics import plot_roc_curve_____no_output_____

### Exploring our Data_____no_output_____

# Reading our data
data = pd.read_csv("heart-disease.csv")
data.shape_____no_output_____

#### Data exploration

1. What questions are we trying to solve?
2. What kind of data do we have
3. What's missing from the dara and how do we deal with it?
4. Where are the outliers and why should you care about them
5. How can you add, change, or remove features to get more out of your data_____no_output_____

data.head()_____no_output_____data['target'].value_counts()_____no_output_____img = data['target'].value_counts().plot(kind='bar', color=['salmon', 'lightblue']);
plt.savefig('Target Count.png')_____no_output_____data.info();<class 'pandas.core.frame.DataFrame'>
RangeIndex: 303 entries, 0 to 302
Data columns (total 14 columns):
 #   Column    Non-Null Count  Dtype
---  ------    --------------  -----
 0   age       303 non-null    int64
 1   sex       303 non-null    int64
 2   cp        303 non-null    int64
 3   trestbps  303 non-null    int64
 4   chol      303 non-null    int64
 5   fbs       303 non-null    int64
 6   restecg   303 non-null    int64
 7   thalach   303 non-null    int64
 8   exang     303 non-null    int64
 9   oldpeak   303 non-null    float64
 10  slope     303 non-null    int64
 11  ca        303 non-null    int64
 12  thal      303 non-null    int64
 13  target    303 non-null    int64
dtypes: float64(1), int64(13)
memory usage: 33.3 KB
# Are there missing values?
data.isna().sum()_____no_output_____data.describe()_____no_output_____

#### Finding Patterns_____no_output_____##### Heart disease frequencey accoding to sex_____no_output_____

data.sex.value_counts()_____no_output_____# compare target column with sex column

pd.crosstab(data['target'], data['sex'])_____no_output_____# create a plot of crosstab
pd.crosstab(data['target'], data['sex']).plot(kind='bar', figsize=(10, 6), color=['salmon', 'lightblue']);

plt.title('Heart Disease Frequency for Sex')
plt.xlabel('0 = No disease, 1 = Disease')
plt.legend(['Female', 'Male'])
plt.ylabel('Amount')
plt.xticks(rotation=0);
plt.savefig('Heart Disease Frequency for Sex.png')_____no_output_____data['thalach'].value_counts()_____no_output_____# Age vs Max Heart Rate for Heart Disease
plt.figure(figsize=(10, 6))

#scatter with positive example (has heart disease)
plt.scatter(
    data.age[data.target == 1],
    data.thalach[data.target==1],
    c='salmon'
);

#scatter with negative example (has no heart disease)
plt.scatter(
    data.age[data.target == 0],
    data.thalach[data.target == 0],
    c='lightblue'
);

# add some helpful info
plt.title('Heart Disease in Function of Age and Max Heart Rate')
plt.xlabel('Age')
plt.ylabel('Max Heart Rate')
plt.legend(['Disease', 'No Disease']);
plt.savefig('Heart Disease in Function of Age and Max Heart Rate.png')_____no_output_____# Check distribution of the age column
# Checks for outliers
data.age.plot.hist();
plt.savefig('Age Frequency Table')

# No outliers so far..._____no_output_____# Heart Disease Frequence per Chest Pain Type
pd.crosstab(data.cp, data.target)_____no_output_____pd.crosstab(data.cp, data.target).plot(kind='bar', figsize=(10,6), color=['salmon','lightblue'])

plt.title('Heart Disease Frequency per Chest Pain Type')
plt.xlabel('Chest Pain Type')
plt.ylabel('Amount')
plt.legend(['No Disease', 'Disease'])
plt.xticks(rotation=0);
plt.savefig('Heart Disease Frequency per Chest Pain Type.png')_____no_output_____# Make correlation matrix
data.corr()_____no_output_____# Visualize correlation matrix
corr_matrix = data.corr()

fig, ax = plt.subplots(figsize=(15, 10))

ax = sns.heatmap(
    corr_matrix,
    annot=True,
    linewidths=0.5,
    fmt='.2f',
    cmap='YlGnBu'
);

plt.savefig('Correlation Matrix.png')_____no_output_____

### Preparing our Data for Machine Learning_____no_output_____

x = data.drop('target', axis=1)
y = data['target']

np.random.seed(42)

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2)_____no_output_____

### Choosing the Right Models

We'll try three different models

1. Logistics Regression

2. K-Nearest Neighbours Classifier

3. Random Forest Classifier _____no_output_____

# put models in dictionary
models = {
    'Logistic Regression': LogisticRegression(),
    'KNN': KNeighborsClassifier(),
    'Random Forest': RandomForestClassifier()
}

# Create a function and score models
def fit_and_score(models, x_train, x_test, y_train, y_test):
    """
    Fits and evaluates given Machine Learning Models.
    """
    np.random.seed(42)
    #Make a dictionary to store model scores
    model_scores = {}

    for name, model in models.items():
        model.fit(x_train, y_train)
        #evaluate the model, and append to model)scores
        model_scores[name] = model.score(x_test, y_test)

    return model_scores_____no_output_____model_scores = fit_and_score(
    models = models,
    x_train=x_train,
    x_test=x_test,
    y_test=y_test,
    y_train=y_train
    )

model_scores_____no_output_____

## Model Comparison_____no_output_____

model_compare = pd.DataFrame(model_scores, index=['Accuracy'])
model_compare.T.plot.bar();_____no_output_____

### Logistic Regression Performs Best_____no_output_____## Tuning and Improving our Models
_____no_output_____Let's look at the following:
* Hyperparameter tuning
* Feature importance
* Confusion Matrix
* Cross-validation
* Precision
* Recall
* F1 score
* Classification report
* ROC Curve
* Area under the curve (AUC)_____no_output_____### Hyperparameter tuning - by hand_____no_output_____

# Tuning KNN
train_scores = []
test_scores = []

# Create a list of different values for n_neightbours
neighbors = range(1, 21)

knn = KNeighborsClassifier()

#loop through different n_neighbours
for i in neighbors:
    knn.set_params(n_neighbors=i)

    knn.fit(x_train, y_train)
    train_scores.append(knn.score(x_train, y_train))
    test_scores.append(knn.score(x_test, y_test))

_____no_output_____plt.plot(neighbors, train_scores, label='Train Score')
plt.plot(neighbors, test_scores, label='Test Score')
#change x values in graph to numbers from 1 to 21 exclusive
plt.xticks(np.arange(1, 21, 1))
plt.xlabel("Number of Neighbors")
plt.ylabel('Model Score')
plt.legend()

print(f'Maximum KNN score on the test data: {max(test_scores)*100:.2f}%')Maximum KNN score on the test data: 75.41%

### Tuning with RandomizedSearchCV_____no_output_____

# Create hyperparameter grid for logistic regresion - documentation reading

#keys are parameters of model
log_reg_grid = {
    'C': np.logspace(-4, 4, 20),
    'solver': ['liblinear'],
}

# Create hyperparameter grid for random forrest - documentation reading
rf_grid = {
    'n_estimators': np.arange(10, 100, 50),
    'max_depth': [None, 3, 5, 10],
    'min_samples_split': np.arange(2, 20, 2),
    'min_samples_leaf': np.arange(1, 20, 2)
}

_____no_output_____

We've got hyperparameters grids setup for our models_____no_output_____

#Tune logistic regression
np.random.seed(42)

#setup hyperparameter search
rs_log_reg = RandomizedSearchCV(
    LogisticRegression(),
    param_distributions=log_reg_grid,
    cv=5,
    n_iter=20, #20 different combinations
    verbose=True
)

#fit random hyperparameter search model for logistic regression
rs_log_reg.fit(x_train, y_train)Fitting 5 folds for each of 20 candidates, totalling 100 fits
[Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
[Parallel(n_jobs=1)]: Done 100 out of 100 | elapsed:    0.8s finished
rs_log_reg.best_params______no_output_____rs_log_reg.score(x_test, y_test)_____no_output_____#Tuning random forrest
np.random.seed(42)

#setup random hyperparameter search for randomforeest classifier
rs_rf = RandomizedSearchCV(
    RandomForestClassifier(),
    rf_grid,
    cv=5,
    n_iter=20,
    verbose=True,
)

rs_rf.fit(x_train, y_train)[Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
Fitting 5 folds for each of 20 candidates, totalling 100 fits
[Parallel(n_jobs=1)]: Done 100 out of 100 | elapsed:    8.0s finished
rs_rf.best_params______no_output_____# Evaluate random forrest
rs_rf.score(x_test, y_test)_____no_output_____

### Hyperparameter Tuning with GridSearchCv_____no_output_____

# Logistic Regression GridSearchCv
log_reg_grid = {
    'C': np.logspace(-4, 4, 30),
    'solver': ['liblinear'],
}

#setup grid hyperparameter search
gs_log_reg = GridSearchCV(
    LogisticRegression(),
    param_grid=log_reg_grid,
    cv=5,
    verbose=True,
)

gs_log_reg.fit(x_train, y_train)
[Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
Fitting 5 folds for each of 30 candidates, totalling 150 fits
[Parallel(n_jobs=1)]: Done 150 out of 150 | elapsed:    1.1s finished
gs_log_reg.score(x_test, y_test)_____no_output_____gs_log_reg.best_params______no_output_____

## Evaluating Logistic Regression Model_____no_output_____* ROC Curve & AUC Score
* Confusion Matrix
* Classification Report
* Precision
* Recall
* F1-score _____no_output_____

# Make predictions first with tuned model

y_preds = gs_log_reg.predict(x_test)_____no_output_____#ROC Curve and AUC Score
#Plot ROC Curve and calculate AUC metric
plot_roc_curve(gs_log_reg, x_test, y_test);
_____no_output_____# Confusion matrix
print(confusion_matrix(y_test, y_preds))[[25  4]
 [ 3 29]]
sns.set(font_scale=1.5)

def plot_confusion_mat(y_test, y_preds):
    '''
    Plots a Confusion Matrix using Heatmap
    '''
    fig, ax = plt.subplots(figsize=(3,3))
    ax = sns.heatmap(
        confusion_matrix(y_test, y_preds),
        annot=True,
        cbar=False,
    )

    plt.xlabel("True Label")
    plt.ylabel('Predicted Label')

plot_confusion_mat(y_test, y_preds)_____no_output_____

Now we've got a ROC Curve, AUC Metric, and Confusion Matrix. Now lets
go get a classification report, as well as cross-validated precision, recall, and f1-score_____no_output_____

print(classification_report(y_test, y_preds))
# Not really accurate: Only calculates with one split              precision    recall  f1-score   support

           0       0.89      0.86      0.88        29
           1       0.88      0.91      0.89        32

    accuracy                           0.89        61
   macro avg       0.89      0.88      0.88        61
weighted avg       0.89      0.89      0.89        61

# Calculate evaluation metrics using cross validation

gs_log_reg.best_params______no_output_____#Create new cclassifier with best params
clf = LogisticRegression(
    C=0.20433597178569418,
    solver='liblinear'
)

_____no_output_____# Corss Validated Accuracy

cv_acc = cross_val_score(
    clf,
    x,
    y,
    cv=5,
    scoring='accuracy'
)

cv_acc = np.mean(cv_acc)_____no_output_____# Cross validated precision
cv_precision = cross_val_score(
    clf,
    x,
    y,
    cv=5,
    scoring='precision'
)

cv_precision = np.mean(cv_precision)
cv_precision_____no_output_____# Cross validated recall
cv_recall = cross_val_score(
    clf,
    x,
    y,
    cv=5,
    scoring='recall'
)

cv_recall = np.mean(cv_recall)
cv_recall_____no_output_____# Cross validated f1

cv_f1 = cross_val_score(
    clf,
    x,
    y,
    cv=5,
    scoring='f1'
)

cv_f1 = np.mean(cv_f1)
cv_f1_____no_output_____# Visualize cross validated metrics

cv_metrics = pd.DataFrame(
    [[cv_acc, cv_precision, cv_recall, cv_f1]],
    columns=['Accuracy', 'Precision', 'Recall', 'F1'])

cv_metrics.T.plot.bar(title='Cross-Validated Classification Metrics', legend=False);_____no_output_____

 ### Feature Importance

Another way of asking, "which features contributed most to the outcomes of the model and how did they contribute?"

Finding feature importance is different for each machine learning model

google -> "(Model Name) Feature importance"

  _____no_output_____

data.head()_____no_output_____clf = LogisticRegression(
    C=0.20433597178569418,
    solver='liblinear'
)

clf.fit(x_train, y_train);_____no_output_____# Check coef_ -> gives coefficient of how variables contribute to the target
clf.coef______no_output_____# match coeff of features to columns
feature_dict = dict(zip(data.columns, list(clf.coef_[0])))
feature_dict_____no_output_____# Visualize feature importance
feature_df = pd.DataFrame(feature_dict, index=[0])
feature_df.T.plot.bar(title='Featre Importance', legend=False);_____no_output_____ # Use to figure out what is going on
 pd.crosstab(data['sex'], data['target'])_____no_output_____pd.crosstab(data['slope'], data['target'])_____no_output_____

## Experimentation

If you haven't hit your evaluation metric yet.......
* Could you collect more data?
* Could you try a better model? Like CatBoost or XGBoost?
* Could you improve the current models? (Beyond what you've done so far)_____no_output_____

## Exporting a model
import pickle

#Save model
pickle.dump(clf, open('final_model.pkl', 'wb'))

#load model
model = pickle.load(open('final_model.pkl', 'rb'))

#make prediction with model
_____no_output_____