A model with 97% accuracy that only exists in your Python session is worth exactly zero to the business. The TecnoMarket team is clear about this: the review classifier from 04-03 only delivers value when the website can query it live, and the photo classifier from 05-03 only saves work when the warehouse system invokes it for every new product. This lesson closes the cycle that began with that timid model.save() in 02-05: you will learn the saving formats of Keras and PyTorch and when to use each, why the preprocessing must be saved together with the model, the options for serving predictions (from a batch script to a REST API with FastAPI, with a complete example), ONNX as a bridge between frameworks, and how to validate and watch over a model before and after deploying it. It is the last mile: where deep learning becomes a product.

Contents

  1. Saving and loading in Keras: .keras, SavedModel and weights-only
  2. Saving and loading in PyTorch: state_dict and checkpoints
  3. The model does not travel alone: saving the preprocessing
  4. Serving a model: options at scale
  5. A REST API with FastAPI for TecnoMarket's reviews
  6. ONNX: the bridge format
  7. Validating before deploying and monitoring afterwards
  8. TecnoMarket's deployment checklist

Saving and loading in Keras: .keras, SavedModel and weights-only

In 02-05 you ran model.save("tecnomarket-dl/models/mnist_dense_v1.keras") and moved on. Let's make it professional: Keras has three ways to save, each with its use case.

from tensorflow import keras

# 1. .keras format (the recommended one): EVERYTHING in one file
model.save("models/reviews_v3.keras")
loaded_model = keras.models.load_model("models/reviews_v3.keras")
# Saves: architecture + weights + optimizer state + compile()
# -> you can keep training exactly where you left off

# 2. SavedModel (TensorFlow's format, a complete directory)
model.export("models/reviews_v3_savedmodel")
# Saves the self-contained computation graph, WITHOUT needing your Python code
# -> it is what TF Serving and the converters (TFLite) consume

# 3. Weights only
model.save_weights("models/reviews_v3.weights.h5")
new_model = create_model()                  # you need to REBUILD the architecture
new_model.load_weights("models/reviews_v3.weights.h5")
Format What it saves When to use it
.keras Architecture + weights + optimizer General use: pause/resume, archive, share with anyone who has Keras
SavedModel (export) Self-contained graph for inference Handing off to TF production systems (Serving, TFLite)
Weights only Just the learned numbers The code defines the architecture (transfer learning as in 05-03, lightweight checkpoints)

Practical note: the ModelCheckpoint from 06-01 writes in .keras format; you already had half this lesson learned.

Saving and loading in PyTorch: state_dict and checkpoints

PyTorch revolves around the state_dict: a {parameter_name: tensor} dictionary with all the model's weights. The canonical way to save is that dictionary, not the model object:

import torch

# SAVE: the recommended way (only the state_dict)
torch.save(model.state_dict(), "models/reviews_v3.pt")

# LOAD: rebuild the architecture and pour the weights into it
model = ReviewClassifier()                          # the class from 06-02
model.load_state_dict(torch.load("models/reviews_v3.pt"))
model.eval()                                        # into inference mode! (see 06-02)

Why not save the whole model with torch.save(model, ...)? It can be done, but it serializes references to your code (class, module, paths): loading it months later with the project reorganized, it breaks. The state_dict is just named tensors: robust and portable. It is the analog of Keras's "weights only" — in PyTorch, the default option.

Full checkpoint: pausing and resuming training

To resume a training run, the weights are not enough: Adam (02-04) keeps per-parameter internal statistics that must be restored too. The professional pattern:

# Save a full checkpoint at the end of every epoch
checkpoint = {
    "epoch": epoch,
    "model_state": model.state_dict(),
    "optimizer_state": optimizer.state_dict(),   # Adam's state too!
    "val_loss": val_loss,
}
torch.save(checkpoint, "models/checkpoint_latest.pt")

# Resume days later, exactly where it stopped
ckpt = torch.load("models/checkpoint_latest.pt")
model.load_state_dict(ckpt["model_state"])
optimizer.load_state_dict(ckpt["optimizer_state"])
start_epoch = ckpt["epoch"] + 1
Need Keras PyTorch
Archive/share a ready model .keras state_dict + the class's code
Resume training .keras (carries the optimizer) checkpoint dict (model + optimizer + epoch)
Hand off to production SavedModel state_dict (or export to ONNX/TorchScript)

The model does not travel alone: saving the preprocessing

Here is the mistake that breaks the most deployments, and it is conceptual, not technical. Your model learned from transformed data: if in production it receives raw data, or data transformed differently, its predictions will be garbage without raising any error. Remember two cases from the course:

  • In 04-04, the demand predictor trained on sales scaled by a scaler fitted on the training set (carefully avoiding data leakage). If in production you feed it raw euros where it expected values in [0, 1], it will predict nonsense with complete self-confidence.
  • In 04-03, the review classifier uses a TextVectorization layer whose vocabulary was learned from the training corpus. A different vocabulary = different indices = a different "language": the network would receive gibberish.

