🤖 Data Engineering for AI

Module : Data Engineering for AI

2. Stocker les données


Durée estimée : 3-4 heures

Prérequis : Module 1 (Introduction) + Docker installé

📍 Où en es-tu dans le parcours ?

    DONNÉES BRUTES                                              DATASET PRÊT
    (chaos)                                                     (training-ready)
         │                                                            ▲
         │   Module 2       Module 3        Module 4       Module 5   │
         │  ──────────     ──────────      ──────────     ──────────  │
         │                                                            │
         ▼                                                            │
    ┌─────────┐      ┌─────────────┐      ┌──────────┐      ┌────────┴───────┐
    │         │      │             │      │          │      │                │
    │ STOCKER │─────▶│ TRANSFORMER │─────▶│ ENRICHIR │─────▶│ AUTOMATISER &  │
    │  ▲▲▲▲   │      │             │      │          │      │ VERSIONNER     │
    │         │      │             │      │          │      │                │
    └─────────┘      └─────────────┘      └──────────┘      └────────────────┘
     TU ES ICI

🎯 Question de ce module

Où et comment stocker des millions de fichiers (images, audio, vidéo) pour un projet IA ?

📦 Ce que tu vas livrer

À la fin de ce module, tu auras :

  • Un bucket MinIO organisé avec des images
  • Un catalogue Parquet avec les métadonnées de chaque fichier
  • Un pipeline d'ingestion fonctionnel
  • La capacité de passer en production (S3, Azure, GCP) sans changer ton code

🛠️ Ce que tu vas apprendre

Compétence Pourquoi c'est important Outil
Stockage objet Standard pour les données non-structurées à grande échelle MinIO (compatible S3)
Formats optimisés Training rapide = I/O efficace Parquet, WebDataset
Métadonnées Savoir ce qu'on a dans le dataset DuckDB
Multi-cloud Flexibilité dev → prod Abstraction S3/Azure/GCP

📦 Bon à savoir : Tout le code de ce module est téléchargeable à la fin (section 2.6). Tu peux soit taper le code pour apprendre, soit récupérer le package prêt à l'emploi !

2.0 Les types de données

Structurées vs semi-structurées vs non-structurées

En Data Engineering pour l'IA, tu travailles principalement avec des données non-structurées.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         SPECTRE DES DONNÉES                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   STRUCTURÉES          SEMI-STRUCTURÉES           NON-STRUCTURÉES          │
│   ────────────         ─────────────────          ────────────────          │
│                                                                             │
│   ┌──────────┐         ┌──────────────┐           ┌──────────────┐          │
│   │  Tables  │         │    JSON      │           │   Images     │          │
│   │   SQL    │         │    XML       │           │   Audio      │          │
│   │  CSV     │         │    YAML      │           │   Vidéo      │          │
│   │  Excel   │         │    Logs      │           │   Texte brut │          │
│   └──────────┘         └──────────────┘           └──────────────┘          │
│                                                                             │
│   Schéma fixe          Schéma flexible            Pas de schéma            │
│   Facile à requêter    Parsable                   Nécessite traitement     │
│   ~10% des données     ~10% des données           ~80% des données ◄────   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
Type Exemples Stockage typique
Structurées Tables SQL, CSV PostgreSQL, Data Warehouse
Semi-structurées JSON, XML, logs MongoDB, Elasticsearch
Non-structurées Images, vidéos, audio, PDF Object Storage (S3, MinIO)

💡 Pour l'IA, on stocke les fichiers bruts (images, audio) dans un object storage, et les métadonnées dans un format structuré (Parquet).

Pourquoi le stockage objet pour l'IA ?

Comparaison des solutions de stockage :

Solution Avantages Inconvénients Pour l'IA ?
Système de fichiers Simple, familier Limité en scale, pas distribué ❌ < 100K fichiers
Base de données (BLOB) ACID, requêtes Lent pour gros fichiers, coûteux ❌ Pas adapté
Object Storage Scalable, distribué, API simple Pas de requêtes complexes ✅ Standard
HDFS Très scalable Complexe à opérer ⚠️ Legacy

L'object storage est le standard pour l'IA car :

┌─────────────────────────────────────────────────────────────────────────────┐
│                    AVANTAGES DU STOCKAGE OBJET                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ✅ Scalabilité infinie      Pétaoctets sans problème                     │
│   ✅ API standardisée         S3 API = compatible partout                  │
│   ✅ Métadonnées riches       Tags, labels sur chaque objet                │
│   ✅ Coût optimisé            Stockage froid pour archives                 │
│   ✅ Distribué                Réplication, haute disponibilité             │
│   ✅ Intégration native       Spark, PyTorch, TensorFlow lisent S3         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Setup de l'environnement (Docker)

