Cheatsheets — vue longue
retour à la listeToutes les pages concaténées sur un seul document, pour un Ctrl-F direct.
Gradio
page dédiée →Interfaces de démo pour modèles ML. Contrairement à streamlit, le modèle est événementiel : on déclare des composants et on branche des fonctions dessus, sans réexécution complète du script.
uv add gradio
python app.py # sert sur http://127.0.0.1:7860
Le plus court chemin
import gradio as gr
def check(site: str, produit: str) -> str:
return f"{produit} trouvé sur {site} en 3 clics"
gr.Interface(
fn=check,
inputs=["text", "text"],
outputs="text",
title="Findability checker",
).launch()
* Running on local URL: http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.
share=True ouvre un tunnel public valable 72 h — pratique pour montrer une démo à un
client sans rien déployer.
Blocks, pour tout le reste
Interface couvre le cas « une fonction, des entrées, des sorties ». Dès qu'il faut de la
mise en page ou plusieurs interactions, passer à Blocks.
with gr.Blocks(title="Findability") as demo:
gr.Markdown("## Test d'accessibilité produit")
with gr.Row():
site = gr.Textbox(label="Site", value="versace.com")
produit = gr.Textbox(label="Produit")
with gr.Row():
budget = gr.Slider(10, 200, value=60, step=10, label="Budget d'étapes")
mode = gr.Radio(["fast", "default"], value="default", label="Mode")
run = gr.Button("Lancer", variant="primary")
out = gr.JSON(label="Résultat")
gallery = gr.Gallery(label="Captures", columns=3)
run.click(fn=check, inputs=[site, produit, budget, mode], outputs=[out, gallery])
demo.launch()
inputs et outputs sont des listes de composants, et la fonction doit renvoyer autant
de valeurs qu'il y a de sorties, dans le même ordre.
Composants courants
| Composant | Usage |
|---|---|
gr.Textbox(lines=5) |
texte court ou long |
gr.Number, gr.Slider |
valeurs numériques |
gr.Dropdown(choices=[...], multiselect=True) |
listes |
gr.Checkbox, gr.Radio |
booléens et choix exclusifs |
gr.Image(type="pil") |
image en entrée ou sortie |
gr.File, gr.Audio, gr.Video |
fichiers |
gr.Dataframe |
tableau éditable |
gr.JSON, gr.Label |
sorties structurées |
gr.Plot |
figure matplotlib ou plotly |
Événements
run.click(fn, inputs=..., outputs=...)
site.change(fn, inputs=site, outputs=out)
site.submit(fn, ...) # touche Entrée
demo.load(fn, ...) # au chargement de la page
Enchaîner des étapes en gardant l'interface réactive :
run.click(prepare, [site], [status]).then(execute, [site], [out])
Streaming
def stream(prompt):
partial = ""
for chunk in llm.stream(prompt):
partial += chunk
yield partial # un yield = une mise à jour de l'UI
gr.Interface(fn=stream, inputs="text", outputs="text").launch()
Un générateur suffit : chaque yield pousse une nouvelle valeur au composant de sortie.
Chat
def respond(message, history):
return f"Reçu : {message}"
gr.ChatInterface(respond, type="messages").launch()
history arrive en liste de dicts {"role", "content"} avec type="messages". C'est le
format aligné sur les API LLM, à préférer à l'ancien format en tuples.
État par session
with gr.Blocks() as demo:
state = gr.State([]) # propre à chaque visiteur
def add(item, current):
current = current + [item]
return current, current
box = gr.Textbox()
out = gr.JSON()
box.submit(add, [box, state], [state, out])
gr.State n'est jamais rendu, il transporte des données Python entre événements.
Files d'attente et progression
def long_task(x, progress=gr.Progress()):
for i in progress.tqdm(range(10), desc="Sessions"):
run(i)
return "ok"
demo.queue(max_size=20).launch()
queue() est nécessaire dès que plusieurs personnes utilisent la démo en même temps.
Déployer sur Hugging Face Spaces
Un repo avec app.py, requirements.txt, et un README.md à en-tête :
---
title: Findability Checker
sdk: gradio
sdk_version: "5.0.0"
app_file: app.py
---
Les secrets se règlent dans les paramètres du Space et se lisent par os.environ.
Gradio ou Streamlit
Gradio pour exposer un modèle ou une fonction, surtout avec image, audio ou chat, et pour publier sur Spaces. Streamlit pour un tableau de bord de données avec des filtres. Les deux sont des outils de démo : au-delà, une vraie application.
See also
Keras & TensorFlow
page dédiée →Keras 3 fonctionne au-dessus de TensorFlow, JAX ou PyTorch. Le backend se choisit avant l'import.
import os
os.environ["KERAS_BACKEND"] = "tensorflow" # ou "jax", "torch"
import keras
Construire un modèle
from keras import layers
model = keras.Sequential([
keras.Input(shape=(128,)),
layers.Dense(256, activation="relu"),
layers.Dropout(0.2),
layers.Dense(10, activation="softmax"),
])
model.summary()
Model: "sequential"
┌─────────────────────────────────┬────────────────────────┬───────────────┐
│ Layer (type) │ Output Shape │ Param # │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense) │ (None, 256) │ 33,024 │
│ dropout (Dropout) │ (None, 256) │ 0 │
│ dense_1 (Dense) │ (None, 10) │ 2,570 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 35,594 (139.04 KB)
summary() est le premier outil de debug : une forme de sortie inattendue s'y voit
immédiatement.
API fonctionnelle, dès qu'il y a plusieurs entrées ou une branche :
inp = keras.Input(shape=(128,))
x = layers.Dense(256, activation="relu")(inp)
x = layers.Dropout(0.2)(x)
out = layers.Dense(10, activation="softmax")(x)
model = keras.Model(inp, out)
Compiler et entraîner
model.compile(
optimizer=keras.optimizers.AdamW(learning_rate=3e-4),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
history = model.fit(
X_train, y_train,
validation_data=(X_val, y_val),
epochs=20,
batch_size=32,
callbacks=[
keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True),
keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=2),
keras.callbacks.ModelCheckpoint("best.keras", save_best_only=True),
],
)
Epoch 1/20
188/188 ━━━━━━━━━━━━━━━━━━━━ 2s 7ms/step - accuracy: 0.4123 - loss: 1.8342 - val_accuracy: 0.6210
Epoch 2/20
188/188 ━━━━━━━━━━━━━━━━━━━━ 1s 6ms/step - accuracy: 0.6890 - loss: 1.0021 - val_accuracy: 0.7455
Choisir la bonne perte
| Cible | Perte | Dernière couche |
|---|---|---|
Entiers de classe (0, 1, 2) |
sparse_categorical_crossentropy |
Dense(n, softmax) |
| One-hot | categorical_crossentropy |
Dense(n, softmax) |
| Binaire | binary_crossentropy |
Dense(1, sigmoid) |
| Régression | mse / mae |
Dense(1) sans activation |
Confondre sparse_ et la version one-hot est l'erreur la plus fréquente : elle produit un
message sur les formes plutôt qu'un mauvais score, donc elle se repère vite.
Évaluer et prédire
loss, acc = model.evaluate(X_test, y_test, verbose=0)
proba = model.predict(X_test)
classes = proba.argmax(axis=1)
Courbes d'apprentissage
import matplotlib.pyplot as plt
plt.plot(history.history["loss"], label="train")
plt.plot(history.history["val_loss"], label="val")
plt.legend(); plt.xlabel("epoch"); plt.ylabel("loss")
La val_loss qui remonte pendant que la loss descend est la signature du surapprentissage.
EarlyStopping(restore_best_weights=True) récupère automatiquement le meilleur état.
Sauvegarder
model.save("model.keras") # format natif, tout inclus
model = keras.models.load_model("model.keras")
model.save_weights("poids.weights.h5") # poids seuls
model.load_weights("poids.weights.h5")
Pipeline de données
import tensorflow as tf
ds = (
tf.data.Dataset.from_tensor_slices((X, y))
.shuffle(10_000)
.batch(32)
.prefetch(tf.data.AUTOTUNE)
)
model.fit(ds, epochs=10)
prefetch(AUTOTUNE) recouvre la préparation des données et le calcul — souvent le gain le
plus simple quand le GPU attend.
Transfer learning
base = keras.applications.EfficientNetB0(include_top=False, weights="imagenet", pooling="avg")
base.trainable = False # gel
model = keras.Sequential([base, layers.Dense(5, activation="softmax")])
model.compile(optimizer=keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy")
model.fit(train_ds, epochs=5)
base.trainable = True # dégel pour le fine-tuning
model.compile(optimizer=keras.optimizers.Adam(1e-5), loss="sparse_categorical_crossentropy")
model.fit(train_ds, epochs=5)
Le second compile avec un learning rate cent fois plus petit est obligatoire : dégeler
sans le baisser détruit les poids pré-entraînés dès le premier batch.
GPU
print(tf.config.list_physical_devices("GPU"))
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]
Liste vide alors qu'un GPU existe : c'est presque toujours une incompatibilité entre les versions de TensorFlow, CUDA et cuDNN.
See also
LangChain
page dédiée →API en mouvement rapide. Ce qui suit couvre le cœur stable — LCEL, modèles de chat, prompts, parsers, retrievers. Vérifier contre docs.langchain.com avant de s'appuyer sur un détail.
Installation
uv add langchain langchain-openai langchain-anthropic langchain-community
Les intégrations sont dans des paquets séparés depuis la 0.1 : le cœur ne dépend d'aucun fournisseur.
Appeler un modèle
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0, max_tokens=1024)
resp = llm.invoke([
SystemMessage("Tu réponds en une phrase."),
HumanMessage("Qu'est-ce qu'un agent computer use ?"),
])
print(resp.content)
Un agent computer use est un système qui perçoit l'écran et agit via souris et clavier
pour accomplir des tâches à la place d'un utilisateur.
Quatre méthodes sur tout composant : invoke, batch, stream, et leurs variantes
asynchrones ainvoke, abatch, astream.
LCEL, l'opérateur |
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_messages([
("system", "Tu es un analyste e-commerce concis."),
("human", "Le produit {produit} est-il trouvable sur {site} ?"),
])
chain = prompt | llm | StrOutputParser()
print(chain.invoke({"produit": "Najim 100 ml", "site": "versace.com"}))
Chaque maillon reçoit la sortie du précédent. StrOutputParser extrait .content du
message, sinon on manipule un objet AIMessage.
Sortie structurée
from pydantic import BaseModel, Field
class Findability(BaseModel):
findable: bool = Field(description="Produit atteignable par la navigation")
clicks: int = Field(description="Nombre de clics jusqu'à la fiche produit")
path: list[str] = Field(default_factory=list)
structured = llm.with_structured_output(Findability)
result = structured.invoke("Sur versace.com, combien de clics pour Najim 100 ml ?")
print(result.clicks, result.findable)
3 True
with_structured_output s'appuie sur le function calling natif du fournisseur. C'est plus
fiable qu'un JsonOutputParser derrière un prompt qui supplie de rendre du JSON.
Tools et agents
from langchain_core.tools import tool
@tool
def get_price(sku: str) -> float:
"""Retourne le prix TTC d'un SKU."""
return 129.0
llm_with_tools = llm.bind_tools([get_price])
msg = llm_with_tools.invoke("Quel est le prix du SKU AB-12 ?")
print(msg.tool_calls)
[{'name': 'get_price', 'args': {'sku': 'AB-12'}, 'id': 'toolu_01X…', 'type': 'tool_call'}]
Le décorateur @tool dérive le schéma des annotations de type et la description de la
docstring — donc la docstring est un élément fonctionnel, pas un commentaire.
bind_tools ne fait que proposer l'appel : c'est à la boucle d'exécuter l'outil et de
renvoyer un ToolMessage. Pour la boucle complète, passer à langgraph.
RAG minimal
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
docs = PyPDFLoader("manuel.pdf").load()
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
chunks = splitter.split_documents(docs)
store = FAISS.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"))
retriever = store.as_retriever(search_kwargs={"k": 4})
RecursiveCharacterTextSplitter coupe d'abord sur les paragraphes, puis les phrases, puis
les mots. C'est le défaut raisonnable ; chunk_overlap évite de trancher une idée en deux.
from langchain_core.runnables import RunnablePassthrough
template = ChatPromptTemplate.from_template(
"Réponds uniquement à partir du contexte.\n\nContexte:\n{context}\n\nQuestion: {question}"
)
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs)
rag = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| template
| llm
| StrOutputParser()
)
print(rag.invoke("Quelle est la garantie ?"))
RunnablePassthrough laisse passer l'entrée telle quelle pendant que l'autre branche va
chercher les documents. Les deux branches du dict s'exécutent en parallèle.
Streaming
for chunk in chain.stream({"produit": "Najim", "site": "versace.com"}):
print(chunk, end="", flush=True)
Observabilité
export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY=ls__...
export LANGCHAIN_PROJECT=mon-projet
Toutes les exécutions apparaissent alors dans LangSmith, avec les prompts réels, les latences et les tokens. C'est le principal argument pour rester dans l'écosystème.
Quand ne pas l'utiliser
Pour un simple appel à un modèle avec un prompt, le SDK du fournisseur suffit et se debugge mieux. LangChain se justifie quand on veut changer de fournisseur sans réécrire, brancher des retrievers existants, ou tracer dans LangSmith.
See also
NumPy
page dédiée →Créer
import numpy as np
np.array([[1, 2], [3, 4]])
np.zeros((2, 3))
np.ones((2, 3), dtype=np.float32)
np.full((2, 2), 7)
np.eye(3)
np.arange(0, 10, 2) # 0 2 4 6 8, borne exclue
np.linspace(0, 1, 5) # 5 points, bornes incluses
rng = np.random.default_rng(42) # API moderne, préférée à np.random.seed
rng.normal(0, 1, size=(2, 3))
rng.integers(0, 10, size=5)
rng.choice([1, 2, 3], size=4, replace=True)
a = np.arange(6).reshape(2, 3)
print(a)
print(a.shape, a.dtype, a.ndim, a.size)
[[0 1 2]
[3 4 5]]
(2, 3) int64 2 6
Formes
a.reshape(3, 2)
a.reshape(-1) # aplatit, -1 = "déduis la dimension"
a.T # transposée
a[:, np.newaxis] # ajoute un axe -> (2, 1, 3)
np.squeeze(a) # retire les axes de taille 1
np.concatenate([a, a], axis=0)
np.stack([a, a]) # crée un nouvel axe
reshape renvoie une vue quand c'est possible : modifier le résultat modifie
l'original. .copy() pour couper le lien.
Indexer
a[0, 1]
a[:, 1] # toute la colonne 1
a[1:, :2]
a[a > 2] # masque booléen -> tableau 1D
a[[0, 1], [2, 0]] # fancy indexing -> éléments (0,2) et (1,0)
a = np.arange(6).reshape(2, 3)
print(a > 2)
print(a[a > 2])
[[False False False]
[ True True True]]
[3 4 5]
Le masque booléen est le pattern à avoir : np.where(cond, x, y) pour choisir élément par
élément, a[cond] = valeur pour affecter.
Agréger
a.sum() # tout
a.sum(axis=0) # somme des lignes -> un résultat par colonne
a.sum(axis=1) # un résultat par ligne
a.mean(), a.std(), a.min(), a.max()
a.argmax(), a.argmin() # position, pas valeur
np.median(a), np.percentile(a, 95)
Le sens d'axis est le piège classique : axis=0 fait disparaître l'axe 0, donc agrège
les lignes et laisse une valeur par colonne.
a = np.arange(6).reshape(2, 3)
print(a.sum(axis=0), a.sum(axis=1))
[3 5 7] [ 3 12]
Avec des NaN : np.nanmean, np.nansum, sinon tout devient NaN.
Broadcasting
a = np.ones((3, 4))
b = np.arange(4) # (4,)
a + b # (3, 4) : b est étiré sur les lignes
Règle : on aligne les shapes par la droite, et deux dimensions sont compatibles si elles sont égales ou si l'une vaut 1.
(3, 4) + (4,) -> (3, 4) ✅
(3, 4) + (3,) -> erreur ❌ (3 ≠ 4 sur le dernier axe)
(3, 4) + (3, 1) -> (3, 4) ✅
Pour corriger le cas d'erreur : a + b[:, np.newaxis].
Algèbre linéaire
A @ B # produit matriciel
A * B # produit terme à terme
np.dot(u, v) # produit scalaire
np.linalg.norm(v)
np.linalg.inv(A)
np.linalg.solve(A, b) # résout Ax = b, mieux que inv(A) @ b
np.linalg.eig(A)
Vectoriser au lieu de boucler
# lent
out = [x ** 2 + 1 for x in data]
# rapide
out = data ** 2 + 1
Une boucle Python sur un tableau NumPy annule tout l'intérêt de NumPy. Si la logique semble
imposer une boucle, chercher du côté de np.where, np.select, des masques, ou d'un
reshape + agrégation par axe.
Similarité cosinus, en pratique
def cosine(a: np.ndarray, b: np.ndarray) -> np.ndarray:
a = a / np.linalg.norm(a, axis=-1, keepdims=True)
b = b / np.linalg.norm(b, axis=-1, keepdims=True)
return a @ b.T
keepdims=True conserve la dimension pour que la division broadcaste correctement — sans
lui, la shape passe de (n, 1) à (n,) et la division échoue ou donne un résultat faux.
Sauvegarder
np.save("arr.npy", a)
a = np.load("arr.npy")
np.savez("plusieurs.npz", x=a, y=b)
np.savetxt("arr.csv", a, delimiter=",")
See also
pandas
page dédiée →Charger et regarder
import pandas as pd
df = pd.read_csv("ventes.csv")
df = pd.read_csv("ventes.csv", sep=";", parse_dates=["date"], dtype={"code": str})
df = pd.read_parquet("ventes.parquet")
df = pd.read_json("ventes.json")
df.head(3)
df.info()
df.describe()
df.shape
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1240 entries, 0 to 1239
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 1240 non-null datetime64[ns]
1 produit 1240 non-null object
2 region 1198 non-null object
3 montant 1240 non-null float64
info() est le premier réflexe : il donne d'un coup les types et les valeurs manquantes.
dtype: object sur une colonne censée être numérique signale presque toujours un problème
de parsing.
Sélectionner
df["montant"] # une Series
df"produit", "montant" # un DataFrame
df.loc[3, "montant"] # par label
df.iloc[3, 2] # par position
df.loc[df["montant"] > 100, ["produit", "montant"]]
Filtres combinés : parenthèses obligatoires, et & | ~ au lieu de and or not.
df[(df["montant"] > 100) & (df["region"] == "IDF")]
df[df["produit"].isin(["A", "B"])]
df[df["produit"].str.contains("chaise", case=False, na=False)]
df.query("montant > 100 and region == 'IDF'")
Valeurs manquantes
df.isna().sum()
df.dropna(subset=["region"])
df["region"] = df["region"].fillna("inconnue")
df.isna().sum()
date 0
produit 0
region 42
montant 0
dtype: int64
Grouper et agréger
df.groupby("region")["montant"].sum()
df.groupby("region")["montant"].agg(["sum", "mean", "count"])
df.groupby(["region", "produit"], as_index=False).agg(
total=("montant", "sum"),
n=("montant", "size"),
)
df.groupby("region")["montant"].agg(["sum", "mean", "count"])
sum mean count
region
IDF 184203.5 312.55 589
PACA 92310.0 287.29 321
Bretagne 61044.2 254.35 240
as_index=False évite d'avoir à faire .reset_index() juste après.
Transformer
df["ttc"] = df["montant"] * 1.2
df["mois"] = df["date"].dt.to_period("M")
df["cat"] = df["montant"].apply(lambda x: "gros" if x > 500 else "petit")
df = df.rename(columns={"montant": "ht"})
df = df.drop(columns=["temp"])
df = df.sort_values("ht", ascending=False)
df = df.astype({"code": "string", "n": "int32"})
apply sur une Series est lent. Préférer les opérations vectorisées, ou np.where /
pd.cut pour les cas conditionnels :
import numpy as np
df["cat"] = np.where(df["ht"] > 500, "gros", "petit")
Joindre et empiler
pd.merge(cmd, clients, on="client_id", how="left")
pd.merge(a, b, left_on="id", right_on="ref", how="inner")
pd.concat([df_jan, df_fev], ignore_index=True) # empile verticalement
how : left, right, inner, outer. Après un merge, vérifier len(df) — une
explosion du nombre de lignes révèle une clé non unique.
Pivots
df.pivot_table(index="region", columns="mois", values="ht", aggfunc="sum", fill_value=0)
df.melt(id_vars=["region"], var_name="mois", value_name="ht") # l'inverse
Le SettingWithCopyWarning
sub = df[df["ht"] > 100]
sub["remise"] = 0.1 # ⚠️ warning : sub peut être une vue
Corriger avec une copie explicite :
sub = df[df["ht"] > 100].copy()
sub["remise"] = 0.1
Ou modifier en place sur l'original : df.loc[df["ht"] > 100, "remise"] = 0.1.
Exporter
df.to_csv("out.csv", index=False)
df.to_parquet("out.parquet") # plus rapide et typé, à préférer
df.to_json("out.json", orient="records")
index=False presque toujours, sinon on récupère une colonne Unnamed: 0 au rechargement.
Options d'affichage
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 200)
pd.set_option("display.float_format", "{:.2f}".format)
See also
Pydantic v2
page dédiée →Validation de données par annotations de type. La v2 a un cœur en Rust et une API qui diffère de la v1 sur plusieurs noms.
Modèle de base
from pydantic import BaseModel, Field
class Item(BaseModel):
id: int
title: str
price: float = Field(gt=0, description="Prix TTC en euros")
tags: list[str] = []
item = Item(id="42", title="Chaise", price=19.9) # "42" est converti en int
print(item)
print(item.model_dump())
id=42 title='Chaise' price=19.9 tags=[]
{'id': 42, 'title': 'Chaise', 'price': 19.9, 'tags': []}
La coercition est volontaire : "42" devient 42. Pour l'interdire, model_config = ConfigDict(strict=True).
Les méthodes v2, et leurs anciens noms
| v2 | v1 |
|---|---|
model_dump() |
.dict() |
model_dump_json() |
.json() |
model_validate(obj) |
parse_obj() |
model_validate_json(s) |
parse_raw() |
model_json_schema() |
schema() |
model_copy(update=...) |
.copy() |
Erreurs de validation
from pydantic import ValidationError
try:
Item(id="abc", title="Chaise", price=-1)
except ValidationError as exc:
print(exc)
2 validation errors for Item
id
Input should be a valid integer, unable to parse string as an integer
[type=int_parsing, input_value='abc', input_type=str]
price
Input should be greater than 0
[type=greater_than, input_value=-1, input_type=int]
exc.errors() renvoie la même chose en liste de dicts — c'est la forme à renvoyer à un
modèle quand on valide ses tool calls.
Contraintes utiles
from typing import Literal, Annotated
from pydantic import BaseModel, Field, HttpUrl, EmailStr
class Agent(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=64)]
url: HttpUrl
email: EmailStr | None = None # pip install pydantic[email]
mode: Literal["fast", "default"] = "default"
budget: int = Field(default=60, ge=1, le=500)
Field(gt, ge, lt, le) pour les nombres, min_length / max_length pour chaînes et
listes, pattern= pour une regex.
Valeurs par défaut mutables
from pydantic import BaseModel
class Bad(BaseModel):
tags: list[str] = [] # sûr ici : pydantic copie la valeur par défaut
Contrairement aux dataclasses et aux fonctions Python, un défaut mutable ne fuit pas entre
instances. Pour un défaut calculé : Field(default_factory=list).
Validateurs
from pydantic import BaseModel, field_validator, model_validator
class Range(BaseModel):
low: int
high: int
@field_validator("low", "high")
@classmethod
def positive(cls, v: int) -> int:
if v < 0:
raise ValueError("doit être positif")
return v
@model_validator(mode="after")
def ordered(self):
if self.low > self.high:
raise ValueError("low doit être <= high")
return self
mode="before" s'exécute sur la valeur brute avant conversion, mode="after" sur l'objet
déjà validé.
Modèles imbriqués et parsing JSON
class Result(BaseModel):
findable: bool
clicks: int
notes: str | None = None
class Report(BaseModel):
site: str
results: list[Result]
payload = '{"site":"versace.com","results":[{"findable":true,"clicks":3}]}'
report = Report.model_validate_json(payload)
print(report.results[0].clicks)
3
C'est le pattern des sorties structurées de LLM : on impose un schéma, on parse, et une réponse hors format lève au lieu de contaminer la suite.
Schéma JSON pour un tool call
print(Result.model_json_schema())
{'properties': {'findable': {'title': 'Findable', 'type': 'boolean'},
'clicks': {'title': 'Clicks', 'type': 'integer'},
'notes': {'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None, 'title': 'Notes'}},
'required': ['findable', 'clicks'],
'title': 'Result',
'type': 'object'}
C'est exactement ce qu'attend un tools=[...] d'API LLM.
Configuration et settings
from pydantic import ConfigDict
from pydantic_settings import BaseSettings # pip install pydantic-settings
class Item(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=False)
class Settings(BaseSettings):
api_key: str # lit API_KEY dans l'environnement
base_url: str = "https://api.example.com"
settings = Settings() # lève si API_KEY manque
extra="forbid" refuse les champs inconnus — précieux pour attraper une faute de frappe
dans une config plutôt que de l'ignorer en silence.
See also
pyenv
page dédiée →Gère des versions de Python, pas des paquets. Fonctionne par shims : de faux
exécutables placés très tôt dans le PATH, qui redirigent vers la version active.
Voir et installer
pyenv versions # installées, l'active marquée d'une étoile
pyenv version # l'active ICI, et qui l'a décidée
pyenv install --list | rg "^\s*3\.12"
pyenv install 3.12.9
pyenv version
3.12.9 (set by /Users/edouard/code/mon-projet/.python-version)
La deuxième partie est la plus utile : elle dit quel fichier impose la version.
Choisir une version
pyenv global 3.12.9 # défaut de la machine -> ~/.pyenv/version
pyenv local 3.12.9 # ce dossier -> ./.python-version
pyenv shell 3.12.9 # ce shell seulement -> $PYENV_VERSION
pyenv local --unset
Ordre de priorité : shell > local > global.
Environnements nommés (plugin pyenv-virtualenv)
pyenv virtualenv 3.12.9 mon-env # -> ~/.pyenv/versions/3.12.9/envs/mon-env
pyenv local mon-env # activation automatique en entrant dans le dossier
pyenv activate mon-env
pyenv deactivate
pyenv virtualenv-delete mon-env
Ils vivent dans ~/.pyenv/versions/, pas dans le projet. C'est la différence de fond
avec un .venv local.
venv standard, sans le plugin
pyenv local 3.12.9
python -m venv .venv
source .venv/bin/activate
pip install httpx pydantic
pip freeze > requirements.txt
deactivate
Les deux pièges qui coûtent des minutes
Le shim répond à la place du venv. Un paquet installé dans le venv, et pourtant la commande résolue est celle de pyenv :
which uvicorn
/Users/edouard/.pyenv/versions/lewagon/bin/uvicorn
Contournement immédiat : python -m uvicorn main:app passe par le Python courant et ignore
le PATH. Sinon hash -r (zsh : rehash) vide le cache de commandes du shell.
La commande existe ailleurs. Message classique :
pyenv: jupyter: command not found
The `jupyter' command exists in these Python versions:
3.12.9/envs/lewagon
3.10.6/envs/taxifare-env
Le shim cherche dans la version active, qui n'a pas le paquet. Soit on active le bon
environnement, soit on passe par uv run.
Diagnostic
which -a python python3 # tous les candidats, dans l'ordre du PATH
pyenv which python # ce que pyenv résoudrait
pyenv doctor # si le plugin est installé
PYENV_VERSION=system python -V # contournement ponctuel
Rapport avec uv
uv est indépendant : il télécharge ses propres interpréteurs dans
~/.local/share/uv/python/ et ne consulte jamais pyenv. Seul point de friction : les deux
lisent .python-version.
See also
Python requests
page dédiée →Client HTTP synchrone. Pas de timeout par défaut — toujours le passer explicitement.
Le squelette à écrire de mémoire
import os
import requests
BASE = "https://api.example.com/v1"
r = requests.post(
f"{BASE}/environments",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Content-Type": "application/json",
},
json={"name": "demo", "url": "https://example.com"},
timeout=30,
)
r.raise_for_status()
print(r.status_code, r.json())
json= sérialise le dict et pose Content-Type: application/json tout seul. data= envoie
du form-urlencoded ou des bytes bruts — c'est la confusion la plus fréquente.
Réponse
| Attribut | Contenu |
|---|---|
r.status_code |
entier, 200, 404… |
r.ok |
True si status_code < 400 |
r.json() |
corps parsé, lève JSONDecodeError si ce n'est pas du JSON |
r.text |
corps brut décodé |
r.headers |
dict insensible à la casse |
r.raise_for_status() |
lève HTTPError sur 4xx/5xx, ne fait rien sinon |
Session : réutiliser la connexion et les headers
with requests.Session() as s:
s.headers.update({"Authorization": f"Bearer {os.environ['API_KEY']}"})
for page in range(1, 6):
r = s.get(f"{BASE}/items", params={"page": page}, timeout=30)
r.raise_for_status()
print(r.json()["items"])
params= construit la query string, pas besoin de la concaténer à la main.
Polling d'un job asynchrone
import time
def wait_for(session_id: str, timeout_s: int = 300, every_s: int = 3) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(f"{BASE}/sessions/{session_id}", timeout=30)
r.raise_for_status()
payload = r.json()
if payload["status"] in {"completed", "failed"}:
return payload
time.sleep(every_s)
raise TimeoutError(f"session {session_id} toujours en cours après {timeout_s}s")
Erreurs à distinguer
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
except requests.Timeout: # dépassement du timeout
...
except requests.ConnectionError: # DNS, refus de connexion, réseau
...
except requests.HTTPError as exc: # 4xx / 5xx après raise_for_status
print(exc.response.status_code, exc.response.text[:200])
Toutes héritent de requests.RequestException.
Charger la clé depuis un .env
from dotenv import load_dotenv # pip install python-dotenv
load_dotenv()
key = os.environ["API_KEY"] # KeyError explicite si absente
os.environ["X"] lève si la variable manque, os.getenv("X") renvoie None en silence.
Préférer le premier pour une clé obligatoire.
Debug rapide
print(r.request.method, r.request.url)
print(r.request.headers)
print(r.request.body)
Équivalences avec curl
| curl | requests |
|---|---|
-X POST |
requests.post(...) |
-H "K: V" |
headers={"K": "V"} |
-d '{"a":1}' |
json={"a": 1} |
-G --data-urlencode "q=x" |
params={"q": "x"} |
-u user:pass |
auth=("user", "pass") |
--max-time 30 |
timeout=30 |
See also
- httpx — même API, plus l'async et un timeout par défaut
scikit-learn
page dédiée →Une API uniforme : tout estimateur a fit, tout transformateur a transform, tout
prédicteur a predict. Le reste en découle.
Découper
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
stratify=y conserve la proportion des classes — indispensable en classification
déséquilibrée. random_state rend le découpage reproductible.
Le pipeline, à utiliser systématiquement
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
numeric = ["age", "montant"]
categorical = ["region", "segment"]
pre = ColumnTransformer([
("num", Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), numeric),
("cat", Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore")),
]), categorical),
])
model = Pipeline([("pre", pre), ("clf", RandomForestClassifier(random_state=42))])
model.fit(X_train, y_train)
Le pipeline n'est pas une élégance : il empêche la fuite de données. Un StandardScaler
ajusté sur tout le jeu avant le split fait fuiter la moyenne du test dans l'entraînement, et
le score devient un mensonge.
handle_unknown="ignore" évite un crash quand une catégorie inconnue apparaît en
production.
Évaluer
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score
y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))
precision recall f1-score support
0 0.91 0.95 0.93 248
1 0.78 0.66 0.71 72
accuracy 0.89 320
macro avg 0.84 0.80 0.82 320
weighted avg 0.88 0.89 0.88 320
Sur un jeu déséquilibré, l'accuracy ment : ici 89 % semble bon alors que la classe minoritaire n'est rattrapée qu'à 66 %. Regarder le rappel par classe, la matrice de confusion, et l'AUC.
proba = model.predict_proba(X_test)[:, 1]
print(roc_auc_score(y_test, proba))
Validation croisée
from sklearn.model_selection import cross_val_score, StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=cv, scoring="f1", n_jobs=-1)
print(scores.mean().round(3), scores.std().round(3))
0.712 0.031
L'écart-type compte autant que la moyenne : un modèle à 0.71 ± 0.03 est utilisable, à 0.71 ± 0.18 il est instable.
Recherche d'hyperparamètres
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
grid = {
"clf__n_estimators": [100, 300],
"clf__max_depth": [None, 10, 20],
"clf__min_samples_leaf": [1, 5],
}
search = GridSearchCV(model, grid, cv=cv, scoring="f1", n_jobs=-1, verbose=1)
search.fit(X_train, y_train)
print(search.best_params_, search.best_score_)
Le double underscore adresse un paramètre à travers le pipeline : clf__max_depth vise le
max_depth de l'étape nommée clf. Au-delà d'une centaine de combinaisons,
RandomizedSearchCV(n_iter=50) donne presque le même résultat bien plus vite.
Estimateurs courants
| Tâche | Point de départ | Ensuite |
|---|---|---|
| Classification tabulaire | LogisticRegression |
HistGradientBoostingClassifier, RandomForest |
| Régression tabulaire | Ridge |
HistGradientBoostingRegressor |
| Clustering | KMeans |
DBSCAN, AgglomerativeClustering |
| Réduction de dimension | PCA |
TSNE, UMAP (hors sklearn) |
HistGradientBoosting* gère les valeurs manquantes nativement et bat presque toujours une
forêt aléatoire sur du tabulaire.
Toujours établir une référence triviale avant de comparer :
from sklearn.dummy import DummyClassifier
DummyClassifier(strategy="most_frequent").fit(X_train, y_train).score(X_test, y_test)
Classes déséquilibrées
RandomForestClassifier(class_weight="balanced")
LogisticRegression(class_weight="balanced")
Et ajuster le seuil de décision plutôt que d'accepter 0.5 par défaut :
from sklearn.metrics import precision_recall_curve
p, r, seuils = precision_recall_curve(y_test, proba)
Sauvegarder
import joblib
joblib.dump(model, "model.joblib")
model = joblib.load("model.joblib")
Le pipeline entier est sérialisé, préprocessing compris — c'est le second intérêt majeur du pipeline : le même objet sert en entraînement et en production.
See also
Streamlit
page dédiée →Interface web en Python pur. Le meilleur rapport temps/effet pour une démo client interne.
Le modèle d'exécution, à comprendre en premier
Le script entier est réexécuté de haut en bas à chaque interaction. Pas de callbacks, pas de composants. C'est ce qui rend Streamlit simple et ce qui provoque toutes ses surprises.
Deux conséquences : tout état doit vivre dans st.session_state, et tout calcul coûteux
doit être mis en cache.
pip install streamlit
streamlit run app.py
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
Network URL: http://192.168.1.24:8501
Afficher
import streamlit as st
st.title("Test d'accessibilité produit")
st.header("Résultats")
st.subheader("Par site")
st.write("Accepte à peu près tout : texte, DataFrame, figure, dict")
st.markdown("**gras**, `code`, [lien](https://example.com)")
st.code("requests.post(url, json=payload)", language="python")
st.json({"findable": True, "clicks": 3})
st.dataframe(df) # interactif, triable
st.table(df.head()) # statique
st.metric("Clics moyens", 3.2, delta=-0.4)
Saisir
site = st.text_input("Site", value="versace.com")
produit = st.text_area("Produit recherché")
budget = st.slider("Budget d'étapes", 10, 200, 60)
n = st.number_input("Répétitions", min_value=1, max_value=10, value=3)
mode = st.selectbox("Mode", ["fast", "default"])
tags = st.multiselect("Tests", ["navigation", "panier", "checkout"])
strict = st.checkbox("Mode strict")
fichier = st.file_uploader("CSV", type=["csv"])
if st.button("Lancer"):
...
Chaque widget renvoie sa valeur courante. Le if st.button(...) n'est vrai que sur le
rerun déclenché par le clic.
Mise en page
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Sessions", 12)
with st.sidebar:
api_key = st.text_input("Clé API", type="password")
tab1, tab2 = st.tabs(["Résultats", "Logs"])
with tab1:
st.dataframe(df)
with st.expander("Détails techniques"):
st.code(trace)
with st.container():
st.write("bloc regroupé")
État
if "runs" not in st.session_state:
st.session_state.runs = []
if st.button("Ajouter"):
st.session_state.runs.append({"site": site})
st.write(f"{len(st.session_state.runs)} exécutions")
Sans session_state, la liste serait recréée vide à chaque interaction.
Cache
@st.cache_data # pour des données : DataFrame, JSON, réponses API
def charger(path: str):
return pd.read_csv(path)
@st.cache_resource # pour des objets vivants : client, modèle, connexion
def get_client():
return SomeClient(api_key=os.environ["API_KEY"])
La distinction compte : cache_data sérialise et renvoie une copie, cache_resource
renvoie le même objet partagé entre sessions. Un client HTTP dans cache_data casse.
Retour visuel pendant un traitement long
with st.spinner("Session en cours…"):
result = run_agent(site, produit)
bar = st.progress(0)
for i, test in enumerate(tests):
run(test)
bar.progress((i + 1) / len(tests))
st.success("Terminé")
st.warning("2 tests non concluants")
st.error("Clé API invalide")
with st.status("Navigation…", expanded=True) as s:
st.write("Ouverture du site")
st.write("Recherche du produit")
s.update(label="Terminé", state="complete")
Streaming d'un LLM
def tokens():
for chunk in client.stream(prompt):
yield chunk.text
st.write_stream(tokens)
Pour un chat complet, st.chat_message("user" | "assistant") et st.chat_input().
Secrets et configuration
.streamlit/secrets.toml :
API_KEY = "sk-..."
key = st.secrets["API_KEY"]
.streamlit/config.toml pour le thème et le port :
[server]
port = 8501
[theme]
base = "light"
Limites à connaître avant de s'engager
Pas de routing multi-pages fin (juste un dossier pages/), peu de contrôle sur le CSS, et
le modèle de rerun devient pénible dès qu'on veut une vraie interactivité. Au-delà de la
démo, passer à Next.js avec une API FastAPI derrière.
Déploiement : Streamlit Community Cloud gratuit depuis un repo GitHub, ou un conteneur Docker n'importe où. Pas sur Vercel — Vercel ne fait pas tourner de processus Python long.
See also
Gestionnaire de projets Python en Rust. Remplace pyenv + venv + pip + pip-tools
d'un seul coup, et télécharge ses propres interpréteurs.
Démarrer un projet
uv init --python 3.12 mon-projet
cd mon-projet
uv add httpx pydantic pytest
uv run python -V
Using CPython 3.12.11
Creating virtual environment at: .venv
Python 3.12.11
Toujours passer --python. Sans lui, uv prend sa version par défaut — souvent une 3.13
ou 3.14 — et écrit requires-python = ">=3.13" dans le pyproject.toml.
uv init crée pyproject.toml, .python-version, main.py, README.md, .gitignore et
un dépôt git. Le .venv/ n'apparaît qu'au premier add ou run.
| Variante | Résultat |
|---|---|
uv init |
projet application, fichiers à plat |
uv init --package |
src/<nom>/__init__.py + [build-system] |
uv init --lib |
idem plus py.typed |
Le quotidien
uv add httpx # ajoute et installe
uv add --dev pytest ruff # dépendance de dev
uv remove httpx
uv sync # aligne .venv sur uv.lock
uv lock # regénère le lock sans installer
uv tree # arbre des dépendances
uv run <commande> # exécute dans le venv, sans activation
uv add et uv run synchronisent implicitement. uv sync sert surtout après un clone, ou
quand le .venv est cassé.
uv sync désinstalle ce qui n'est pas dans le lock, contrairement à pip install -r.
uv sync
Resolved 100 packages in 4ms
Uninstalled 3 packages in 8ms
- markdown-it-py==4.2.0
- mdurl==0.1.2
- rich==15.0.0
Reprendre un repo existant
git clone <url> && cd <repo> && ls
| Ce qu'on trouve | Ce qu'on lance |
|---|---|
uv.lock |
uv sync |
pyproject.toml seul |
uv sync (le lock est généré) |
requirements.txt |
uv venv --python 3.12 puis uv pip install -r requirements.txt |
poetry.lock |
poetry install, ou uv pip install -e . |
| rien | uv init --python 3.12 |
uv venv + uv pip ne modifient aucun fichier du repo — c'est la voie propre pour
accélérer l'installation chez un client sans imposer son outillage.
Le piège .python-version
uv et pyenv lisent ce fichier. Si uv y écrit 3.13 et que pyenv n'a pas de 3.13, pyenv
affiche une erreur à chaque commande dans ce dossier. C'est du bruit, uv continue de
fonctionner, mais c'est pénible.
# corriger requires-python dans pyproject.toml D'ABORD
uv python pin 3.12
Updated `.python-version` from `3.13` -> `3.12`
Dans l'autre ordre, uv refuse : The requested Python version 3.12 is incompatible with the project requires-python value of >=3.13.
Outils globaux
uv tool install ruff # installé isolément, disponible partout
uv tool list
uvx ruff check . # exécute sans installer
Diagnostic
uv run python -c "import sys; print(sys.executable)"
/Users/edouard/code/mon-projet/.venv/bin/python3
Le chemin doit contenir /.venv/ et être dans le projet. S'il pointe vers
.pyenv/shims, /opt/homebrew ou /usr/bin, on n'est pas où on croit.
uv python list # interpréteurs connus
uv cache clean # vider le cache
Pour un live coding sous pression, activer une fois et oublier le préfixe :
source .venv/bin/activate