The golden rule: the (preprocessing, model) pair IS the deployable unit. They never travel separately.

# Option A (Keras): put the preprocessing INSIDE the model before saving
# (TextVectorization is a layer: it can be part of the graph)
full_model = keras.Sequential([
    keras.Input(shape=(1,), dtype="string"),   # RAW text goes in!
    vectorizer,                                # the already-adapted TextVectorization (04-03)
    trained_model,                             # the network that expects indices
])
full_model.save("models/reviews_full.keras")
# In production: model.predict(["The charger arrived broken"])  -> straight from text

# Option B (general, also valid for PyTorch): save the transformer separately
import joblib
joblib.dump(scaler, "models/demand_scaler.joblib")    # the scaler from 04-04
# ...and in production, ALWAYS as a pair:
scaler = joblib.load("models/demand_scaler.joblib")
x = scaler.transform(raw_data)                        # same scaler, same parameters
prediction = model.predict(x)

Option A is the safest (impossible to desynchronize); B is the most flexible. Whichever you choose: same files versioned together, under the same version name.

Serving a model: options at scale

"Deploying" does not always mean "standing up a server". There is a scale of options, from least to most complex, and the professional move is choosing the smallest one that covers the need:

Option How it works TecnoMarket case
Batch script A script runs periodically, predicts over what has accumulated and stores the results Weekly demand forecasting (04-04): no real time needed
REST API A server exposes the model over HTTP; other applications request predictions on the spot Classifying each review as it is posted (04-03)
Model server (TF Serving / TorchServe) Specialized software: coexisting versions, automatic batching, high throughput When requests per second overwhelm the handcrafted API
Edge (TFLite) The model, compressed, runs ON the device, no network The photo classifier in the warehouse app (the plan from 06-03)

A minimal batch script (sometimes the best architecture is the most boring one):

# predict_weekly_demand.py -- scheduled to run every Monday
import joblib
from tensorflow import keras

model = keras.models.load_model("models/demand_v2.keras")
scaler = joblib.load("models/demand_scaler_v2.joblib")    # ALWAYS as a pair

sales = load_recent_weekly_sales()                # from the database
pred = model.predict(scaler.transform(sales))
save_predictions(inverse_scale(pred))             # to the table purchasing reads

TF Serving and TorchServe stay noted on your radar: they are the natural evolution when scale starts to squeeze, and they consume precisely the "production" formats from the first part (SavedModel and packaged state_dict).

A REST API with FastAPI for TecnoMarket's reviews

Let's serve the review classifier live. FastAPI is a modern, minimalist Python web framework, ideal for wrapping a model. Installation: pip install fastapi uvicorn.

# review_api.py
from fastapi import FastAPI
from pydantic import BaseModel
from tensorflow import keras

app = FastAPI(title="TecnoMarket - Review analysis")

# 1. Load the model ONCE, when the server starts (not on every request)
#    It is the "full" model with TextVectorization inside (option A above)
model = keras.models.load_model("models/reviews_full.keras")
REVIEW_THRESHOLD = 0.65   # the confidence threshold from the 03-04 pipeline

class Review(BaseModel):        # 2. input contract: JSON with a "text" field
    text: str

@app.post("/classify")          # 3. endpoint: POST /classify
def classify(review: Review):
    prob = float(model.predict([review.text], verbose=0)[0][0])   # 4. inference
    sentiment = "positive" if prob >= 0.5 else "negative"
    confidence = max(prob, 1 - prob)
    return {                    # 5. JSON response for the website/CRM
        "sentiment": sentiment,
        "confidence": round(confidence, 3),
        "human_review": confidence < REVIEW_THRESHOLD,    # human review queue (03-04)
        "model_version": "reviews_v3",
    }

Explanation of the decisions:

  1. Load at startup: loading the model takes seconds; a prediction, milliseconds. Loading it on every request would kill the API. The load goes at module level.
  2. Pydantic (BaseModel) defines and validates the input: if a JSON without text arrives, FastAPI returns a clear error automatically, without the model seeing anything.
  3. The endpoint is POST because it sends data in the request body.
  4. Thanks to option A (vectorizer inside), the API receives raw text: zero risk of desynchronized preprocessing.
  5. The response includes the model version (traceability) and the human_review field: the same confidence threshold + human review queue pattern we designed in 03-04, now in production.

Start it and try it:

uvicorn review_api:app --port 8000
# Automatic interactive documentation: http://localhost:8000/docs