Crée un fichier docker-compose.yml :

version: '3.8'

services:
  # ============================================
  # MinIO - Stockage objet compatible S3
  # ============================================
  minio:
    image: minio/minio:latest
    container_name: minio
    ports:
      - "9000:9000"   # API S3
      - "9001:9001"   # Console Web
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  minio_data:
# Lancer l'environnement
docker-compose up -d

# Vérifier que MinIO est up
docker-compose ps

# Accéder à la console : http://localhost:9001
# Login: minioadmin / minioadmin123

2.1 Stockage objet avec MinIO

Concepts clés : Buckets, Objets, Clés

┌─────────────────────────────────────────────────────────────────────────────┐
│                         ANATOMIE DU STOCKAGE OBJET                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   BUCKET (conteneur)                                                        │
│   └── raw-images/                                                           │
│       ├── 2024/                                                             │
│       │   ├── 01/                                                           │
│       │   │   ├── image_001.jpg  ◄── OBJET (fichier + métadonnées)         │
│       │   │   ├── image_002.jpg                                            │
│       │   │   └── image_003.jpg                                            │
│       │   └── 02/                                                           │
│       │       └── ...                                                       │
│       └── metadata/                                                         │
│           └── catalog.parquet                                               │
│                                                                             │
│   CLÉ = chemin complet : "2024/01/image_001.jpg"                           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
Concept Description Analogie
Bucket Conteneur de premier niveau Dossier racine
Objet Fichier + métadonnées Fichier enrichi
Clé Identifiant unique de l'objet Chemin du fichier
Métadonnées Infos sur l'objet (taille, type, tags) Attributs du fichier
pythonVoir le code
# ============================================
# Installation des dépendances
# ============================================

# !pip install boto3 minio pillow pyarrow pandas

import boto3
from botocore.client import Config
import os

# Configuration MinIO
MINIO_ENDPOINT = "http://localhost:9000"
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin123"

# Créer le client S3 (compatible MinIO)
s3_client = boto3.client(
    's3',
    endpoint_url=MINIO_ENDPOINT,
    aws_access_key_id=MINIO_ACCESS_KEY,
    aws_secret_access_key=MINIO_SECRET_KEY,
    config=Config(signature_version='s3v4'),
    region_name='us-east-1'  # Requis mais ignoré par MinIO
)

print("✅ Client S3 connecté à MinIO")
pythonVoir le code
# ============================================
# Créer un bucket
# ============================================

BUCKET_NAME = "raw-images"

def create_bucket_if_not_exists(bucket_name: str):
    """Crée un bucket s'il n'existe pas."""
    try:
        s3_client.head_bucket(Bucket=bucket_name)
        print(f"✅ Bucket '{bucket_name}' existe déjà")
    except:
        s3_client.create_bucket(Bucket=bucket_name)
        print(f"✅ Bucket '{bucket_name}' créé")

create_bucket_if_not_exists(BUCKET_NAME)

# Lister les buckets
response = s3_client.list_buckets()
print(f"\n📦 Buckets disponibles :")
for bucket in response['Buckets']:
    print(f"   - {bucket['Name']}")
pythonVoir le code
# ============================================
# Créer des images de test et les uploader
# ============================================

from PIL import Image
import io
import numpy as np
from datetime import datetime

def create_test_image(width: int = 256, height: int = 256) -> bytes:
    """Crée une image de test aléatoire."""
    arr = np.random.randint(0, 255, (height, width, 3), dtype=np.uint8)
    img = Image.fromarray(arr)
    buffer = io.BytesIO()
    img.save(buffer, format='JPEG', quality=85)
    return buffer.getvalue()

def upload_image(bucket: str, key: str, image_bytes: bytes, metadata: dict = None):
    """Upload une image avec ses métadonnées."""
    extra_args = {}
    if metadata:
        extra_args['Metadata'] = {k: str(v) for k, v in metadata.items()}
    
    s3_client.put_object(
        Bucket=bucket,
        Key=key,
        Body=image_bytes,
        ContentType='image/jpeg',
        **extra_args
    )

# Créer et uploader 10 images de test
print("📤 Upload des images de test...")
for i in range(10):
    image_bytes = create_test_image()
    key = f"2024/01/image_{i:04d}.jpg"
    metadata = {
        'width': '256',
        'height': '256',
        'source': 'test',
        'created_at': datetime.now().isoformat()
    }
    upload_image(BUCKET_NAME, key, image_bytes, metadata)
    print(f"   ✅ {key}")

print(f"\n🎉 {10} images uploadées dans '{BUCKET_NAME}'")
pythonVoir le code
# ============================================
# Lister les objets dans un bucket
# ============================================

