Intelligence Artificielle

Mettre en place un serveur MCP en Python 3.13

Qu’est ce qu’un serveur MCP (Model Context Protocole?)

Un serveur MCP est un webservice qui fournit de la data, cette data va servir de contexte pour l’IA générative. A la différence du RAG (Retrieval Augmented Generation), il n’est pas besoin de base vectorielle pour stocker des informations.

Le workflow est le suivant, le client IA (Claude Desktop) va requêter le serveur MCP pour retirer les informations dont il a besoin pour envoyer au LLM.(chatGPT par exemple).

Cette technologie inventée par Anthropic (à l’origine de Claude) est devenue très populaire. Je pense que c’est dû au fait qu’il n’est besoin que de connaissances existantes (Webservice) par les développeurs pour pouvoir taquiner les IA afin d’éviter les hallucination.

Mais cela va au delà des hallucination, un serveur MCP est un webservice à destination non du programmeur mais de l’IA générative. Je pense que c’est une bonne comparaison, l’IA (ou plutôt l’agent IA) va pouvoir décider en autonomie de requêter un serveur MCP pour chercher les information dont il a besoin?

Donc un serveur MCP est un webservice pour agent IA, au même titre qu’un webservice est un …. webservice pour un programmeur. Dans notre cas on va utiliser Claude Desktop comme « agent IA » ou plutôt comme Client IA.

Mise en place du serveur MCP dans Pycharm

Création de mon environnement virtuel

On crée le répertoire MCP puis on fait la commande :

vous avez un répertoire appelé MCP rentrez dedans

$ python -m venv .venv

# activez l'environnement virtuel (sous powershell)
$ .\.venv\Scripts\Activate.ps1

#c'est activé (normalement) vous avez accès à la commande pip
$ pip --version

Installation de uv

uv est un gestionnaire de paquet nouvelle génération écrite en RUST.

pip install uv

puis avec uv on installe mcp 

uv add "mcp[cli]"

A partir de maintenant on va utiliser uv pour installer notre projet

uv init 

# la commande tree -L 2 nous donne la structure suivante
.
|-- README.md
|-- main.py
|-- .venv
|   |-- Include
|   |-- Lib
|   |-- Scripts
|   `-- pyvenv.cfg
|-- pyproject.toml
`-- uv.lock

Pour install tree dans Windows Gitbash, il faut télécharger l’EXE et le mettre dans C:\Program Files\Git\usr\bin, un tuto est accessible sur ce lien.

Le soucis c’est que mcp s’attends à ce que le dossier environnement virtuel soit nommé .venv, si vous faites la commande:

$ uv run mcp
warning: `VIRTUAL_ENV=mcpvenv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead


Désativez l'environnement avec 
$ deactivate
si un .venv est présent, exécutez le venv  de ce répertoire.

Exemple rapide de serveur MCP:

#server.py

from mcp.server.fastmcp import FastMCP

# Create an MCP server
mcp = FastMCP("Demo")


# Add an addition tool
@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b


# Add a dynamic greeting resource
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
    """Get a personalized greeting"""
    return f"Hello, {name}!"


# Add a prompt
@mcp.prompt()
def greet_user(name: str, style: str = "friendly") -> str:
    """Generate a greeting prompt"""
    styles = {
        "friendly": "Please write a warm, friendly greeting",
        "formal": "Please write a formal, professional greeting",
        "casual": "Please write a casual, relaxed greeting",
    }

    return f"{styles.get(style, styles['friendly'])} for someone named {name}."

Pour le faire tourner

mcp dev server.py

#il vous sera demandé d'installer le paquet @modelcontextprotocol/inspector@0.17.0

Source: https://composio.dev/blog/mcp-server-step-by-step-guide-to-building-from-scrtch

Installer votre propre IA avec Ollama sur votre ordinateur (Windows)

Installation de Ollama

Pour commencer il faut aller sur le site Ollama.com (à ne pas confondre avec Llama.com). Le lien Github, pour consulter les sources et la documentation sur les modèles

Téléchargez et installez. Une fois installé, vous aurez une opup comme ci-dessous :

Vous allez vous perdre dans la jungle des modèles. Vous connaissez sans doute aussi de nom Hugging Face, c’est un repository de modèle LLM open source. Une fois que vous avez installé Ollama, ouvrez un terminal et tapez la commande $ ollama, une liste de commande sera affichée. Allez sur le Github, vous verrz un tableau de modèles LLM, différentes capacité et taille, ça va de 1 Go à 404 Go ! En fonction de votre machine téléchargez celui que vous pouvez faire fonctionner !

La colonne parameters indique combien de paramètres est considéré par le modèle, comptez environs 1 giga de RAM pour 1 Giga (Billion milliards) de paramètres. Mon ordinateur fait 32 Giga de RAM, je peux aller avec le modèle à 30B paramètres.

Les commandes pour installer les LLM

ollama run llama2


Pour connaitres les différentes commande au sein du chat
/?
>>> /?
Available Commands:
  /set            Set session variables
  /show           Show model information
  /load <model>   Load a session or model
  /save <model>   Save your current session
  /clear          Clear session context
  /bye            Exit
  /?, /help       Help for a command
  /? shortcuts    Help for keyboard shortcuts

Use """ to begin a multi-line message.


Pour quitter :
/bye

Pour lister les modèles :
ollama list

.

Ecran du téléchargement du modèle, à la vue de la sortie terminal, ça ressemble à une image Docker

Test de llama2

La première chose que je constate, est que le modèle est vraiment limité, surtout quand vous avez l’habitude de vous amuser avec chatGPT ou Claude sur le web, donc sur les modèles les plus sophistiqués.

Par exemple je lui demande de me donner la recette du roesti, il me répond qu’il ne sait pas

Quand je lui demande la recette de la tarte tatin, il me liste en anglais (pas vraiment multilingue, ok pour la taille du modèle cependant !). Par contre comme il est hébergé en local il est très rapide. Je lui demande pourquoi il me répond qu’il n’a pas réussi à détecter (alors que je lui ai demandé en français), mais il me propose de donner la version française ce qu’il fit très bien.

Nous allons télécharger un autre modèle

Cette fois ci nous allons télécharger le modèle Mistral, équivalent en taille. Sur le repository, vous trouverez la commande dans le tableau

ollama run mistral

Après le téléchargement, il démarre le prompt automatiquement. Quand vous ferez bye, vous pourrez démarrer entre les deux modèles (Mistral ou llma2 en ligne de commande)
Pour effacer un modèle, vous avez la comamnde remove, mais pensez à faire ollama pour afficher les différentes commandes.

Et maintenant explorons l’API !

Eh oui c’est là que cela devient très intéressant car nous allons pouvoir exploiter un LLM depuis un programme pour automatiser nos tâche (notre GRAAL)

Ollama expose une API sur le localhost. Dans la barre des tâches, vous pouvez voir une image de Llama, cela veut dire que l’application Desktop est lancée, et donc que l’API est active. Mais si le desktop n’est pas llancé, vous pouvez toujours lancer le serveur en ligne de commande

ollama serve

En parlant de l’icone, vous pouvez accéder aux paramètres

Vous remarquez (j’espère) le slider pour définir la « mémoire » du LLM (la taille du contexte), ceci est utile pour se souvenir d’une conversation !

Vous pouvez exposer le LLM au réseau local (très intéressant !)

Installer le package Python ollama

Très simplement on va faire un script pour requêter Ollama par API avec pip install ollama. Si vous installez le paquet après avoir démarré l’environnement virtuel, il sera en local, si vous le faites hors environnement virtuels, il sera dans le système. Pour connaitre où est installé un package

#affiche les emplacements pouvant héberger les paquets
python -m site

# cherche un paquet en particuliers
python -m pip show <paquet>

#lister les paquets de l'environnement virtuel en cours
pip list
import ollama
client = ollama.Client()

model = "llama2"
prompt = "How to set up a venv?"

response = client.generate(model, prompt)
print(f"Response from {model}:")
print(response.response)

Si vous préférez que la réponse arrive morceau par morceau voici le script (il suffit de passer stream= True)

import ollama

client = ollama.Client()

model = "llama2"
prompt = "How to set up a venv?"

# Use the stream=True option
stream = client.generate(model=model, prompt=prompt, stream=True)

print(f"Response from {model}:\n")

for chunk in stream:
    # Each chunk is a small piece of the model's output
    print(chunk['response'], end='', flush=True)

print()  # final newline

Ok là c’est mieux mais on peut wrapper les phrases (retour à la ligne quand ça déborde)

Avec retour à la ligne sans streaming

import ollama
import textwrap
import shutil

client = ollama.Client()
model = "llama2"
prompt = "How to set up a venv?"

response = client.generate(model=model, prompt=prompt)
terminal_width = shutil.get_terminal_size().columns

print(f"Response from {model}:\n")
print(textwrap.fill(response['response'], width=terminal_width))

Avec retour à la ligne avec streaming

import ollama
import shutil
import sys

client = ollama.Client()
model = "llama2"
prompt = "How to set up a venv?"

stream = client.generate(model=model, prompt=prompt, stream=True)

print(f"Response from {model}:\n")

terminal_width = shutil.get_terminal_size().columns
current_line = ""

for chunk in stream:
    text = chunk["response"]
    for char in text:
        current_line += char
        if len(current_line) >= terminal_width - 1:
            print(current_line)
            current_line = ""
# print remaining text
if current_line:
    print(current_line)

Personnalisation du modèle

Avec un fichier de configuration, on peut agir sur la façon dont les réponse vont être apportées, il y a de nombreux paramètres qui font qu’un LLM va se comporer d’une certianes façon. Vous avez peut être remarqué que ChatGPT était plus jovial que Claude qui est plus sérieux. Perso j’aim bien cette touche.

Mettez Le fichier de personnalisation Modefile dans un répertoire et invoquez une commande sur ce fichier dans ce répertoire.

$ ollama create formabot -f ./Modelfile

# ici j'ai invoqué un modèle non présent ça met du temps à télécharger le modèle

On va lancer avec la commande run

$ ollama run formabot

>>> qui es tu?



# pour effacer le modèle
$ ollama rm formabot

Conclusion :

Dans sa forme, l’outil ressemble beaucoup à Docker (les commandes, les modèles). Voilà pour une première introduction de Ollama. Pourquoi est ce important d’avoir un LLM en local? Si vous voulez rester privé, avoir des temps de réponse rapide, et ne pas vous ruiner en crédit token, alors n’hésitez pas à utiliser un LLM open source, il y en a d’autres allez voir du côté de Deepseek et de HuggingFace.

Tuto Réalisez un serveur MCP Model Context Protocol

Dans quelle situation un serveur MCP est pertinent?

Un modèle LLM comme ChatGPT est généraliste et a été entrainé sur un dataset limité bien que grand. Il ne dispose pas d’une connaissance universelle. Mais avez vous remarqué qu’une IA a toujours réponse à tous néanmoins? quitte à vous sortir des insanités? qu’on appelle des hallucinations.

Pour éviter ce phénomène d’hallucination, qui résulte d’un trou dans sa connaissance, il faut pouvoir compléter avec des données supplémentaires. Seulement voilà, le modèle de chatGPT a été entrainé déjà (et c’est ultra coûteux d’entrainer une IA, tout le monde ne dispose pas les moyens de le faire.

La solution du RAG (Retrieval Augmented Generation)

Le principe repose sur le stockage dans une base vectorielle des données, avec une base de données vectorielle comme FAISS ou ChromaDB, puis de prompter la base au lieu de prompter chatGPT, le résultat de ce prompt est envoyé à chatGPT. (prompt augmenté).

C’est une bonne solution, et vraiment adapté à certaines situation comme chatPDF. Cependant c’est un peu lourd. Nous allons voir qu’Anthropic propose une solution qui marche dans le sens inverse mais qui semble être plus légère et performante (quoique différent).

La solution MCP (Model Context Protocol)

Cette solution à base de données structurée (et c’est là la différence avec la RAG) requiert à Claude (et non chatGPT puisque qu’au moment d’écrire cet article chatGPT ne le prend pas encore en charge), de faire un sorte de requête AJAX avec un serveur MCP pour contextualiser la réponse à un prompt.

MCP ouvre tout un univers de champs d’application très intéressant, tout en préservant l’anonymat des données.

Exemple de code en Python d’un serveur MCP

Assistant RH intelligent

rh-assistant-mcp/
├── employee_db.json
├── mcp_server.py
├── mcp_manifest.json

Notre serveur MCP est simplement un serveur REST, le différence c’est ce n’est pas vous qui requêtez en AJAX mais c’est Claude qui va le requêter (on comprend mieux pourquoi les autres vendors d’IA ne l’ont pas encore implémenté)

employee_db.json est la base de données, mcp_manifest/json va décrire la façon dont les données seront formatées en input (venant de Claude) et en output (allant vers Claude).

{
  "employees": [
    {
      "id": "E001",
      "name": "Alice Dupont",
      "role": "Développeuse Backend",
      "hire_date": "2021-06-12",
      "skills": ["Python", "Django", "PostgreSQL"]
    },
    {
      "id": "E002",
      "name": "Bruno Martin",
      "role": "Data Analyst",
      "hire_date": "2022-01-20",
      "skills": ["SQL", "Power BI", "Python"]
    }
  ]
}

Le fichier manifest.json qui décrit les format d’entrée et sortie

{
  "name": "employee-context",
  "description": "Provides structured context about employees from the HR database.",
  "version": "1.0",
  "input_schema": {
    "type": "object",
    "properties": {
      "employee_name": {
        "type": "string",
        "description": "Name of the employee to look up"
      }
    },
    "required": ["employee_name"]
  },
  "output_schema": {
    "type": "object",
    "properties": {
      "employee_info": {
        "type": "object",
        "properties": {
          "role": { "type": "string" },
          "hire_date": { "type": "string" },
          "skills": { "type": "array", "items": { "type": "string" } }
        }
      }
    }
  }
}

Le fichier serveur avec FastAPI

from fastapi import FastAPI, Request
import json

app = FastAPI()

with open("employee_db.json", "r") as f:
    db = json.load(f)

@app.post("/mcp/context")
async def provide_context(request: Request):
    body = await request.json()
    name = body.get("employee_name", "").lower()

    for emp in db["employees"]:
        if emp["name"].lower() == name:
            return {
                "employee_info": {
                    "role": emp["role"],
                    "hire_date": emp["hire_date"],
                    "skills": emp["skills"]
                }
            }
    return {"employee_info": None}

Pour tester rapidement le serveur:

curl -X POST http://localhost:8000/mcp/context   -H "Content-Type: application/json"   -d '{"employee_name": "alice dupont"}'

Comment déployer ce système

Pour que Claude puisse lire le manifest.json, on peut le stocker sur une github page, ou un serveur accessible publiquement. On va notifier cette url à Claude via son interface graphique ou par API.

Le serveur REST doit être accessible publiquement, si vous le déployez en local il faut faire un tunnel vers votre serveur.

Comment utiliser ce système

Il faut que ce soit clair que dans ce cas de figure vous ne pouvez requêter Claude que par API. Quand est ce que Claude va savoir qu’il va avoir besoin de requêter le serveur MCP? C’est lui qui détermine quand il faut requêter le serveur MCP, quand il va s’apercevoir que le contexte lui manque et qu’il existe un serveur MCP décrit par le manifest.json existe.

Exemple de requête faite à Claude

vous pouvez faire en Javascript cette requête POST

POST /v1/messages
{
  "model": "claude-3-opus-20240229",
  "messages": [
    { "role": "user", "content": "Peux-tu me dire ce que fait Bruno Martin ?" }
  ],
  "context_providers": [
    { "url": "https://ton-domaine.com/mcp_manifest.json" }
  ]
}

Pour exposer le site sur un serveur il suffit de simplement déployer sur un serveur. Une alternative serait de créer un tunnel SSH pour exposer votre site local à Claude.

Quelques visuels pour évaluer le modèle

Je vous présente quelques graphique qui servent à avoir un insight dans le modèle ainsi que l’évaluation de la qualité du modèle, sa précision etc.

Confusion Matrix

Ceci graphique permet de voir el taux de faux positifs et de faux négatifs.

import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import confusion_matrix

import joblib
loaded_rf = joblib.load('random_forest_model.pkl')
#load the datas
import pickle
with open('X_test.pkl', 'rb') as f:
    X_test = pickle.load(f)
with open('y_test.pkl', 'rb') as f:
    y_test = pickle.load(f)

prediction = loaded_rf.predict(X_test)
# Create confusion matrix
cm = confusion_matrix(y_test, prediction)

# Plot confusion matrix
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', cbar=False)
plt.title('Confusion Matrix')
plt.xlabel('Predicted Label')
plt.ylabel('True Label')
plt.show()

Receiver Operating Characteristics

from sklearn.metrics import roc_curve, auc

# Get probability scores for the positive class
if hasattr(loaded_rf, "predict_proba"):
    proba = loaded_rf.predict_proba(X_test)[:, 1]
    fpr, tpr, _ = roc_curve(y_test, proba)
    roc_auc = auc(fpr, tpr)
    
    # Plot ROC curve
    plt.figure(figsize=(8, 6))
    plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (area = {roc_auc:.2f})')
    plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('Receiver Operating Characteristic (ROC) Curve')
    plt.legend(loc='lower right')
    plt.show()

Precision recall curve

from sklearn.metrics import precision_recall_curve, average_precision_score

# Get probabilities and calculate precision-recall
if hasattr(loaded_rf, "predict_proba"):
    proba = loaded_rf.predict_proba(X_test)[:, 1]
    precision, recall, _ = precision_recall_curve(y_test, proba)
    avg_precision = average_precision_score(y_test, proba)
    
    # Plot precision-recall curve
    plt.figure(figsize=(8, 6))
    plt.plot(recall, precision, color='blue', lw=2, label=f'Precision-Recall curve (AP = {avg_precision:.2f})')
    plt.xlabel('Recall')
    plt.ylabel('Precision')
    plt.title('Precision-Recall Curve')
    plt.legend(loc='lower left')
    plt.show()

Classification report

from sklearn.metrics import classification_report
import pandas as pd

# Get classification report as a dictionary
report = classification_report(y_test, prediction, output_dict=True)
report_df = pd.DataFrame(report).transpose()

# Plot classification report
plt.figure(figsize=(10, 6))
sns.heatmap(report_df.iloc[:-1, :].drop(['support'], axis=1), annot=True, cmap='Blues')
plt.title('Classification Report')
plt.tight_layout()
plt.show()

Feature importance

if hasattr(loaded_rf, "feature_importances_"):
    # Create a dataframe of feature importances
    feature_importance = pd.DataFrame({
        'feature': X_test.columns,
        'importance': loaded_rf.feature_importances_
    }).sort_values('importance', ascending=False)
    
    # Plot feature importances
    plt.figure(figsize=(10, 8))
    sns.barplot(x='importance', y='feature', data=feature_importance)
    plt.title('Feature Importance')
    plt.tight_layout()
    plt.show()

Overall Performance Metrics

from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score

accuracy = accuracy_score(y_test, prediction)
precision = precision_score(y_test, prediction, average='weighted')
recall = recall_score(y_test, prediction, average='weighted')
f1 = f1_score(y_test, prediction, average='weighted')

metrics = pd.DataFrame({
    'Metric': ['Accuracy', 'Precision', 'Recall', 'F1 Score'],
    'Value': [accuracy, precision, recall, f1]
})

# Plot metrics
plt.figure(figsize=(10, 6))
sns.barplot(x='Value', y='Metric', data=metrics, hue='Metric',palette='viridis')
plt.title('Model Performance Metrics')
plt.xlim(0, 1)
for i, v in enumerate(metrics['Value']):
    plt.text(v + 0.01, i, f'{v:.4f}', va='center')
plt.tight_layout()
plt.show()

Réutiliser un modèle Machine Learning généré

Afin de ne pas à avoir à calculer toujours le modèle pour déterminer si un client va partir ou non, on sauve le modèle entrainé et on on va le réutiliser

Charger le modèle

import joblib

#load the model
loaded_rf = joblib.load('random_forest_model.pkl')
print(loaded_rf)

Charger les données

Normalement nous chargeons de nouvelles données qu’on va donner à moudre au modèle, mais on n’en a pas, donc on va repasser les même données qu’on a sauvegardé.

#load the datas
import pickle

with open('X_test.pkl', 'rb') as f:
    X_test = pickle.load(f)
with open('y_test.pkl', 'rb') as f:
    y_test = pickle.load(f)

X_test.head()

Injection des données de test (les données de test n’ont pas servi à entrainer le modèle), on va obtenir une Series avec des 0 et 1

# Now you can use the loaded model to make predictions

from sklearn.metrics import accuracy_score
prediction = loaded_rf.predict(X_test)
#la série qui contient les outcome (churn si 1 , 0 si no churn)
print(prediction)

[0 1 1 1 0 0 0.... ]

Test sur une ligne (un client)

# test on row
# subset = X_test[X_test['TotalCharges']<100]
# extract a line from X_test
oneline = X_test.loc[[6125]]
#convert to dataframe
#oneline = oneline.to_frame()

import pandas as pd
isinstance(oneline,pd.DataFrame) # test if it's DataFrame
 
# affichage des informations de la ligne
print(oneline)
oneline.head()

#prediction sur la ligne
prediction = loaded_rf.predict(oneline)
print(prediction)

chatPDF clone sans base vectorielle

Cet exemple est plus facile à comprendre

Installer les module Python suivants

pip install PyPDF2
pip install openai
pip install tiktoken
#!/usr/bin/env python3
"""
ChatPDF Simple - Version corrigée pour OpenAI v1.0+
Permet d'extraire le texte d'un PDF et de poser des questions dessus
Sans base vectorielle
"""

import PyPDF2
from openai import OpenAI
import os
import argparse
import sys
from typing import List, Optional
import tiktoken
import re


class ChatPDF:
    def __init__(self, api_key: Optional[str] = None):
        """
        Initialise ChatPDF avec la clé API OpenAI
        """
        self.api_key = 'sk-proj-BBXFnLZzRMt8EZp4LStJXyfzc_6knvyK'
        if not self.api_key:
            raise ValueError("Clé API OpenAI requise. Définissez OPENAI_API_KEY ou passez api_key")

        self.client = OpenAI(api_key=self.api_key)
        self.pdf_content = ""
        self.pdf_path = ""
        self.encoding = tiktoken.get_encoding("cl100k_base")

    def extract_text_from_pdf(self, pdf_path: str) -> str:
        """
        Extrait tout le texte d'un fichier PDF
        """
        try:
            with open(pdf_path, 'rb') as file:
                pdf_reader = PyPDF2.PdfReader(file)
                text = ""

                print(f"Extraction du texte de {len(pdf_reader.pages)} pages...")

                for page_num, page in enumerate(pdf_reader.pages):
                    try:
                        page_text = page.extract_text()
                        text += f"\n--- Page {page_num + 1} ---\n{page_text}\n"
                    except Exception as e:
                        print(f"Erreur lors de l'extraction de la page {page_num + 1}: {e}")
                        continue

                self.pdf_content = text
                self.pdf_path = pdf_path
                print(f"Extraction terminée. {len(text)} caractères extraits.")
                return text

        except FileNotFoundError:
            raise FileNotFoundError(f"Fichier PDF non trouvé: {pdf_path}")
        except Exception as e:
            raise Exception(f"Erreur lors de la lecture du PDF: {e}")

    def chunk_text(self, text: str, max_tokens: int = 3000) -> List[str]:
        """
        Divise le texte en chunks pour respecter les limites de tokens
        """
        sentences = re.split(r'[.!?]+', text)
        chunks = []
        current_chunk = ""

        for sentence in sentences:
            sentence = sentence.strip()
            if not sentence:
                continue

            test_chunk = current_chunk + " " + sentence if current_chunk else sentence

            if len(self.encoding.encode(test_chunk)) <= max_tokens:
                current_chunk = test_chunk
            else:
                if current_chunk:
                    chunks.append(current_chunk)
                current_chunk = sentence

        if current_chunk:
            chunks.append(current_chunk)

        return chunks

    def ask_question(self, question: str, model: str = "gpt-3.5-turbo") -> str:
        """
        Pose une question sur le contenu du PDF
        """
        if not self.pdf_content:
            return "Aucun PDF chargé. Veuillez d'abord extraire le contenu d'un PDF."

        # Préparer le contexte
        chunks = self.chunk_text(self.pdf_content, max_tokens=3000)

        # Si le document est court, utiliser tout le contenu
        if len(chunks) == 1:
            context = chunks[0]
        else:
            # Pour des documents longs, prendre les premiers chunks
            context = " ".join(chunks[:2])  # Utiliser les 2 premiers chunks
        print(('======'))
        print(context)
        print(('======'))

        prompt = f"""
Vous êtes un assistant qui répond aux questions basées sur le contenu d'un document PDF.

Contenu du document:
{context}

Question: {question}

Instructions:
- Répondez uniquement basé sur le contenu fourni
- Si l'information n'est pas dans le document, dites-le clairement
- Citez des passages spécifiques quand c'est pertinent
- Soyez précis et concis

Réponse:"""

        try:
            response = self.client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system", "content": "Vous êtes un assistant spécialisé dans l'analyse de documents PDF."},
                    {"role": "user", "content": prompt}
                ],
                max_tokens=500,
                temperature=0.1
            )

            return response.choices[0].message.content.strip()

        except Exception as e:
            return f"Erreur lors de la génération de la réponse: {e}"

    def summarize_pdf(self) -> str:
        """
        Génère un résumé du PDF
        """
        if not self.pdf_content:
            return "Aucun PDF chargé."

        chunks = self.chunk_text(self.pdf_content, max_tokens=3000)
        context = " ".join(chunks[:3])  # Utiliser les 3 premiers chunks

        prompt = f"""
Veuillez créer un résumé concis du document suivant:

{context}

Le résumé doit:
- Capturer les points clés et idées principales
- Être structuré et facile à lire
- Faire environ 200-300 mots maximum

Résumé:"""

        try:
            response = self.client.chat.completions.create(
                model="gpt-3.5-turbo",
                messages=[
                    {"role": "system", "content": "Vous créez des résumés clairs et concis de documents."},
                    {"role": "user", "content": prompt}
                ],
                max_tokens=400,
                temperature=0.2
            )

            return response.choices[0].message.content.strip()

        except Exception as e:
            return f"Erreur lors de la génération du résumé: {e}"


def main():
    parser = argparse.ArgumentParser(description="ChatPDF Simple - Analyseur de PDF avec IA")
    parser.add_argument("pdf_path", help="Chemin vers le fichier PDF")
    parser.add_argument("--api-key", help="Clé API OpenAI (ou utilisez la variable d'environnement OPENAI_API_KEY)")
    parser.add_argument("--model", default="gpt-3.5-turbo", help="Modèle OpenAI à utiliser")

    args = parser.parse_args()

    try:
        # Initialiser ChatPDF
        chat_pdf = ChatPDF(api_key=args.api_key)

        # Extraire le texte du PDF
        print(f"Chargement du PDF: {args.pdf_path}")
        chat_pdf.extract_text_from_pdf(args.pdf_path)

        # Générer un résumé automatique
        print("\n=== RÉSUMÉ AUTOMATIQUE ===")
        summary = chat_pdf.summarize_pdf()
        print(summary)

        # Mode interactif pour les questions
        print("\n=== MODE QUESTIONS-RÉPONSES ===")
        print("Posez vos questions sur le document (tapez 'quit' pour quitter):")

        while True:
            try:
                question = input("\n❓ Votre question: ").strip()

                if question.lower() in ['quit', 'exit', 'q']:
                    break

                if not question:
                    continue

                print("🤔 Recherche de la réponse...")
                answer = chat_pdf.ask_question(question, model=args.model)
                print(f"\n💡 Réponse:\n{answer}")

            except KeyboardInterrupt:
                break
            except Exception as e:
                print(f"Erreur: {e}")

        print("\nAu revoir!")

    except Exception as e:
        print(f"Erreur: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

# Exemple d'utilisation en tant que module
"""
from chatpdf_simple import ChatPDF

# Initialiser
chat = ChatPDF(api_key="votre_clé_api")

# Charger un PDF
chat.extract_text_from_pdf("document.pdf")

# Poser des questions
response = chat.ask_question("Quels sont les points principaux de ce document?")
print(response)

# Générer un résumé
summary = chat.summarize_pdf()
print(summary)
"""

Usage

Macos
python3 main.py  document.pdf
Windows
python main.py document.pdf

Créez un clone de chatPDF en Python et la base de données vectorielle FAISS

Si vous ne connaissez pas chatPDF, je vous recommande de le tester sur le site en uploadant un fichier pdf, et interrogez le document comme si vous interrogiez chatGPT. L’intérêt est que vous pouvez connaitre c’e qui vous intéresse du document sans le lire entièrement.

Par exemple vous souscrivez à une assurance, et il y a un long contrat en petits caractères à lire, plutôt que de perdre des heures à décortiquer le document, vous interrogez chatGPT sur le document.

Techniquement ce n’est pas comme ça. En fait vous injecter le texte du document dans la base vectorielle FAISS, vous interrogez FAISS, qui vous retournera un résultat (qui est un contexte) et vous le soumettez à chatGPT.

Vous contextualisez très précisément votre prompt.

Installation des composants

La base de données vectorielle FAISS

FAISS est un logiciel fait par Facebook, acronyme de Facebook AI Similarity Search. Elle tokenise le pdf et le stocke sous forme vectorielle.

Le parseur de PDF

PyPDF2 est un très bon parseur de pdf.

Un tokeniser

tiktoken va transformer le texte en token. Un token est une unité qui a une signification mathématique pour le LLM. tiktoken travaille de concert avec le modèle de OpenAI.

Openai

Permet d’appeler le webservice d’OpenAI facilement

Une clé API par exemple chatGPT

Il vous faudra acheter des crédits pour le webservice chatGPT, 10 euros est pas mal et vous permettra de faire beaucoup d’appels.

Le code Python

#!/usr/bin/env python3
"""
ChatPDF Clone avec Base de Données Vectorielle
Utilise des embeddings et une recherche vectorielle pour une meilleure précision
"""

import PyPDF2
from openai import OpenAI
import os
import argparse
import sys
import numpy as np
from typing import List, Optional, Dict, Tuple
import tiktoken
import re
import faiss
import pickle
from sentence_transformers import SentenceTransformer
from dataclasses import dataclass
import json


@dataclass
class TextChunk:
    text: str
    page_num: int
    chunk_id: int
    embedding: Optional[np.ndarray] = None


class VectorChatPDF:
    def __init__(self, api_key: Optional[str] = None, embedding_model: str = "all-MiniLM-L6-v2"):
        """
        Initialise ChatPDF avec base vectorielle
        """
        self.api_key = "sk-proj-BBFnLZzRMt8EZp4LStJXyfzc_6knvyKX2jOhYXIRh27MmGHIkxzcGFfcisYMS3uiRYBP0r2qT3BlbkFJObsGY8qW-VjRQNLsB9Fr-iPhSmCs0Aaj2Y8hSF34dBQQ4dEgiq7mQO30BCWDItJC775SUA"
        if not self.api_key:
            raise ValueError("Clé API OpenAI requise. Définissez OPENAI_API_KEY ou passez api_key")

        self.client = OpenAI(api_key=self.api_key)

        # Modèle d'embeddings local (gratuit)
        print("Chargement du modèle d'embeddings...")
        self.embedding_model = SentenceTransformer(embedding_model)

        # Base de données vectorielle FAISS
        self.vector_db = None
        self.chunks: List[TextChunk] = []
        self.pdf_path = ""
        self.encoding = tiktoken.get_encoding("cl100k_base")

    def extract_text_from_pdf(self, pdf_path: str) -> List[TextChunk]:
        """
        Extrait le texte du PDF et le divise en chunks
        """
        try:
            with open(pdf_path, 'rb') as file:
                pdf_reader = PyPDF2.PdfReader(file)
                chunks = []
                chunk_id = 0

                print(f"Extraction du texte de {len(pdf_reader.pages)} pages...")

                for page_num, page in enumerate(pdf_reader.pages):
                    try:
                        page_text = page.extract_text()
                        if page_text.strip():
                            # Diviser la page en chunks
                            page_chunks = self._split_text_into_chunks(page_text, max_tokens=500)
                            for chunk_text in page_chunks:
                                chunk = TextChunk(
                                    text=chunk_text,
                                    page_num=page_num + 1,
                                    chunk_id=chunk_id
                                )
                                chunks.append(chunk)
                                chunk_id += 1

                    except Exception as e:
                        print(f"Erreur lors de l'extraction de la page {page_num + 1}: {e}")
                        continue

                self.chunks = chunks
                self.pdf_path = pdf_path
                print(f"Extraction terminée. {len(chunks)} chunks créés.")
                return chunks

        except FileNotFoundError:
            raise FileNotFoundError(f"Fichier PDF non trouvé: {pdf_path}")
        except Exception as e:
            raise Exception(f"Erreur lors de la lecture du PDF: {e}")

    def _split_text_into_chunks(self, text: str, max_tokens: int = 500) -> List[str]:
        """
        Divise le texte en chunks optimaux pour les embeddings
        """
        # Nettoyer le texte
        text = re.sub(r'\s+', ' ', text).strip()

        # Diviser par phrases
        sentences = re.split(r'[.!?]+', text)
        chunks = []
        current_chunk = ""

        for sentence in sentences:
            sentence = sentence.strip()
            if not sentence:
                continue

            test_chunk = current_chunk + " " + sentence if current_chunk else sentence

            if len(self.encoding.encode(test_chunk)) <= max_tokens:
                current_chunk = test_chunk
            else:
                if current_chunk:
                    chunks.append(current_chunk)
                current_chunk = sentence

        if current_chunk:
            chunks.append(current_chunk)

        return [chunk for chunk in chunks if len(chunk.strip()) > 20]  # Filtrer les chunks trop courts

    def create_embeddings(self):
        """
        Crée les embeddings pour tous les chunks et construit l'index FAISS
        """
        if not self.chunks:
            raise ValueError("Aucun chunk disponible. Extrayez d'abord le texte du PDF.")

        print("Création des embeddings...")
        texts = [chunk.text for chunk in self.chunks]

        # Créer les embeddings par batch pour l'efficacité
        embeddings = self.embedding_model.encode(texts, show_progress_bar=True)

        # Associer les embeddings aux chunks
        for i, chunk in enumerate(self.chunks):
            chunk.embedding = embeddings[i]

        # Créer l'index FAISS
        dimension = embeddings.shape[1]
        self.vector_db = faiss.IndexFlatIP(dimension)  # Index par produit scalaire (cosine similarity)

        # Normaliser les vecteurs pour la similarité cosine
        normalized_embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
        self.vector_db.add(normalized_embeddings.astype('float32'))

        print(f"Index vectoriel créé avec {len(self.chunks)} embeddings.")

    def search_similar_chunks(self, query: str, top_k: int = 5) -> List[Tuple[TextChunk, float]]:
        """
        Recherche les chunks les plus similaires à la requête
        """
        if not self.vector_db:
            raise ValueError("Index vectoriel non créé. Appelez create_embeddings() d'abord.")

        # Créer l'embedding de la requête
        query_embedding = self.embedding_model.encode([query])
        query_embedding = query_embedding / np.linalg.norm(query_embedding)

        # Rechercher les chunks similaires
        scores, indices = self.vector_db.search(query_embedding.astype('float32'), top_k)

        results = []
        for score, idx in zip(scores[0], indices[0]):
            if idx < len(self.chunks):  # Vérification de sécurité
                results.append((self.chunks[idx], float(score)))

        return results

    def ask_question(self, question: str, model: str = "gpt-3.5-turbo", top_k: int = 5) -> Dict:
        """
        Pose une question en utilisant la recherche vectorielle
        """
        if not self.vector_db:
            return {"error": "Index vectoriel non créé. Appelez create_embeddings() d'abord."}

        # Rechercher les chunks pertinents
        similar_chunks = self.search_similar_chunks(question, top_k=top_k)

        if not similar_chunks:
            return {"error": "Aucun contenu pertinent trouvé."}

        # Construire le contexte à partir des chunks les plus pertinents
        context_parts = []
        source_info = []

        for chunk, score in similar_chunks:
            context_parts.append(f"[Page {chunk.page_num}] {chunk.text}")
            source_info.append({
                "page": chunk.page_num,
                "chunk_id": chunk.chunk_id,
                "similarity_score": score,
                "text_preview": chunk.text[:100] + "..." if len(chunk.text) > 100 else chunk.text
            })

        context = "\n\n".join(context_parts)

        prompt = f"""
Vous êtes un assistant expert qui répond aux questions basées sur le contenu d'un document PDF.

Contexte pertinent extrait du document:
{context}

Question: {question}

Instructions:
- Répondez uniquement basé sur le contexte fourni
- Si l'information n'est pas suffisante, dites-le clairement
- Citez les numéros de page quand c'est pertinent
- Soyez précis et structuré dans votre réponse

Réponse:"""

        try:
            response = self.client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system",
                     "content": "Vous êtes un assistant spécialisé dans l'analyse de documents PDF avec une expertise en recherche d'information."},
                    {"role": "user", "content": prompt}
                ],
                max_tokens=800,
                temperature=0.1
            )

            answer = response.choices[0].message.content.strip()

            return {
                "answer": answer,
                "sources": source_info,
                "context_used": len(similar_chunks)
            }

        except Exception as e:
            return {"error": f"Erreur lors de la génération de la réponse: {e}"}

    def save_index(self, filepath: str):
        """
        Sauvegarde l'index vectoriel et les métadonnées
        """
        if not self.vector_db:
            raise ValueError("Aucun index à sauvegarder.")

        # Sauvegarder l'index FAISS
        faiss.write_index(self.vector_db, f"{filepath}.faiss")

        # Sauvegarder les métadonnées
        metadata = {
            "chunks": [
                {
                    "text": chunk.text,
                    "page_num": chunk.page_num,
                    "chunk_id": chunk.chunk_id
                } for chunk in self.chunks
            ],
            "pdf_path": self.pdf_path
        }

        with open(f"{filepath}.json", 'w', encoding='utf-8') as f:
            json.dump(metadata, f, ensure_ascii=False, indent=2)

        print(f"Index sauvegardé: {filepath}.faiss et {filepath}.json")

    def load_index(self, filepath: str):
        """
        Charge un index vectoriel sauvegardé
        """
        try:
            # Charger l'index FAISS
            self.vector_db = faiss.read_index(f"{filepath}.faiss")

            # Charger les métadonnées
            with open(f"{filepath}.json", 'r', encoding='utf-8') as f:
                metadata = json.load(f)

            # Reconstruire les chunks
            self.chunks = []
            for chunk_data in metadata["chunks"]:
                chunk = TextChunk(
                    text=chunk_data["text"],
                    page_num=chunk_data["page_num"],
                    chunk_id=chunk_data["chunk_id"]
                )
                self.chunks.append(chunk)

            self.pdf_path = metadata["pdf_path"]
            print(f"Index chargé: {len(self.chunks)} chunks disponibles.")

        except FileNotFoundError:
            raise FileNotFoundError(f"Fichiers d'index non trouvés: {filepath}.faiss ou {filepath}.json")


def main():
    parser = argparse.ArgumentParser(description="ChatPDF avec Base Vectorielle")
    parser.add_argument("pdf_path", help="Chemin vers le fichier PDF")
    parser.add_argument("--api-key", help="Clé API OpenAI")
    parser.add_argument("--model", default="gpt-3.5-turbo", help="Modèle OpenAI à utiliser")
    parser.add_argument("--save-index", help="Sauvegarder l'index vectoriel")
    parser.add_argument("--load-index", help="Charger un index vectoriel existant")
    parser.add_argument("--top-k", type=int, default=5, help="Nombre de chunks à récupérer")

    args = parser.parse_args()

    try:
        # Initialiser ChatPDF vectoriel
        chat_pdf = VectorChatPDF(api_key=args.api_key)

        if args.load_index:
            # Charger un index existant
            print(f"Chargement de l'index: {args.load_index}")
            chat_pdf.load_index(args.load_index)
        else:
            # Traiter un nouveau PDF
            print(f"Chargement du PDF: {args.pdf_path}")
            chat_pdf.extract_text_from_pdf(args.pdf_path)
            chat_pdf.create_embeddings()

            if args.save_index:
                chat_pdf.save_index(args.save_index)

        # Mode interactif
        print("\n=== MODE QUESTIONS-RÉPONSES VECTORIEL ===")
        print("Posez vos questions sur le document (tapez 'quit' pour quitter):")
        print("Tapez 'stats' pour voir les statistiques de l'index")

        while True:
            try:
                question = input("\n❓ Votre question: ").strip()

                if question.lower() in ['quit', 'exit', 'q']:
                    break

                if question.lower() == 'stats':
                    print(f"📊 Statistiques:")
                    print(f"   - Nombre de chunks: {len(chat_pdf.chunks)}")
                    print(f"   - Pages: {max(c.page_num for c in chat_pdf.chunks) if chat_pdf.chunks else 0}")
                    print(f"   - Document: {os.path.basename(chat_pdf.pdf_path)}")
                    continue

                if not question:
                    continue

                print("🔍 Recherche vectorielle en cours...")
                result = chat_pdf.ask_question(question, model=args.model, top_k=args.top_k)

                if "error" in result:
                    print(f"❌ {result['error']}")
                else:
                    print(f"\n💡 Réponse:\n{result['answer']}")
                    print(f"\n📚 Sources utilisées ({result['context_used']} chunks):")
                    for i, source in enumerate(result['sources'][:3], 1):
                        print(f"   {i}. Page {source['page']} (score: {source['similarity_score']:.3f})")
                        print(f"      {source['text_preview']}")

            except KeyboardInterrupt:
                break
            except Exception as e:
                print(f"Erreur: {e}")

        print("\nAu revoir!")

    except Exception as e:
        print(f"Erreur: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

Lancer le programme en ligne de commande

lancement du script en mode normal, cela va créer un fichier binaire mon_index.faiss qui est la base vectorielle

python3 avecBaseVectorielle.py document.pdf --save-index mon_index

La même chose en plus léger

python3 avecBaseVectorielle.py document.pdf --fast --save-index mon_index

La même chose avec un LLM personnalisé

python3 avecBaseVectorielle.py document.pdf --embedding-model "paraphrase-MiniLM-L3-v2"

La même chose en chargeant une base vectorielle déjà calculée

python3 avecBaseVectorielle.py document.pdf --load-index mon_index

Lors du lancement du script en mode normal, une base vectorielle va être créée. Vous allez poser une question qui va requêter sur cette base de données. Par exemple, « Combien de jours de formation y a t il dans cette formation? », la requête ne va pas partir vers chatGPT directement mais va extraire un contexte de la base vectorielle, qui va être mis dans le prompt vers chatGPT.

Sans le vouloir vraiment, vous venez de faire du RAG, Retrieval Augmented Generation, autrement dit intelligence générative augmentée par une extraction de données.

Modèle d’embedding c’est quoi?

Un modèle d’embedding est un système qui transforme du texte (mots, phrases, paragraphes) en vecteurs numériques de dimension fixe. Ces vecteurs capturent le sens sémantique du texte de manière à ce que des textes similaires aient des représentations vectorielles proches dans l’espace mathématique.

Comment ça fonctionne

Les modèles d’embedding utilisent généralement des réseaux de neurones entraînés sur de grandes quantités de texte pour apprendre les relations sémantiques. Par exemple, les mots « chien » et « animal » auront des vecteurs plus proches que « chien » et « voiture ».

Utilité pratique

Ces représentations vectorielles permettent de :

  • Mesurer la similarité sémantique entre textes
  • Effectuer des recherches par similarité
  • Regrouper des documents par thème
  • Alimenter des systèmes de question-réponse

Le modèle « paraphrase-MiniLM-L3-v2 » est un modèle d’embedding compact et efficace développé par Sentence Transformers. Il est particulièrement adapté pour :

  • Identifier des paraphrases (textes exprimant la même idée différemment)
  • Créer des bases de données vectorielles pour la recherche sémantique

Le mode –fast

C’est u mode d’optimisation qui accélère le traitement en faisant certains compromis.

Le mode fast améliore généralement la vitesse au détriment de :

  • La précision de la recherche sémantique
  • La qualité des réponses
  • La finesse de l’analyse du document

Introduction au machine learning avec un modèle de prédiction d’abandon de souscription

vous avez entendu parler de machine learning partout, mais avez vous vu comment cela fonctionnait? Nous allons voir avec un exemple comment ça marcce à quoi ça sert et vous comprendrez à la fin pourquoi il est très intéressant d’en faire dans les entreprises.

Le contexte : avec des données d’abandon de souscription à un forfait mobile (churn), nous allons établir un modèle mathématique pour prédire les futur churn. L’intérêt de faire confiance au machine learning est qu’il y a un grand nombre de paramètres qui influence le fait qu’un client part à la concurrence, il est quasi impossible de faire le diagnostic à la main pour savoir si le client va partir ou non, plus exactement il y a 18 paramètres.

Le principe du machine learning est de prendre ces données, les travailler et les faire passer dans un algorithme qui va générer un modèle mathématique qui servira aux futures données, pour prédire si le client va churner ou non. En gros se servir du passé pour prédire le futur.

Le dataset à télécharger

Allez sur Kaggle et téléchargez le dataset (il vous faudra créer un compte). On va explorer ce dataset, mais ce qu’il faut savoir c’est que dans ce dataset, 30% des lignes concernent les clients ayant churné, et 70% sont des clients qui n’ont pas churné. On n’a pas une distribution 50/50. En machine learning, entrainer sur ce dataset va produire un modèle mathématique qui va prédire de façon biaisé, quelquesoit l’entrée donnée, les probabilités que la prédiction soit en faveur d’un non churn sera anormalement haute.

Pour éviter ce déséquilibre dans les données (et ce déséquilibre importe car le type d’issue d’une prédiction est unique : le churn), on va créer artificiellement (oui artificiellement) des données pour compenser ce déséquilibre, donc on va générer des données ayant pour issue le churn, afin d’avoir un rapport 50/50 (churn/non-churn)

Installation des paquets

Pour faire du traitement de machine learning, il vous faudra installer les paquets suivants : numpy, pandas, matplotlib, seaborn,sklearn

Note: lors de l’installation de sklearn, vous aurez une erreur de paquet déprécié, en fait dans l’import de bibliothèque, c’est bien sklearn qu’il faut importer, mais lors que vous installez le paquet avec pip, c’est scikit-learn qu’il faut installer ! (Lien pour en savoir plus)

Utiliser l’éditeur de code Jupyter Notebook

Jupyter noteboopk est un éditeur de code progressif, très adapté à des non codeur pur et dur. Pour les datascientistes, c’est très pertinent car ils peuvent voir comment évolue leur traitement de données. Et pour tout le monde, c’est très visuel lors de l’apprentissage du machine learning, même pour un codeur pur et dur comme moi.

Vous pouvez décomposer le code en étape et refaire exécuter une étape à tout moment, pratique si vous vous trompez dans votre code et qu’il y a une erreur.

Pour installer Jupyter Notebook, faites la commande

# installation de jupyterlab
pip install jupyterlab
pour les macs
pip3 install jupyterlab


pour démarrer
jupyter lab



pip install notebook
ou pour les mac
pip3 install notebook

puis démarrer en faisant :
jupyter notebook

Note sur Jupyterlab : jupyterlab est la version moderne de jupyter notebook, et possède un système d eplugin plus simple à utiliser et un terminal et un éditeur de texte.

Commençons à explorer les caractéristiques de notre jeu de données

Notre objectif est d’entrainer un modèle sur le jeu de données, pour ensuite prédire la probabilité d’un client à passer à la concurrencer (churner). customerChurn.csv est le fichier de chez Kaggle.

import pandas as pd

df = pd.read_csv('customerChurn.csv')
df.head()

La commande df.head() affiche les 5 premières ligne du dataframe. Le dataframe est la version de Pandas d’un fichier Excel en gros.

Nous avons un fichier avec des colonnes, qu’on appelle de feature, puisque chaque colonne est une caractéristique d’un client. Pour voir le nom et le type de la donnée stockée dans chaque colonne ainsi que le nombre de colonnes, on fait

df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7043 entries, 0 to 7042
Data columns (total 21 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   customerID        7043 non-null   object 
 1   gender            7043 non-null   object 
 2   SeniorCitizen     7043 non-null   int64  
 3   Partner           7043 non-null   object 
 4   Dependents        7043 non-null   object 
 5   tenure            7043 non-null   int64  
 6   PhoneService      7043 non-null   object 
 7   MultipleLines     7043 non-null   object 
 8   InternetService   7043 non-null   object 
 9   OnlineSecurity    7043 non-null   object 
 10  OnlineBackup      7043 non-null   object 
 11  DeviceProtection  7043 non-null   object 
 12  TechSupport       7043 non-null   object 
 13  StreamingTV       7043 non-null   object 
 14  StreamingMovies   7043 non-null   object 
 15  Contract          7043 non-null   object 
 16  PaperlessBilling  7043 non-null   object 
 17  PaymentMethod     7043 non-null   object 
 18  MonthlyCharges    7043 non-null   float64
 19  TotalCharges      7043 non-null   object 
 20  Churn             7043 non-null   object 
dtypes: float64(1), int64(2), object(18)
memory usage: 1.1+ MB

Et pour savoir combien il y a d’enregistrements on fait:

df["Churn"].value_counts()

Churn
No     5174
Yes    1869
Name: count, dtype: int64

donc on a 19 colonnes utile et une colonne inutile d’un point de vue machine learning, c’est la colonne customerID, elle ne comporte aucune information métier. La dernière colonne appelée Churn, prend deux valeuer, elle indique si le client est parti chez le concurrent ou non. Elle prend les valeur No ou Yes. Pour afficher le compte on fait:

df["Churn"].values_count()

Churn
No     5174
Yes    1869
Name: count, dtype: int64

il y a 1869 personnes partie, on note ici que le nombre de Yes et de No est différent dans un rapport 1 à 3 environ. Cela on le verra plus tard aura son importance dans l'entrainement du modèle. Il y a inégalité dans les 2 camps, cette disparité va avoir une incidence sur la qualité du modèle entrainé. En effet, le modèle résultant va être biaisé avec le No, car ce dernier est majoritaire. On devra équilibrer artificiellement ce dataset pour avoir un bon modèle de prédiction.

Analyse exploratoire des données

Maintenant explorons les relations entre les différentes catégorie et la colonne Churn

import matplotlib.pyplot as plt
import seaborn as sns 
import numpy as np

cols = ['gender','SeniorCitizen',"Partner","Dependents"]
numerical = cols

plt.figure(figsize=(20,4))

for i, col in enumerate(numerical):
    ax = plt.subplot(1, len(numerical), i+1)
    unique_vals = df[col].nunique()
    sns.countplot(x=col, hue=col, data=df, legend=False, 
                  palette=sns.color_palette("husl", df[col].nunique()))
    #sns.countplot(x=str(col), data=df, palette=sns.color_palette("husl", unique_vals))
    ax.set_title(f"{col}")

L’idée est ici de graphiquement regarder pour chaque colonne la distribution des valeurs. Ici on voit la colonne Genre, SeniorCitizen, Partner, Dependents, des varaible catégorielle relative à la démographie.

On voit qu’il y a autant d’homme que de femmes, mais que les seniors sont minoritaires, qu’il y a autant de célibataires que de mariés, et il y a 1 tier de la population qui est dépendantes.

Maintenant regardons la relation entre le MonthlyCharges et le Churn, on peut se dire que plus le forfait est élevé plus les gens ont tendance à partir

sns.boxplot(x='Churn', y='MonthlyCharges', hue='Churn', data=df, palette='Set2', dodge=False, legend=False)

Effectivement c’est ce que nous constatons, les Yes sont entre 50 et 100 dollars / mois.

Pour comprendre le boxplot je vous joins ce petit schéma pris sur datatab.net

Maintenant analysons la relation entre quelques autres variable catégorielle avec le Churn

cols = ['InternetService',"TechSupport","OnlineBackup","Contract"]

plt.figure(figsize=(14,4))

for i, col in enumerate(cols):
    ax = plt.subplot(1, len(cols), i+1)
    sns.countplot(x ="Churn", hue = str(col), data = df)
    ax.set_title(f"{col}")

Cette fois-ci les catégories ont plusieurs valeurs (3). On peut voir que pour InternetService, donc le forfait box de l’abonnement, les churners n’ont pas de forfait box ou très peu, idem pour le service ADSL, par contre ils sont nombreux à être sur fibre optique. Concernant les non churners, ils sont plus équitablement distribué.

InternetService : Il ressort clairement du graphique ci-dessus que les clients utilisant une connexion Internet par fibre optique résilient leur abonnement plus souvent que les autres. Cela pourrait s’expliquer par le fait que la fibre est un service plus coûteux, ou bien que ce fournisseur n’offre pas une bonne couverture.

TechSupport : De nombreux utilisateurs ayant résilié leur abonnement ne s’étaient pas inscrits au service d’assistance technique. Cela pourrait signifier qu’ils n’ont reçu aucune aide pour résoudre leurs problèmes techniques et ont décidé d’arrêter d’utiliser le service.

OnlineBackup : Beaucoup de clients ayant résilié leur abonnement ne s’étaient pas inscrits au service de sauvegarde en ligne pour le stockage de données.

Contract : Les utilisateurs qui ont résilié leur abonnement étaient presque toujours sous contrat mensuel. Cela semble logique, car ces clients paient au mois et peuvent facilement annuler leur abonnement avant le prochain cycle de paiement.

Même sans construire un modèle d’apprentissage automatique sophistiqué, une simple analyse basée sur les données comme celle-ci peut aider les organisations à comprendre pourquoi elles perdent des clients et ce qu’elles peuvent faire pour y remédier.

Par exemple, si l’entreprise se rend compte que la majorité des clients qui résilient leur abonnement ne se sont pas inscrits au service d’assistance technique, elle pourrait inclure ce service gratuitement dans certaines de ses futures offres afin d’éviter que d’autres clients ne partent.

Préparation des données pour la modélisation

Rappel
df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7043 entries, 0 to 7042
Data columns (total 21 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   customerID        7043 non-null   object 
 1   gender            7043 non-null   object 
 2   SeniorCitizen     7043 non-null   int64  
 3   Partner           7043 non-null   object 
 4   Dependents        7043 non-null   object 
 5   tenure            7043 non-null   int64  
 6   PhoneService      7043 non-null   object 
 7   MultipleLines     7043 non-null   object 
 8   InternetService   7043 non-null   object 
 9   OnlineSecurity    7043 non-null   object 
 10  OnlineBackup      7043 non-null   object 
 11  DeviceProtection  7043 non-null   object 
 12  TechSupport       7043 non-null   object 
 13  StreamingTV       7043 non-null   object 
 14  StreamingMovies   7043 non-null   object 
 15  Contract          7043 non-null   object 
 16  PaperlessBilling  7043 non-null   object 
 17  PaymentMethod     7043 non-null   object 
 18  MonthlyCharges    7043 non-null   float64
 19  TotalCharges      7043 non-null   object 
 20  Churn             7043 non-null   object 
dtypes: float64(1), int64(2), object(18)
memory usage: 1.1+ MB

Beaucoup de données sont de type Object, des valeurs non numériques, « TotalCharges » qui est un montant est un objet par exemple, on va le convertir en valeur numérique

df['TotalCharges'] = df['TotalCharges'].apply(lambda x: pd.to_numeric(x, errors='coerce')).dropna()

Encodage des variables catégorielles

Les valeurs catégorielles (non numérique) doivent être convertie en valeur numérique pour être modélisée, des colonnes avec pour valeur Yes/No, on peut rendre le Yes en 1 et le No en 0. Avec Sckitlearn on va pouvoir transformer ces valeurs en nombres. Si on regarde le dataframe sans les valeur numériques

cat_features = df.drop(['customerID','TotalCharges','MonthlyCharges','SeniorCitizen','tenure'],axis=1)

cat_features.head()

La commande ci-dessus nous permet de voir les valeurs catégorielles des features. Transformaons les en valuer numérique avec ScikitLearn

from sklearn import preprocessing

le = preprocessing.LabelEncoder()
df_cat = cat_features.apply(le.fit_transform)
df_cat.head(1)

Scikitlearn convertit automatiquement les valeurs catégorielle en valeur numérique. Il n’y a pas de règle particulière, la valeur 0 correspond à la première valeur catégorielle ordonnées dans l’ordre alphabétique. No est avant Yes donc No vaut zéro et Yes vaut 1.

Regardons maintenant les types

df_cat.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7043 entries, 0 to 7042
Data columns (total 16 columns):
 #   Column            Non-Null Count  Dtype
---  ------            --------------  -----
 0   gender            7043 non-null   int64
 1   Partner           7043 non-null   int64
 2   Dependents        7043 non-null   int64
 3   PhoneService      7043 non-null   int64
 4   MultipleLines     7043 non-null   int64
 5   InternetService   7043 non-null   int64
 6   OnlineSecurity    7043 non-null   int64
 7   OnlineBackup      7043 non-null   int64
 8   DeviceProtection  7043 non-null   int64
 9   TechSupport       7043 non-null   int64
 10  StreamingTV       7043 non-null   int64
 11  StreamingMovies   7043 non-null   int64
 12  Contract          7043 non-null   int64
 13  PaperlessBilling  7043 non-null   int64
 14  PaymentMethod     7043 non-null   int64
 15  Churn             7043 non-null   int64
dtypes: int64(16)
memory usage: 880.5 KB

On va maintenant fusionner avec le dataframe qui était déjà numérique

num_features = df[['customerID','TotalCharges','MonthlyCharges','SeniorCitizen','tenure']]
finaldf = pd.merge(num_features, df_cat, left_index=True, right_index=True)

Oversampling

On avait dit que le Churn Yes était minoritaire dans les données, alors qu’il devait être à 50/50 avec le churn No.

Comme mentionné précédemment, le jeu de données est déséquilibré, ce qui signifie que la majorité des valeurs de la variable cible appartiennent à une seule classe. La plupart des clients du jeu de données n’ont pas résilié leur abonnement — seulement 27 % l’ont fait.

Ce problème de déséquilibre des classes peut entraîner de mauvaises performances d’un modèle d’apprentissage automatique. Certains algorithmes, lorsqu’ils sont entraînés sur un jeu de données déséquilibré, finissent toujours par prédire la classe majoritaire. Dans notre cas, par exemple, le modèle pourrait prédire qu’aucun client n’a résilié. Bien qu’un tel modèle semble très précis (il serait correct dans 73 % des cas), il ne nous est d’aucune utilité puisqu’il prédit toujours le même résultat.

Il existe différentes techniques pour résoudre le problème de déséquilibre des classes en apprentissage automatique. Dans ce tutoriel, nous allons utiliser une technique appelée suréchantillonnage (oversampling). Ce processus consiste à sélectionner aléatoirement des échantillons de la classe minoritaire et à les ajouter au jeu de données d’entraînement. Nous allons suréchantillonner la classe minoritaire jusqu’à ce que le nombre d’exemples soit égal à celui de la classe majoritaire.

Avant de procéder au suréchantillonnage, effectuons une séparation entre le jeu d’entraînement et le jeu de test. Nous appliquerons le suréchantillonnage uniquement sur le jeu d’entraînement, car le jeu de test doit rester représentatif de la population réelle.

On va constituer un dataset pour le training du modèle, et un dataset de test qu’on va soumettre qu modèle entrainé.

from sklearn.model_selection import train_test_split

finaldf = finaldf.dropna()
finaldf = finaldf.drop(['customerID'],axis=1)

X = finaldf.drop(['Churn'],axis=1)
y = finaldf['Churn']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)

Chaque dataset est en deux parties X (majuscule) et y (minuscule), y représente la dernière colonne qui est le churn. Ainsi Dans le dataset de training, on a X_train et y_train. Et pour le dataset de test on a X_test et y_test.

#y_train
X_test.info()

<class 'pandas.core.frame.DataFrame'>
Index: 2321 entries, 2481 to 5585
Data columns (total 19 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   TotalCharges      2321 non-null   float64
 1   MonthlyCharges    2321 non-null   float64
 2   SeniorCitizen     2321 non-null   int64  
 3   tenure            2321 non-null   int64  
 4   gender            2321 non-null   int64  
 5   Partner           2321 non-null   int64  
 6   Dependents        2321 non-null   int64  
 7   PhoneService      2321 non-null   int64  
 8   MultipleLines     2321 non-null   int64  
 9   InternetService   2321 non-null   int64  
 10  OnlineSecurity    2321 non-null   int64  
 11  OnlineBackup      2321 non-null   int64  
 12  DeviceProtection  2321 non-null   int64  
 13  TechSupport       2321 non-null   int64  
 14  StreamingTV       2321 non-null   int64  
 15  StreamingMovies   2321 non-null   int64  
 16  Contract          2321 non-null   int64  
 17  PaperlessBilling  2321 non-null   int64  
 18  PaymentMethod     2321 non-null   int64  
dtypes: float64(2), int64(17)
memory usage: 362.7 KB

C’est avec la librairie imblearn qu’on va construire le suréchantillon sur les données de training

from imblearn.over_sampling import SMOTE

oversample = SMOTE(k_neighbors=5)
X_smote, y_smote = oversample.fit_resample(X_train, y_train)
X_train, y_train = X_smote, y_smote

Ensuite consmptez le nombre de rangées : 

y_train.value_counts()
Churn
0    3452
1    3452
Name: count, dtype: int64

On est à 50/50

Construction du modèle de prédiction du churn

Maintenant la partie vraiment importante ! On a passé beaucoup de temps pour nettoyer, changé la valeur catégorielle en valeur numérique, rééquilibré les population, maintenant on va entrainer le modèle !

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(random_state=46)
rf.fit(X_train,y_train)

Evaluation du modèle

On va évaluer l aprécision du modèle avec les données de test (dont on connait l’issue car ce sont les données d’origine)

#customer churn model evaluation
from sklearn.metrics import accuracy_score

preds = rf.predict(X_test)
print(accuracy_score(preds,y_test))


0.7733735458853942

On a une précision de 77%

Sauvegarder le modèle

On en va pas à chaque fois réentrainer le modèle quand on va faire une prédiction, donc on va le sauvegarder pour pouvoir le réutiliser.

#save the model
import joblib
# After training your model  (format Pickle)
joblib.dump(rf, 'random_forest_model.pkl')


Sauvergarder les données de test et de training

#save training and testing data to file
import pickle

# Save X data
with open('X_train.pkl', 'wb') as f:
    pickle.dump(X_train, f)
with open('X_test.pkl', 'wb') as f:
    pickle.dump(X_test, f)

# Save y data
with open('y_train.pkl', 'wb') as f:
    pickle.dump(y_train, f)
with open('y_test.pkl', 'wb') as f:
    pickle.dump(y_test, f)

Chargement du modèle et des données sauvegardées

On va ouvrir un autre fichier Notebook vierge et on va juste charge le modèle et les données pour passer au crible. En effet le modèle que vous avez calculé va être réutilisé, et il n’est pas question de le calculer à chaque fois que vous avez besoin de scorer la probabilité d’un client de patir.

Chargement du modèle

import joblib
#load the model
loaded_rf = joblib.load('random_forest_model.pkl')
print(loaded_rf)

# Now you can use the loaded model to make predictions
#prediction = loaded_rf.predict(new_customer)
#print(prediction)

Chargement des données

#load the datas
import pickle

with open('X_test.pkl', 'rb') as f:
    X_test = pickle.load(f)
with open('y_test.pkl', 'rb') as f:
    y_test = pickle.load(f)

X_test.head()

On rejoue le test, X_test contient les clients, et on passe en argument de la fonction predict qui va nous retourner la variable prediction, qui est une Serie, contenant des 0 et 1, 0 correspondant à « No » et 1 à « Yes ».

# Now you can use the loaded model to make predictions
from sklearn.metrics import accuracy_score
prediction = loaded_rf.predict(X_test)
#la série qui contient les outcome (churn si 1 , 0 si no churn)
print(prediction)

[0 0 1 ... 0 1 1]

Ce qu'on voit c'est la colonne churn

Test avec un sous ensemble

Ici je simule le fait que j’ai un nouveau client à scorer, c’est donc une ligne (un seul client), je passe cette ligne en argument de la fonction predict, qui va nous retourner un Serie avec un seul élément (on n’a scoré qu’un client), ici on obtient un « Yes ».

# test on row
# subset = X_test[X_test['TotalCharges']<100]
# extract a line from X_test
oneline = X_test.loc[[6125]]
#convert to dataframe
#oneline = oneline.to_frame()


import pandas as pd
isinstance(oneline,pd.DataFrame) # test if it's DataFrame
print(oneline)
oneline.head()


prediction = loaded_rf.predict(oneline)
print(prediction)

[1] //  Churn

Retour en haut