Chapitre 04
nlmixr2 — le modèle d'erreur résiduelle et le BLQ
Racym Berrah, PharmD PMx Explain Relecture scientifique par l'auteur : ORCID
Déclarer l'erreur dans model() : add, prop, pow, lnorm et leurs combinaisons ; les valeurs initiales dans ini(), le BLQ par les colonnes CENS et LIMIT, les diagnostics ggPMX et xpose.nlmixr2.
1 activités guidées01 · Pourquoi ce chapitre
En nlmixr2, le modèle d'erreur résiduelle tient dans la dernière ligne de model(), et cette ligne ne ressemble à aucune autre : elle porte un tilde, pas une flèche. Ce n'est pas une coquetterie de syntaxe. Toutes les lignes au-dessus calculent quelque chose ; celle-ci déclare comment ce quelque chose a été observé.
Elle décide pourtant de l'essentiel, car chaque observation entre dans la vraisemblance divisée par son écart-type résiduel. Un point que vous déclarez précis tire fort sur la courbe ; un point que vous déclarez bruité ne pèse presque rien. Écrire cette ligne, ce n'est pas décrire du bruit : c'est distribuer les poids de l'estimation.
Deux choses distinguent nlmixr2 sur ce terrain, et il faut connaître les deux.
La bonne : nlmixr2 estime des écarts-types, directement dans les unités de la mesure. Pas de bloc SIGMA, pas de variance à fixer à 1, pas de W à écrire à la main. Tout ce que le chapitre NONMEM obtient au prix d'une paramétrisation astucieuse est ici le comportement par défaut.
La mauvaise : une partie du sens de votre ligne d'erreur ne se trouve pas dans le modèle. Elle se trouve dans l'objet de contrôle passé à l'estimateur. Un modèle nlmixr2 lu seul est, sur ce point précis, ambigu — et c'est le piège de ce chapitre.
02 · Intuition
Lisez cp ~ add(add.err) comme une phrase française : « cp est observée avec une erreur additive, dont l'écart-type s'appelle add.err ». Le tilde est exactement celui de lm(y ~ x) : à gauche ce qu'on observe, à droite le modèle de cette observation. nlmixr2 emprunte la grammaire de R plutôt que d'en inventer une.
Cette lecture a une conséquence pratique immédiate. cp = centr/v est une affectation : après cette ligne, cp vaut quelque chose. cp ~ add(add.err) ne change la valeur de rien du tout — elle branche cp sur la colonne d'observations et dit à l'estimateur quelle barre d'erreur mettre autour. Une fonction model() sans ligne à tilde ne définit aucune vraisemblance ; ce n'est plus un modèle à estimer, c'est un simulateur.
Reste à répondre à la seule question que pose le modèle d'erreur : quelle largeur a la barre, et comment varie-t-elle avec la concentration ? Un dosage réel a deux régimes de bruit, sans rapport l'un avec l'autre :
- un plancher, en mg/L, indifférent à la concentration — bruit de fond, ligne de base, tout ce qui subsiste quand il n'y a presque plus rien à mesurer. C'est
add(); - un pourcentage, qui grandit avec la concentration — dilutions, pipetage, calibration. C'est
prop().
Un profil de PK couvre couramment deux ou trois ordres de grandeur, du pic au dernier creux. Il traverse donc les deux régimes, et aucune forme à un seul terme n'est juste sur toute la gamme. C'est très exactement pourquoi add() + prop() est un candidat fréquent, à confirmer sur les résidus.
03 · La formule décortiquée
Toutes les formes de nlmixr2 se rangent sous une écriture unique :
La ligne à tilde ne fait que choisir , la fonction d'écart-type.
déclaration dans model() |
ce que ça dit | |
|---|---|---|
cp ~ add(a) |
plancher seul, en mg/L | |
cp ~ prop(b) |
pourcentage seul | |
cp ~ pow(b, c) |
puissance, exposant estimé | |
cp ~ add(a) + prop(b) |
||
| c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 | ||
| c340,-704.7,510.7,-1060.3,512,-1067 | ||
| l0 -0 | ||
| c4.7,-7.3,11,-11,19,-11 | ||
| H40000v40H1012.3 | ||
| s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 | ||
| c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 | ||
| s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 | ||
| c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z | ||
| M1001 80h400000v40h-400000z"/> ou | combiné — voir plus bas | |
cp ~ add(a) + pow(b, c) |
||
| c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 | ||
| c340,-704.7,510.7,-1060.3,512,-1067 | ||
| l0 -0 | ||
| c4.7,-7.3,11,-11,19,-11 | ||
| H40000v40H1012.3 | ||
| s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 | ||
| c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 | ||
| s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 | ||
| c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z | ||
| M1001 80h400000v40h-400000z"/> ou | combiné à exposant libre | |
cp ~ lnorm(s) |
erreur exponentielle |
prop(b) n'est que le cas de pow(b, c). Laisser l'exposant libre revient à demander aux données où se situe le régime réel du dosage entre le plancher pur () et le pourcentage pur () ; c'est souvent un paramètre mal identifié, à ne sortir que si la gamme est large et les données abondantes.
lnorm(s) donne : support strictement positif, distribution asymétrique à droite. Comme pour petit, le modèle se comporte comme un proportionnel de CV , mais sans jamais rendre de valeur négative. Le paramètre s vit sur l'échelle log : il est sans dimension. Le lire comme des mg/L produit un nombre plausible et faux. Corollaire : lnorm() produit déjà une erreur proportionnelle à la prédiction — lui empiler un prop() compte deux fois le même effet.
Le bloc ini() : deux opérateurs, deux rôles.
mod <- function() {
ini({
tka <- log(1.1)
tcl <- log(2.8)
tv <- log(32)
eta.cl ~ 0.09 # effet aleatoire : variance
eta.v ~ 0.04
add.err <- c(0, 0.03) # ecart-type, mg/L, borne inferieure a 0
prop.err <- c(0, 0.12) # ecart-type relatif, fraction
})
model({
ka <- exp(tka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) = -ka*depot
d/dt(centr) = ka*depot - (cl/v)*centr
cp = centr/v
cp ~ add(add.err) + prop(prop.err) + combined2()
})
}
fit <- nlmixr2(mod, data, est = "saem")
Le chapitre précédent a posé la règle : dans ini(), le ~ déclare un effet aléatoire par sa variance, le <- déclare un effet fixe. Les paramètres résiduels n'ont pas de troisième voie — ils prennent le <-, exactement comme les theta. Écrire add.err ~ 0.03 ne produit donc pas un terme résiduel : cela crée un ETA nommé add.err dont plus personne ne se sert. C'est la faute de débutant classique, et elle ne fait pas planter grand-chose.
Ce qui mène au point de nommage. nlmixr2 n'a pas de bloc réservé aux paramètres d'erreur. add.err et prop.err sont des noms de convention, pas des mots-clés : appelez-les sigma.floor et cv.assay, rien ne change. Un scalaire déclaré dans ini() devient un paramètre résiduel parce qu'il est consommé par une fonction d'erreur dans la ligne à tilde, et pour aucune autre raison. Le rôle vient de l'usage, pas de la déclaration.
Deux écritures utiles dans ini() : les bornes, add.err <- c(0, 0.03) = borne inférieure 0 et valeur initiale 0,03 (la forme à trois éléments ajoute une borne supérieure) ; et le gel, add.err <- fixed(0.05), pour imposer un plancher de bruit connu du laboratoire au lieu de l'estimer — utile quand les données basses sont trop rares pour l'informer.
Le combiné, et où vit le choix. Deux façons d'assembler un plancher et un pourcentage :
combined2 est la forme qui découle de deux sources de bruit indépendantes : indépendance, donc les variances s'additionnent. C'est celle qui a une justification statistique, c'est l'équivalent exact du Y = F + F*EPS(1) + EPS(2) de NONMEM, et c'est le défaut de nlmixr2. combined1 additionne les écarts-types : aucune paire de sources indépendantes ne produit cela, c'est une paramétrisation commode, rien de plus. Les deux ne diffèrent notablement qu'autour de , où les régimes s'équilibrent — au plus d'un facteur .
Le point nlmixr2, lui, n'est pas mathématique. Il est que ce choix se règle aussi par l'option addProp de l'objet de contrôle, hors du modèle :
nlmixr2(mod, data, est = "focei",
control = foceiControl(addProp = "combined1"))
Écrire + combined2() explicitement sur la ligne à tilde, comme dans le code plus haut, ancre le choix dans le modèle et le rend insensible au contrôle. Ce n'est pas une décoration : voyez le piège fréquent.
04 · Exemple concret
Un inhibiteur de kinase oral, 42 patients, 336 concentrations, de 12 mg/L au pic jusqu'à une LOQ à 0,10 mg/L — plus de deux ordres de grandeur. Le SAEM tourne sur le modèle ci-dessus et rend le tableau des paramètres de population :
| paramètre | Est. | %RSE | rétro-transformé |
|---|---|---|---|
tka |
0,10 | 14 | = 1,11 h⁻¹ |
tcl |
1,03 | 5,2 | = 2,80 L/h |
tv |
3,47 | 3,1 | = 32,1 L |
add.err |
0,031 | 9,4 | 0,031 mg/L |
prop.err |
0,118 | 4,6 | 11,8 % |
Trois choses se lisent directement, sans calcul intermédiaire.
Les unités. add.err = 0,031 mg/L est un plancher de bruit, prop.err = 0,118 un CV de 11,8 %. Ce sont des écarts-types : la même erreur exprimée en NONMEM afficherait un $SIGMA de 0,000961 et 0,0139, et il faudrait deux racines carrées pour retrouver ces chiffres.
La cohérence. Le repère coûte deux secondes : add.err doit atterrir dans le voisinage du bruit du dosage, donc du même ordre que la LOQ. Ici 0,031 pour une LOQ à 0,10 — cohérent. Si le SAEM vous rend add.err = 0,9 mg/L avec la même LOQ, le paramètre ne mesure plus le dosage : il éponge un défaut structural. Regardez la courbe avant d'accepter le chiffre.
Le point de bascule. Il est en mg/L. Au-dessus, le pourcentage commande ; au-dessous, le plancher. Un creux prédit à 0,15 mg/L reçoit mg/L. Sous prop(prop.err) seul, il aurait reçu mg/L : le modèle affirmerait résoudre cette concentration à 18 ng/mL près, six fois mieux que sa propre LOQ. C'est ce que le terme additif empêche.
Le BLQ. Le laboratoire renvoie 47 points sur 336 (14 %) sous la LOQ, presque tous des creux tardifs. Une observation « < LOQ » n'est ni une valeur manquante ni un nombre : c'est une inégalité, et souvent la seule information dont vous disposiez sur la phase terminale.
nlmixr2 la prend au mot, dans le jeu de données, pas dans le modèle. Deux colonnes suffisent : CENS marque la ligne (0 = observation ordinaire, 1 = censurée à gauche, -1 = censurée à droite) et LIMIT fournit l'autre borne de l'intervalle. Sur une ligne censurée, la colonne DV porte la LOQ elle-même.
ID TIME DV AMT EVID CMT CENS LIMIT
7 0.0 . 200 1 depot 0 .
7 1.0 3.42 . 0 cp 0 .
7 8.0 0.61 . 0 cp 0 .
7 24.0 0.10 . 0 cp 1 . <- BQL : DV porte la LOQ
La ligne censurée n'entre plus par une densité mais par une probabilité :
C'est la méthode M3 de Beal, obtenue en remplissant une colonne. Ajouter LIMIT = 0 sur ces mêmes lignes borne l'intervalle à au lieu de : c'est M4. Une colonne sépare les deux. Là où NONMEM réclame un F_FLAG, un PHI codé à la main et un LAPLACIAN, la censure est ici un attribut de la donnée — et le coût d'entrée de M3 étant nul, il n'y a plus d'excuse pour un LOQ/2.
Ce que ça change sur ce jeu, en refaisant tourner les deux :
| (L/h) | add.err (mg/L) |
%RSE de add.err |
|
|---|---|---|---|
| M1 — les 47 points écartés | 2,55 | 0,009 | 71 |
M3 — CENS = 1 |
2,80 | 0,031 | 9,4 |
Neuf pour cent d'écart sur la clairance, et ce n'est pas un hasard : les BQL ne manquent pas au hasard. À un temps tardif donné, seuls survivent les patients dont la concentration est au-dessus de la moyenne. On ne supprime pas du bruit, on tronque la queue par le bas — la pente terminale paraît plus plate et sort sous-estimée.
Le tell est dans la dernière colonne. Sans les BQL, add.err n'a plus aucune donnée basse pour l'informer : il s'effondre à 0,009 mg/L et son %RSE passe à 71. nlmixr2 vous imprime gratuitement le signe que le paramètre n'est plus identifié. Un %RSE à trois chiffres sur un terme résiduel additif est presque toujours cette histoire-là.
Les diagnostics. L'IWRES est le résidu divisé par l'écart-type que le modèle d'erreur revendique, donc le seul graphique qui juge :
Si est juste, le nuage est centré sur 0, d'écart-type 1, et de même largeur partout. D'où la règle de lecture : IWRES contre prédictions — pas contre le temps — et on lit la largeur, pas le centre. Nuage qui s'ouvre vers la droite : trop petit en haut de gamme, il manque le pourcentage. Nuage qui se resserre vers la droite : trop petit en bas de gamme, il manque le plancher. Nuage courbé contre le temps : cela ne parle plus du modèle d'erreur mais du modèle structural.
Le fit est un objet R, donc les deux écosystèmes de diagnostic s'y branchent directement :
fit <- addCwres(fit) # CWRES/CPRED : non calcules par defaut apres un SAEM
fit <- addNpde(fit) # NPDE, par simulation depuis le modele de population
library(xpose.nlmixr2)
xpdb <- xpose_data_nlmixr2(fit)
absval_res_vs_pred(xpdb, res = "IWRES") # la largeur, pas le centre
dv_vs_ipred(xpdb)
library(ggPMX)
ctr <- pmx_nlmixr(fit)
pmx_plot_iwres_ipred(ctr)
pmx_plot_npde_time(ctr)
pmx_report(ctr, name = "diag", save_dir = ".", format = "html")
vpcPlot(fit, n = 500)
sd(fit$IWRES) # a lire AVANT la forme du nuage
05 · Piège fréquent
Reprenez la ligne d'erreur la plus banale de nlmixr2, écrite sans modificateur :
cp ~ add(add.err) + prop(prop.err)
Cette ligne ne dit pas comment les deux termes se combinent. Elle nomme deux paramètres et s'arrête là. Le reste — somme des variances ou somme des écarts-types — se décide dans l'objet de contrôle, par l'option addProp, dont le défaut est "combined2" :
fit1 <- nlmixr2(mod, data, est = "focei")
fit2 <- nlmixr2(mod, data, est = "focei",
control = foceiControl(addProp = "combined1"))
Même fonction mod, même jeu de données, deux modèles d'erreur différents. Sur le jeu de la section précédente, fit1 rend add.err = 0,031 et prop.err = 0,118 ; fit2 rend 0,021 et 0,109 — les estimations se déplacent pour compenser la forme plus large de combined1. Et l'OFV bouge de deux ou trois points, c'est-à-dire de rien : les deux formes ne diffèrent qu'au voisinage de , donc elles ajustent presque pareil. Rien dans la sortie ne crie.
Le principe déborde largement le cas d'addProp : en nlmixr2, tout ce qui vit dans le contrôle échappe au modèle. C'est vrai aussi de l'OFV.
06 · À retenir
- La ligne à tilde de
model()ne calcule rien : elle déclare comment la prédiction est observée. Sans elle, pas de vraisemblance — juste un simulateur. - est le poids de chaque point dans la vraisemblance, pas une description du bruit. Il arbitre quelle partie du profil le SAEM a le droit de rater.
- Les formes :
add(a);prop(b);pow(b,c);lnorm(s), oùsest un CV sur l'échelle log, pas des mg/L. - nlmixr2 estime des écarts-types dans les unités de la mesure :
prop.err= 0,118 se lit « CV de 11,8 % », sans racine carrée. Pas deSIGMA, pas de1 FIX, pas deW— le confort que NONMEM demande de construire à la main. ini():~pour un effet aléatoire,<-pour tout le reste. Un scalaire devient résiduel parce qu'une fonction d'erreur le consomme, pas parce qu'il est déclaré quelque part de spécial. Les noms sont libres.add() + prop()seul est ambigu : la combinaison vient deaddPropdans le contrôle (défautcombined2, la somme des variances, équivalente au combiné de NONMEM). Écrivez+ combined2()dans la ligne à tilde pour que le modèle se lise seul.- BLQ : colonnes
CENS(1 = censuré à gauche) etLIMIT, la LOQ dansDV. Le point contribue par — le M3 de Beal sans une ligne de code, M4 en ajoutantLIMIT = 0.DV = 0sur une ligne censurée est une affirmation impossible qui fait dériver le run. - Jugez sur les IWRES contre prédictions, en lisant la largeur : qui s'ouvre = pourcentage manquant ; qui se resserre = plancher manquant ; courbé = structural. Mais lisez
sd(fit$IWRES)d'abord — sous -shrinkage, un beau nuage ne prouve rien. - Un %RSE énorme sur
add.errsignale un plancher que plus aucune donnée basse n'informe — typiquement des BQL jetés.
Point de contrôle
Vérifiez votre compréhension
Q1. Votre bloc model() se termine par `cp ~ add(add.err) + prop(prop.err)` et rien d'autre. La manière dont nlmixr2 combine les deux termes...
Q2. Après une estimation, nlmixr2 vous rend `prop.err = 0,118`. Ce nombre s'interprète comme...
Q3. Une ligne censurée de votre jeu de données porte `CENS = 1`. La colonne `DV` de cette même ligne doit contenir...
Q4. Dans le bloc `ini()`, un paramètre d'erreur résiduelle se déclare avec...
Exercices d'entraînement
Cas en plusieurs étapes combinant calcul, interprétation et raisonnement.
nlmixr2 : coder 20 % d’erreur
Calcul · Interprétation · Lecture de codeÀ commencer
nlmixr2 : coder 20 % d’erreur
Calcul · Interprétation · Lecture de codeÀ commencerCiter cette page
Berrah R. nlmixr2 — le modèle d'erreur résiduelle et le BLQ. PMx Explain, 2026. https://rberrah.github.io/pharmacometrie/chapitres/nlmixr2-erreur-residuelle/
@misc{berrah2026nlmixr2erreurresiduelle,
author = {Berrah, Racym},
title = {{nlmixr2 — le modèle d'erreur résiduelle et le BLQ}},
howpublished = {PMx Explain},
year = {2026},
url = {https://rberrah.github.io/pharmacometrie/chapitres/nlmixr2-erreur-residuelle/},
note = {Texte actuel sous licence CC BY-NC-SA 4.0}
}