def list_objects(bucket: str, prefix: str = "") -> list:
    """Liste tous les objets dans un bucket avec un préfixe optionnel."""
    objects = []
    paginator = s3_client.get_paginator('list_objects_v2')
    
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        if 'Contents' in page:
            for obj in page['Contents']:
                objects.append({
                    'key': obj['Key'],
                    'size': obj['Size'],
                    'last_modified': obj['LastModified']
                })
    return objects

# Lister toutes les images
objects = list_objects(BUCKET_NAME, prefix="2024/")
print(f"📋 {len(objects)} objets dans '{BUCKET_NAME}/2024/':\n")
for obj in objects[:5]:  # Afficher les 5 premiers
    print(f"   {obj['key']} ({obj['size']} bytes)")
if len(objects) > 5:
    print(f"   ... et {len(objects) - 5} autres")

2.2 Formats optimisés pour l'IA

Le problème : millions de petits fichiers

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PROBLÈME DES PETITS FICHIERS                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1 million d'images × 1 requête/image = 1 million de requêtes réseau      │
│                                                                             │
│   ❌ Latence énorme (overhead par requête)                                  │
│   ❌ Coût cloud élevé (facturation par requête)                             │
│   ❌ Training lent (I/O bound)                                              │
│                                                                             │
│   SOLUTION : Regrouper les fichiers en archives optimisées                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Les deux formats clés

Format Usage Caractéristiques
Parquet Métadonnées, catalogue Colonnes, compression, requêtes SQL
WebDataset Training ML Séquentiel, streaming, shards

Parquet : le format pour les métadonnées

┌─────────────────────────────────────────────────────────────────────────────┐
│                         POURQUOI PARQUET ?                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   CSV (row-based)              PARQUET (column-based)                      │
│   ──────────────               ─────────────────────                       │
│                                                                             │
│   id,path,width,height         ┌─────┬─────┬─────┬─────┐                  │
│   1,img_001.jpg,256,256        │ id  │path │width│height│                 │
│   2,img_002.jpg,512,512        ├─────┼─────┼─────┼─────┤                  │
│   3,img_003.jpg,256,256        │  1  │     │ 256  256 │                  │
│                                │  2  │     │ 512  512 │                  │
│   ❌ Lire tout le fichier      │  3  │     │ 256  256 │                  │
│   ❌ Pas de compression         └─────┴─────┴─────┴─────┘                  │
│   ❌ Pas de types               ✅ Lire seulement les colonnes voulues     │
│                                 ✅ Compression par colonne                 │
│                                 ✅ Types stricts (int, float, string)      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Avantages de Parquet :

  • Compression 10x vs CSV
  • Lecture partielle (colonnes)
  • Compatible Spark, Pandas, DuckDB
  • Schéma intégré
pythonVoir le code
# ============================================
# Créer un catalogue Parquet
# ============================================

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from datetime import datetime
import hashlib

def build_catalog(bucket: str, prefix: str = "") -> pd.DataFrame:
    """Construit un catalogue des images dans un bucket."""
    objects = list_objects(bucket, prefix)
    
    records = []
    for obj in objects:
        # Récupérer les métadonnées de l'objet
        response = s3_client.head_object(Bucket=bucket, Key=obj['key'])
        
        record = {
            'key': obj['key'],
            'bucket': bucket,
            'size_bytes': obj['size'],
            'content_type': response.get('ContentType', 'unknown'),
            'last_modified': obj['last_modified'].isoformat(),
            's3_uri': f"s3://{bucket}/{obj['key']}"
        }
        
        # Ajouter les métadonnées custom si présentes
        if 'Metadata' in response:
            for k, v in response['Metadata'].items():
                record[f'meta_{k}'] = v
        
        records.append(record)
    
    return pd.DataFrame(records)

# Construire le catalogue
catalog_df = build_catalog(BUCKET_NAME, "2024/")
print(f"📊 Catalogue construit : {len(catalog_df)} entrées\n")
print(catalog_df.head())
pythonVoir le code
# ============================================
# Sauvegarder le catalogue en Parquet
# ============================================

# Sauvegarder localement
catalog_df.to_parquet('catalog.parquet', index=False)
print("✅ Catalogue sauvegardé localement: catalog.parquet")

# Uploader dans MinIO
with open('catalog.parquet', 'rb') as f:
    s3_client.put_object(
        Bucket=BUCKET_NAME,
        Key='metadata/catalog.parquet',
        Body=f.read(),
        ContentType='application/octet-stream'
    )
print(f"✅ Catalogue uploadé: s3://{BUCKET_NAME}/metadata/catalog.parquet")

# Vérifier la taille
import os
parquet_size = os.path.getsize('catalog.parquet')
print(f"\n📦 Taille du fichier Parquet: {parquet_size} bytes")