curl -X POST http://localhost:8000/classify \
     -H "Content-Type: application/json" \
     -d '{"text": "The charger arrived broken and nobody responds"}'
# {"sentiment":"negative","confidence":0.94,"human_review":false,"model_version":"reviews_v3"}

With this, any TecnoMarket system (the website, the customer service CRM) consumes the model with a simple HTTP request, knowing nothing about deep learning.

ONNX: the bridge format

In 06-03 we noted down ONNX (Open Neural Network Exchange): a standard model interchange format. The idea: you train wherever you like, export to .onnx, and run with ONNX Runtime almost anywhere (Python, C#, Java, mobile...), without installing the source framework.

# Export the PyTorch review model (06-02) to ONNX
import torch

model.eval()
example = torch.randn(1, 200)                      # example input (defines shapes)
torch.onnx.export(model, example, "models/reviews.onnx",
                  input_names=["review"], output_names=["logit"])

# Run it WITHOUT PyTorch, only with onnxruntime (pip install onnxruntime)
import onnxruntime as ort
import numpy as np

session = ort.InferenceSession("models/reviews.onnx")
output = session.run(None, {"review": np.random.rand(1, 200).astype("float32")})
print(output[0])   # the logit, identical to what the original model would give

When it pays off: handing a model to a team working in another language or framework, unifying inference across models of different origins (exactly TecnoMarket's case after the 06-03 decision: Keras in production, PyTorch in exploration), or exploiting ONNX Runtime's inference optimizations. Minimal good practices: after exporting, compare the outputs of the original model and the ONNX one on a test batch (they must match up to decimals) — and remember that external preprocessing still travels separately, here too.

Validating before deploying and monitoring afterwards

Deploying is not an act of faith; it is a process with two guards:

Before: validation on a frozen set. The team maintains a fixed and versioned test set (real reviews labeled by hand, never used for training — the anti-leakage discipline from 04-04 elevated to team policy). Every production candidate, whether Keras or PyTorch (the rule agreed in 06-03), must:

  1. Match or beat the current production model's metrics on that set.
  2. Also be checked on the critical segments (short reviews, new products): a model that is better "on average" can be worse exactly where it hurts most.
  3. Pass a smoke test of the complete deployable unit: spin up the API with the final artifact and verify known predictions end to end.

Afterwards: monitoring data drift. The world changes and production data slowly moves away from the training data: TecnoMarket launches new categories, review vocabulary evolves ("BLE pairing doesn't work" did not exist in the original corpus). Performance decays in silence: no exception, no error log, just increasingly worse predictions. Minimal conceptual watch:

  • Log every prediction with its confidence and the model version (that is why the API returns it).
  • Watch indirect signals: the percentage of cases sent to human review rising, the confidence distribution shifting, and the human reviewers' corrections as a continuous sampling of the truth.
  • Define the action threshold in advance: "if X worsens by more than Y for Z weeks, retrain with recent data" — and the retrained model goes through the frozen set again. The cycle closes.

TecnoMarket's deployment checklist

The list the team goes through before every production release:

  1. Model saved in the right format (.keras / state_dict) with a version name (reviews_v3), not final_model_GOOD.
  2. Preprocessing saved and versioned together with the model (inside the graph or as a paired artifact).
  3. Metrics on the frozen set ≥ the current model, critical segments included.
  4. Random seeds, config and library versions recorded (the reproducibility from 06-04): this model can be rebuilt from scratch.
  5. Smoke test of the deployable unit: API spun up locally, known predictions verified end to end.
  6. The API response includes the model version and a confidence signal; doubtful cases go to the human review queue (03-04).
  7. Active monitoring: prediction logging and drift alerts defined before launch, not after the first scare.
  8. Rollback plan: the previous version (reviews_v2) stays archived and ready to restore in minutes.

Common Mistakes and Tips

  • Deploying the model without its preprocessing (or with a different version of it): the number one silent failure. No error, just absurd predictions with high confidence. The deployable unit is the pair, always.
  • torch.save(model, ...) of the whole object: works today, breaks when you refactor the project. State_dict + the class's code, as a rule.
  • Forgetting model.eval() when loading in PyTorch for inference: with dropout active, every request to your API would give a different prediction for the same review (you saw it in 06-02; in production it is a baffling bug).
  • Loading the model inside the endpoint: every request would take seconds and the API would crawl. Load once at startup; predict many times.
  • Validating on a test set that changes: if every candidate is measured against different data, the comparisons mean nothing. Frozen and versioned set.
  • Trusting that "if there are no errors, it works": degradation from data drift throws no exceptions. Without monitoring, your model may have been wrong for months by the time someone notices.
  • Tip: after every save, do the full cycle in a clean interpreter: load, predict on 3 known examples, compare with the expected values. Thirty seconds that catch 90% of serialization problems.

Exercises

Exercise 1: choosing a format

For each situation, state the appropriate saving mechanism and why: (a) module 7's PyTorch training will take three nights on Colab and must be resumable every morning; (b) the Keras photo classifier must be handed to the platform team to serve it with TF Serving; (c) you want to archive the Keras review classifier to pick it up again (even retrain it) in six months; (d) the backend team, which programs in C# and does not want to install PyTorch, needs to run the fraud model.

Exercise 2: the deployment that predicts nonsense

The demand predictor from 04-04 is deployed as a batch script. In testing it had an 8% mean error; in production it predicts absurd quantities (thousands of units of a product that sells dozens), with no error in the logs. The script loads demand_v2.keras correctly and the database delivers the sales properly. What is the most likely cause, why does no error appear, and which two changes would you make to fix it and to keep it from happening again?

Exercise 3: extending the API

Extend review_api.py with a POST /classify_batch endpoint that receives a list of reviews (up to 100) and returns a list of results, taking advantage of the fact that the model predicts more efficiently in batches than one at a time. Also add a GET /health endpoint that returns {"status": "ok", "model_version": "reviews_v3"} and explain what it is for in production.

Solutions

Solution 1:

  • (a) Full PyTorch checkpoint (dict with model_state, optimizer_state and epoch) saved to Drive every epoch: resuming requires Adam's state, not just weights.
  • (b) SavedModel (model.export(...)): it is the self-contained format TF Serving consumes directly.
  • (c) .keras: a single file with architecture, weights and optimizer; in six months, load_model and carry on (retraining included).
  • (d) ONNX: export with torch.onnx.export and deliver the .onnx; the backend runs it with ONNX Runtime for C#, no PyTorch. (After verifying the outputs match the original.)

Solution 2: Most likely cause: the script applies the scaler wrongly (or not at all) — it uses one refitted on other data, a desynchronized version, or feeds raw sales to a model that expected scaled values; forgetting the inverse transform of the output also fits. There is no error because the shapes and dtypes are valid: the model operates on "legal" numbers but at the wrong scale — the classic silent failure. Fixes: (1) deploy the pair as a unit — load demand_scaler_v2.joblib versioned alongside the model, same version suffix, and apply transform to the input and the inverse to the output; (2) add a smoke test to the checklist: after each deployment, predict on 3 known historical cases and compare with the expected values before writing anything to the purchasing table (plus a range validation: if the prediction falls outside the reasonable historical range, alert and do not publish).

Solution 3:

from typing import List
from pydantic import BaseModel, Field

class ReviewBatch(BaseModel):
    texts: List[str] = Field(..., max_length=100)    # validates the limit of 100

@app.post("/classify_batch")
def classify_batch(batch: ReviewBatch):
    probs = model.predict(batch.texts, verbose=0)[:, 0]   # ONE batched pass
    results = []
    for text, prob in zip(batch.texts, probs):
        prob = float(prob)
        confidence = max(prob, 1 - prob)
        results.append({
            "sentiment": "positive" if prob >= 0.5 else "negative",
            "confidence": round(confidence, 3),
            "human_review": confidence < REVIEW_THRESHOLD,
        })
    return {"model_version": "reviews_v3", "results": results}

@app.get("/health")
def health():
    return {"status": "ok", "model_version": "reviews_v3"}

The efficiency key: a single call to model.predict with the 100 reviews (the batch parallelism running through the whole course), not 100 calls. The /health endpoint (health check) lets the infrastructure verify every few seconds that the API is alive and which version it serves: if it stops responding, it gets restarted or an alert fires automatically — and during a deployment it confirms the new version is actually live.

Conclusion

The circle opened by the model.save() of 02-05 closes. You now know how to save with judgment (.keras/SavedModel/weights in Keras; state_dict and checkpoints in PyTorch), you know the deployable unit is the preprocessing + model pair (the scaler from 04-04 and the vocabulary from 04-03 always travel with their network), you know how to choose the serving approach for the need (batch → FastAPI → model servers → edge), you have ONNX as a bridge between worlds, and you understand that deploying requires validating against a frozen set beforehand and monitoring data drift afterwards — all condensed into TecnoMarket's checklist.

And with this, module 6 is complete: you know TensorFlow from the inside, you speak PyTorch, you know how to choose between them, you work with a professional environment and discipline, and you take models all the way to production. Techniques (modules 2-5) and tools (module 6): the toolbox is full. In module 7 we will empty it entirely: we will build TecnoMarket's complete projects end to end — image classification, text generation, anomaly detection, a GAN and the fine-tuning of a pretrained model. It stops being a course; it starts being a portfolio.

© Copyright 2026. All rights reserved