WebDataset : le format pour le training

┌─────────────────────────────────────────────────────────────────────────────┐
│                         POURQUOI WEBDATASET ?                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Fichiers séparés              WebDataset (TAR shards)                    │
│   ────────────────              ────────────────────────                   │
│                                                                             │
│   img_001.jpg                   shard_0000.tar                              │
│   img_001.json                  ├── 000000.jpg                              │
│   img_002.jpg                   ├── 000000.json                             │
│   img_002.json                  ├── 000001.jpg                              │
│   ...                           ├── 000001.json                             │
│   img_999.jpg                   └── ...                                     │
│   img_999.json                  shard_0001.tar                              │
│                                 └── ...                                     │
│                                                                             │
│   ❌ 1M requêtes réseau         ✅ ~100 requêtes (100 shards)               │
│   ❌ Random access lent         ✅ Streaming séquentiel rapide              │
│   ❌ Shuffle coûteux            ✅ Shuffle entre shards                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Convention WebDataset :

  • Fichiers groupés par basename : 000001.jpg, 000001.json, 000001.txt
  • Shards de taille fixe (~100 MB - 1 GB)
  • Compatible PyTorch DataLoader
pythonVoir le code
# ============================================
# Créer un shard WebDataset
# ============================================

import tarfile
import json

def create_webdataset_shard(
    bucket: str,
    keys: list,
    output_path: str
) -> int:
    """
    Crée un shard WebDataset à partir d'objets S3.
    
    Returns:
        Nombre d'échantillons dans le shard
    """
    count = 0
    
    with tarfile.open(output_path, 'w') as tar:
        for i, key in enumerate(keys):
            # Télécharger l'image
            response = s3_client.get_object(Bucket=bucket, Key=key)
            image_bytes = response['Body'].read()
            
            # Basename pour WebDataset
            basename = f"{i:06d}"
            
            # Ajouter l'image au tar
            img_info = tarfile.TarInfo(name=f"{basename}.jpg")
            img_info.size = len(image_bytes)
            tar.addfile(img_info, io.BytesIO(image_bytes))
            
            # Ajouter les métadonnées JSON
            metadata = {
                'original_key': key,
                'bucket': bucket,
                'index': i
            }
            json_bytes = json.dumps(metadata).encode('utf-8')
            json_info = tarfile.TarInfo(name=f"{basename}.json")
            json_info.size = len(json_bytes)
            tar.addfile(json_info, io.BytesIO(json_bytes))
            
            count += 1
    
    return count

# Créer un shard avec nos images
objects = list_objects(BUCKET_NAME, "2024/")
keys = [obj['key'] for obj in objects]

count = create_webdataset_shard(BUCKET_NAME, keys, "shard_0000.tar")
print(f"✅ Shard créé: shard_0000.tar ({count} échantillons)")

# Vérifier le contenu
with tarfile.open("shard_0000.tar", 'r') as tar:
    print(f"\n📦 Contenu du shard:")
    for member in tar.getmembers()[:6]:
        print(f"   {member.name} ({member.size} bytes)")

2.3 Métadonnées et cataloguing avec DuckDB

Pourquoi un catalogue ?

┌─────────────────────────────────────────────────────────────────────────────┐
│                         SANS vs AVEC CATALOGUE                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   SANS CATALOGUE                   AVEC CATALOGUE                          │
│   ──────────────                   ──────────────                          │
│                                                                             │
│   "Combien d'images ?"             SELECT COUNT(*) FROM catalog            │
│   → Lister tous les objets         → 0.01 secondes                         │
│   → 10 minutes pour 1M images                                              │
│                                                                             │
│   "Images > 1 MB ?"                SELECT * FROM catalog                   │
│   → Télécharger chaque image       WHERE size_bytes > 1000000              │
│   → Impossible                     → 0.05 secondes                         │
│                                                                             │
│   "Distribution des tailles ?"     SELECT width, COUNT(*)                  │
│   → Ouvrir chaque image            FROM catalog GROUP BY width             │
│   → Des heures                     → 0.02 secondes                         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

DuckDB : SQL sur fichiers Parquet

DuckDB est parfait pour analyser des catalogues :

  • Lit Parquet nativement
  • SQL standard
  • Très rapide (columnar)
  • Pas de serveur à gérer
pythonVoir le code
# ============================================
# Analyser le catalogue avec DuckDB
# ============================================

# !pip install duckdb

import duckdb

# Connecter DuckDB et charger le Parquet
con = duckdb.connect()

# Requête directe sur le fichier Parquet
print("📊 Statistiques du catalogue:\n")

# Nombre d'images
result = con.execute("SELECT COUNT(*) as total FROM 'catalog.parquet'").fetchone()
print(f"   Total images: {result[0]}")

# Taille totale
result = con.execute("SELECT SUM(size_bytes) as total_bytes FROM 'catalog.parquet'").fetchone()
print(f"   Taille totale: {result[0] / 1024:.2f} KB")

# Distribution par type
print(f"\n📋 Distribution par content_type:")
result = con.execute("""
    SELECT content_type, COUNT(*) as count 
    FROM 'catalog.parquet' 
    GROUP BY content_type
""").fetchall()
for row in result:
    print(f"   {row[0]}: {row[1]}")
pythonVoir le code
# ============================================
# Requêtes avancées sur le catalogue
# ============================================

# Créer une vue pour faciliter les requêtes
con.execute("""
    CREATE OR REPLACE VIEW images AS 
    SELECT * FROM 'catalog.parquet'
""")

# Trouver les images les plus récentes
print("📅 5 images les plus récentes:")
result = con.execute("""
    SELECT key, size_bytes, last_modified
    FROM images
    ORDER BY last_modified DESC
    LIMIT 5
""").fetchdf()
print(result.to_string(index=False))

# Statistiques de taille
print("\n📏 Statistiques de taille:")
result = con.execute("""
    SELECT 
        MIN(size_bytes) as min_size,
        MAX(size_bytes) as max_size,
        AVG(size_bytes)::INTEGER as avg_size,
        MEDIAN(size_bytes)::INTEGER as median_size
    FROM images
""").fetchdf()
print(result.to_string(index=False))

2.4 Projet pratique : Pipeline d'ingestion complet

Architecture du pipeline

┌─────────────────────────────────────────────────────────────────────────────┐
│                         PIPELINE D'INGESTION                                │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   SOURCE              VALIDATION           STOCKAGE           CATALOGUE    │
│   ──────              ──────────           ────────           ─────────    │
│                                                                             │
│   ┌─────────┐      ┌─────────────┐      ┌─────────┐      ┌─────────────┐   │
│   │ Images  │─────▶│  Valider    │─────▶│  MinIO  │─────▶│  Parquet    │   │
│   │ locales │      │  • Format   │      │  bucket │      │  catalog    │   │
│   │ ou URLs │      │  • Taille   │      │         │      │             │   │
│   └─────────┘      │  • Intégrité│      └─────────┘      └─────────────┘   │
│                    └─────────────┘                                         │
│                           │                                                │
│                           ▼                                                │
│                    ┌─────────────┐                                         │
│                    │   Rejetés   │                                         │
│                    │   (logs)    │                                         │
│                    └─────────────┘                                         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
pythonVoir le code
# ============================================
# Pipeline d'ingestion complet
# ============================================

from dataclasses import dataclass
from typing import Optional, List, Tuple
from PIL import Image
import hashlib

@dataclass
class ImageMetadata:
    """Métadonnées d'une image."""
    key: str
    bucket: str
    size_bytes: int
    width: int
    height: int
    format: str
    mode: str
    md5_hash: str
    s3_uri: str

class IngestionPipeline:
    """Pipeline d'ingestion d'images vers MinIO."""
    
    def __init__(self, s3_client, bucket: str):
        self.s3 = s3_client
        self.bucket = bucket
        self.ingested = []
        self.rejected = []
    
    def validate_image(self, image_bytes: bytes) -> Tuple[bool, Optional[dict], str]:
        """
        Valide une image.
        
        Returns:
            (is_valid, metadata_dict, error_message)
        """
        try:
            img = Image.open(io.BytesIO(image_bytes))
            img.verify()  # Vérifie l'intégrité
            
            # Ré-ouvrir après verify()
            img = Image.open(io.BytesIO(image_bytes))
            
            # Vérifications
            if img.width < 32 or img.height < 32:
                return False, None, "Image trop petite (< 32px)"
            
            if img.width > 10000 or img.height > 10000:
                return False, None, "Image trop grande (> 10000px)"
            
            metadata = {
                'width': img.width,
                'height': img.height,
                'format': img.format or 'UNKNOWN',
                'mode': img.mode
            }
            
            return True, metadata, ""
            
        except Exception as e:
            return False, None, f"Image corrompue: {str(e)}"
    
    def ingest(self, image_bytes: bytes, key: str) -> Optional[ImageMetadata]:
        """
        Ingère une image dans MinIO.
        
        Returns:
            ImageMetadata si succès, None si rejeté
        """
        # Valider
        is_valid, img_meta, error = self.validate_image(image_bytes)
        
        if not is_valid:
            self.rejected.append({'key': key, 'reason': error})
            return None
        
        # Calculer le hash
        md5_hash = hashlib.md5(image_bytes).hexdigest()
        
        # Upload
        self.s3.put_object(
            Bucket=self.bucket,
            Key=key,
            Body=image_bytes,
            ContentType=f"image/{img_meta['format'].lower()}",
            Metadata={
                'width': str(img_meta['width']),
                'height': str(img_meta['height']),
                'md5': md5_hash
            }
        )
        
        # Créer les métadonnées
        metadata = ImageMetadata(
            key=key,
            bucket=self.bucket,
            size_bytes=len(image_bytes),
            width=img_meta['width'],
            height=img_meta['height'],
            format=img_meta['format'],
            mode=img_meta['mode'],
            md5_hash=md5_hash,
            s3_uri=f"s3://{self.bucket}/{key}"
        )
        
        self.ingested.append(metadata)
        return metadata
    
    def get_catalog_df(self) -> pd.DataFrame:
        """Retourne le catalogue sous forme de DataFrame."""
        if not self.ingested:
            return pd.DataFrame()
        return pd.DataFrame([vars(m) for m in self.ingested])
    
    def get_stats(self) -> dict:
        """Retourne les statistiques d'ingestion."""
        return {
            'ingested': len(self.ingested),
            'rejected': len(self.rejected),
            'total': len(self.ingested) + len(self.rejected),
            'success_rate': len(self.ingested) / max(1, len(self.ingested) + len(self.rejected))
        }

print("✅ Pipeline d'ingestion défini")
pythonVoir le code
# ============================================
# Utiliser le pipeline
# ============================================

# Créer un nouveau bucket pour le projet
PROJECT_BUCKET = "project-images"
create_bucket_if_not_exists(PROJECT_BUCKET)

# Initialiser le pipeline
pipeline = IngestionPipeline(s3_client, PROJECT_BUCKET)

# Simuler l'ingestion de 20 images (dont quelques-unes invalides)
print("📤 Ingestion en cours...\n")

for i in range(20):
    # Créer une image (parfois invalide pour tester)
    if i % 7 == 0:  # Simuler une image trop petite
        img = Image.new('RGB', (16, 16), color='red')
    else:
        size = np.random.randint(128, 512)
        arr = np.random.randint(0, 255, (size, size, 3), dtype=np.uint8)
        img = Image.fromarray(arr)
    
    # Convertir en bytes
    buffer = io.BytesIO()
    img.save(buffer, format='JPEG')
    image_bytes = buffer.getvalue()
    
    # Ingérer
    key = f"batch_001/image_{i:04d}.jpg"
    result = pipeline.ingest(image_bytes, key)
    
    status = "✅" if result else "❌"
    print(f"   {status} {key}")

# Afficher les stats
stats = pipeline.get_stats()
print(f"\n📊 Résultats:")
print(f"   ✅ Ingérées: {stats['ingested']}")
print(f"   ❌ Rejetées: {stats['rejected']}")
print(f"   📈 Taux de succès: {stats['success_rate']*100:.1f}%")

# Afficher les rejets
if pipeline.rejected:
    print(f"\n⚠️ Images rejetées:")
    for r in pipeline.rejected:
        print(f"   - {r['key']}: {r['reason']}")
pythonVoir le code
# ============================================
# Sauvegarder le catalogue final
# ============================================

# Obtenir le catalogue
final_catalog = pipeline.get_catalog_df()

if not final_catalog.empty:
    # Sauvegarder en Parquet
    final_catalog.to_parquet('project_catalog.parquet', index=False)
    print("✅ Catalogue sauvegardé: project_catalog.parquet")
    
    # Upload dans MinIO
    with open('project_catalog.parquet', 'rb') as f:
        s3_client.put_object(
            Bucket=PROJECT_BUCKET,
            Key='metadata/catalog.parquet',
            Body=f.read()
        )
    print(f"✅ Catalogue uploadé: s3://{PROJECT_BUCKET}/metadata/catalog.parquet")
    
    # Aperçu
    print(f"\n📊 Aperçu du catalogue:")
    print(final_catalog[['key', 'width', 'height', 'size_bytes']].head())
else:
    print("⚠️ Aucune image ingérée")

2.5 Aller plus loin : Compatibilité Multi-Cloud

Tu maîtrises maintenant MinIO. Bravo ! 🎉

En production, tu auras plusieurs options selon ton contexte :

Option Quand l'utiliser Avantages
MinIO self-hosted Souveraineté, contrôle Coûts maîtrisés, pas de dépendance cloud
AWS S3 Écosystème AWS Intégration native AWS
Azure Blob Écosystème Microsoft Intégration Azure AD
GCP Storage Écosystème Google Intégration BigQuery, Vertex AI

💡 MinIO en prod : De nombreuses entreprises utilisent MinIO en production. Ce n'est pas "juste pour le dev" — c'est une vraie alternative aux clouds publics !

Le même code, différents clouds

Grâce à l'API S3 standardisée, ton code fonctionne partout :

# MinIO (local)
client = boto3.client('s3', endpoint_url='http://localhost:9000', ...)

# AWS S3 (prod)
client = boto3.client('s3')  # Credentials via env vars ou IAM

# Le reste du code est IDENTIQUE !
client.put_object(Bucket='...', Key='...', Body=data)
pythonVoir le code
# ============================================
# Client multi-cloud abstrait
# ============================================

from enum import Enum
from typing import Optional

class CloudProvider(Enum):
    MINIO = "minio"
    AWS = "aws"
    AZURE = "azure"
    GCP = "gcp"

def create_storage_client(
    provider: CloudProvider,
    endpoint_url: Optional[str] = None,
    access_key: Optional[str] = None,
    secret_key: Optional[str] = None
):
    """
    Crée un client de stockage selon le provider.
    
    En production, utilise des variables d'environnement :
    - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
    - AZURE_STORAGE_CONNECTION_STRING
    - GOOGLE_APPLICATION_CREDENTIALS
    """
    
    if provider == CloudProvider.MINIO:
        return boto3.client(
            's3',
            endpoint_url=endpoint_url or 'http://localhost:9000',
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            config=Config(signature_version='s3v4'),
            region_name='us-east-1'
        )
    
    elif provider == CloudProvider.AWS:
        # Utilise les credentials par défaut (env vars, IAM role, etc.)
        return boto3.client('s3')
    
    elif provider == CloudProvider.GCP:
        # GCS avec interopérabilité S3
        return boto3.client(
            's3',
            endpoint_url='https://storage.googleapis.com',
            aws_access_key_id=access_key,  # GCS HMAC key
            aws_secret_access_key=secret_key
        )
    
    else:
        raise ValueError(f"Provider {provider} non supporté avec boto3")

# Exemple d'utilisation
print("📦 Création de clients multi-cloud:\n")

# MinIO (local)
minio_client = create_storage_client(
    CloudProvider.MINIO,
    endpoint_url=MINIO_ENDPOINT,
    access_key=MINIO_ACCESS_KEY,
    secret_key=MINIO_SECRET_KEY
)
print("   ✅ Client MinIO créé")

# AWS (simulé - échouerait sans credentials)
# aws_client = create_storage_client(CloudProvider.AWS)
print("   ⏸️ Client AWS (nécessite credentials)")

print("\n💡 Le même code fonctionne avec tous les providers !")

2.6 Code source et ressources

Structure du projet

module2-data-storage/
├── docker-compose.yml       # Infrastructure (MinIO)
├── requirements.txt         # Dépendances Python
├── src/
│   ├── __init__.py
│   ├── storage_client.py    # Client multi-cloud
│   ├── parquet_utils.py     # Utilitaires Parquet
│   ├── webdataset_utils.py  # Création de shards
│   └── pipeline.py          # Pipeline d'ingestion
└── notebooks/
    └── module2.ipynb        # Ce notebook

Télécharger le code

Exécute la cellule suivante pour télécharger tout le code du module.

pythonVoir le code
# ============================================
# Générer le package téléchargeable
# ============================================

import os
import zipfile

# Créer la structure
os.makedirs('module2-data-storage/src', exist_ok=True)

# requirements.txt
requirements = '''boto3>=1.28.0
minio>=7.1.0
pandas>=2.0.0
pyarrow>=12.0.0
duckdb>=0.8.0
pillow>=9.0.0
'''

with open('module2-data-storage/requirements.txt', 'w') as f:
    f.write(requirements)

# docker-compose.yml
docker_compose = '''version: '3.8'

services:
  minio:
    image: minio/minio:latest
    container_name: minio
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data

volumes:
  minio_data:
'''

with open('module2-data-storage/docker-compose.yml', 'w') as f:
    f.write(docker_compose)

print("✅ Structure du projet créée")
print("\n📦 Pour télécharger, exécute la cellule suivante")
pythonVoir le code
# ============================================
# Créer le ZIP téléchargeable
# ============================================

import zipfile
import base64
from IPython.display import HTML, display

# Créer le ZIP
zip_path = 'module2-data-storage.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
    for root, dirs, files in os.walk('module2-data-storage'):
        for file in files:
            file_path = os.path.join(root, file)
            arcname = file_path  # Garde la structure
            zipf.write(file_path, arcname)

# Créer le lien de téléchargement
with open(zip_path, 'rb') as f:
    zip_data = f.read()
    b64 = base64.b64encode(zip_data).decode()

# Afficher le bouton de téléchargement
html = f'''
<a download="module2-data-storage.zip" 
   href="data:application/zip;base64,{b64}" 
   style="display: inline-block; padding: 10px 20px; 
          background-color: #4CAF50; color: white; 
          text-decoration: none; border-radius: 5px;
          font-weight: bold;">
   📦 Télécharger module2-data-storage.zip
</a>
'''

display(HTML(html))
print(f"\n✅ Archive créée: {zip_path} ({os.path.getsize(zip_path)} bytes)")

🎯 Checkpoint — Quiz

Teste tes connaissances ! Réponds aux questions, puis déroule pour voir les réponses.


Question 1 : Quelle est la différence entre un bucket et un objet ?

A) Un bucket contient des objets, un objet est un fichier avec métadonnées
B) Un objet contient des buckets
C) C'est la même chose
D) Un bucket est un format de fichier

📖 Voir la réponse

Réponse : A

Concept Définition Analogie
Bucket Conteneur de premier niveau Dossier racine
Objet Fichier + métadonnées Fichier enrichi
Clé Identifiant unique de l'objet Chemin : 2024/01/img.jpg

Question 2 : Pourquoi MinIO est-il compatible S3 ?

A) Parce qu'il est développé par Amazon
B) Parce qu'il implémente la même API que S3
C) Parce qu'il utilise les serveurs AWS
D) Ce n'est pas compatible S3

📖 Voir la réponse

Réponse : B

MinIO implémente l'API S3 d'Amazon. Ton code utilise boto3 (client S3) et fonctionne identiquement sur :

  • MinIO local
  • AWS S3
  • Google Cloud Storage (mode S3)

Seul l'endpoint_url change. Le reste du code est identique !


Question 3 : Quel est l'avantage principal du format Parquet ?

A) Il est plus facile à lire pour les humains
B) Il stocke les données en colonnes, permettant des requêtes rapides
C) Il est compatible avec Excel
D) Il ne prend aucune place sur le disque

📖 Voir la réponse

Réponse : B

Parquet est un format colonne (vs CSV qui est en lignes).

Requête CSV (lignes) Parquet (colonnes)
SELECT width FROM catalog Lit TOUT le fichier Lit SEULEMENT la colonne width
Performance Lent 10-100x plus rapide

En plus, Parquet compresse très bien (5-10x plus petit que CSV).


Question 4 : WebDataset regroupe les fichiers en archives TAR. Quel est l'avantage ?

A) C'est plus joli
B) Ça réduit le nombre de requêtes réseau (1 requête par shard au lieu de 1 par image)
C) Ça augmente la qualité des images
D) C'est obligatoire pour le training

📖 Voir la réponse

Réponse : B

Avec 1 million d'images :

  • Sans WebDataset : 1 000 000 requêtes réseau (lent, coûteux)
  • Avec WebDataset : ~1 000 requêtes (1 par shard de 100MB)

Le streaming séquentiel est aussi plus efficace pour le training : le GPU n'attend jamais.


Question 5 : Pourquoi créer un catalogue de métadonnées ?

A) Pour décorer le projet
B) Pour répondre à des questions sur le dataset sans télécharger les images
C) C'est obligatoire pour MinIO
D) Pour compresser les images

📖 Voir la réponse

Réponse : B

Question Sans catalogue Avec catalogue
"Combien d'images ?" Lister 1M objets (10 min) SELECT COUNT(*) (0.01s)
"Images > 1MB ?" Télécharger chaque image WHERE size > 1000000 (0.05s)
"Dimensions moyennes ?" Ouvrir chaque image AVG(width), AVG(height) (0.1s)

Question 6 : À quoi sert le hash MD5 dans un pipeline d'ingestion ?

A) À compresser les fichiers
B) À identifier de manière unique le contenu et détecter les duplicatas
C) À chiffrer les données
D) À accélérer le téléchargement

📖 Voir la réponse

Réponse : B

Le hash MD5 est une "empreinte digitale" du fichier :

  • Même contenu = même hash (toujours)
  • Contenu différent = hash différent

Utilisation :

  • Détecter les duplicatas exacts : Si hash existe déjà → skip
  • Vérifier l'intégrité : Hash avant/après transfert doit être identique
  • Créer des clés uniques : {hash}.jpg au lieu de image_001.jpg

➡️ Prochaine étape

Module 3 : Transformer les données

Tu vas :

  • Maîtriser les transformations d'images (resize, normalize, convert)
  • Utiliser Spark pour traiter à grande échelle
  • Implémenter le Schema Enforcement avec Pydantic
  • Appliquer des contrôles qualité (corruption, doublons)
  • Créer de la data augmentation

Module Data Engineering for AI — From Zero to Hero Bootcamp