Source code for pharmpy.modeling.check_dataset

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Literal, Optional, overload

from pharmpy.basic import Quantity, Unit
from pharmpy.deps import pandas as pd
from pharmpy.deps.rich import box as rich_box
from pharmpy.deps.rich import console as rich_console
from pharmpy.deps.rich import table as rich_table
from pharmpy.model import DataVariable, Model


def get_variable_description(model, variable) -> str:
    for col in model.datainfo:
        if col.variable_id is None:
            if variable == col.variable_mapping:
                return col.name
        elif variable in col.variable_mapping.values():
            inv_mapping = {v: k for k, v in col.variable_mapping.items()}
            i = inv_mapping[variable]
            return f"{col.name}[{col.variable_id} == {i}]"
    raise ValueError("Variable not in DataInfo")


def get_variable_data(model, variable) -> pd.Series:
    for col in model.datainfo:
        if col.variable_id is None:
            if variable == col.variable_mapping:
                return model.dataset[col.name]
        elif variable in col.variable_mapping.values():
            inv_mapping = {v: k for k, v in col.variable_mapping.items()}
            i = inv_mapping[variable]
            return model.dataset.loc[model.dataset[col.variable_id] == i, col.name]
    raise ValueError("Variable not in DataInfo")


class Violation:
    pass


@dataclass
class DatasetViolation(Violation):
    model: Model
    variable: DataVariable
    rows: list[int]
    msg: str

    def __str__(self):
        return f"{self.msg} in {get_variable_description(self.model, self.variable)} at {self.rows}"


@dataclass
class DataInfoViolation(Violation):
    model: Model
    variable: DataVariable
    msg: str

    def __str__(self):
        return f"{self.msg} for {get_variable_description(self.model, self.variable)}"


class VariableQuantifier(ABC):
    @abstractmethod
    def get_variables(self, model: Model) -> list[DataVariable]: ...


@dataclass
class Descriptor(VariableQuantifier):
    value: str

    def get_variables(self, model: Model) -> list[DataVariable]:
        di = model.datainfo
        variables = [
            variable
            for variable in di.variables
            if variable.properties.get("descriptor", None) == self.value
        ]
        return variables

    def __str__(self):
        return f'All data variables with descriptor="{self.value}"'


@dataclass
class Type(VariableQuantifier):
    value: str

    def get_variables(self, model: Model) -> list[DataVariable]:
        di = model.datainfo
        try:
            variables = di.typeix[self.value].variables
        except IndexError:
            variables = []
        return variables

    def __str__(self):
        return f"All {self.value} columns"


class Predictor:
    pass


@dataclass
class UnitOf(Predictor):
    variables: VariableQuantifier
    unit: Unit

    def check(self, model) -> list[Violation]:
        violations = []
        for variable in self.variables.get_variables(model):
            diunit = variable.get_property("unit")
            if not diunit.is_compatible_with(self.unit):
                violation = DataInfoViolation(model, variable, "Unit not compatible")
                violations.append(violation)
        return violations

    def __str__(self):
        if self.unit == Unit.unitless():
            return f"{self.variables} are dimensionless"
        else:
            return f"{self.variables} have dimension {self.unit.get_dimensionality_string()}"


@dataclass
class InRange(Predictor):
    variables: VariableQuantifier
    unit: Unit
    lower: float
    upper: float = float("inf")
    lower_included: bool = True
    upper_included: bool = False

    def check(self, model) -> list[Violation]:
        violations = []
        for variable in self.variables.get_variables(model):
            variable_unit = variable.get_property("unit")
            if not variable_unit.is_compatible_with(self.unit):
                continue
            violated_indices = []
            ser = get_variable_data(model, variable)
            converted_lower = Quantity(self.lower, self.unit).convert_to(variable_unit).value
            converted_upper = Quantity(self.upper, self.unit).convert_to(variable_unit).value
            for i, val in ser.items():
                if (
                    self.lower_included
                    and val < converted_lower
                    or not self.lower_included
                    and val <= converted_lower
                ):
                    violated_indices.append(i)
                elif (
                    self.upper_included
                    and val > converted_upper
                    or not self.upper_included
                    and val >= converted_upper
                ):
                    violated_indices.append(i)
            if violated_indices:
                violation = DatasetViolation(
                    model, variable, violated_indices, "Value out of range"
                )
                violations.append(violation)
        return violations

    def __str__(self):
        lower_op = "≤" if self.lower_included else "<"
        upper_op = "≤" if self.upper_included else "<"
        upper_part = f" {upper_op} {self.upper}" if self.upper < float("inf") else ""
        return f"{self.variables} are in the range {self.lower} {lower_op} x{upper_part}"


@dataclass
class InSet(Predictor):
    variables: VariableQuantifier
    values: set[float | int]

    def check(self, model) -> list[Violation]:
        violations = []
        for variable in self.variables.get_variables(model):
            violated_indices = []
            ser = get_variable_data(model, variable)
            for i, val in ser.items():
                if val not in self.values:
                    violated_indices.append(i)
            if violated_indices:
                violation = DatasetViolation(
                    model, variable, violated_indices, "Value not in allowed set"
                )
                violations.append(violation)
        return violations

    def __str__(self):
        return f"{self.variables}{self.values}"


PREDICATES = (
    UnitOf(Type("id"), Unit(1)),
    InRange(Type("id"), Unit(1), 1),
    UnitOf(Type("dvid"), Unit(1)),
    InRange(Type("dvid"), Unit(1), 0),
    UnitOf(Type("mdv"), Unit(1)),
    InSet(Type("mdv"), {0, 1}),
    UnitOf(Descriptor("body weight"), Unit("kg")),
    InRange(Descriptor("body weight"), Unit("kg"), 0.0, 700.0, lower_included=False),
    UnitOf(Descriptor("lean body mass"), Unit("kg")),
    InRange(Descriptor("lean body mass"), Unit("kg"), 0.0, 700.0, lower_included=False),
    UnitOf(Descriptor("fat free mass"), Unit("kg")),
    InRange(Descriptor("fat free mass"), Unit("kg"), 0.0, 700.0, lower_included=False),
    UnitOf(Descriptor("age"), Unit("yr")),
    InRange(Descriptor("age"), Unit("yr"), 0.0, 130.0),
    UnitOf(Descriptor("time after dose"), Unit("h")),
    InRange(Descriptor("time after dose"), Unit("h"), 0.0),
    UnitOf(Descriptor("plasma concentration"), Unit("mg/L")),
    InRange(Descriptor("plasma concentration"), Unit("h"), 0.0),
)


def pretty_print_checks(checks):
    table = rich_table.Table(title="Dataset checks", box=rich_box.SQUARE, show_lines=True)
    table.add_column("Check")
    table.add_column("Result")
    table.add_column("Violations")

    for check, result, violation in checks:
        if result == "OK":
            table.add_row(check, f'[bold green]{result}', "")
        else:
            table.add_row(check, f'[bold red]{result}', violation)

    if table.rows:  # Do not print an empty table
        console = rich_console.Console()
        console.print(table)


@overload
def check_dataset(model: Model, dataframe: Literal[False], verbose: bool = False) -> None: ...


@overload
def check_dataset(
    model: Model, dataframe: Literal[True], verbose: bool = False
) -> pd.DataFrame: ...


[docs] def check_dataset( model: Model, dataframe: bool = False, verbose: bool = False ) -> Optional[pd.DataFrame]: """Check dataset for consistency across a set of rules Parameters ---------- model : Model Pharmpy model dataframe : bool True to return a DataFrame instead of printing to the console verbose : bool Print out all rules checked if True else print only failed rules Returns ------- pd.DataFrame Only returns a DataFrame is dataframe=True """ checks = [] for pred in PREDICATES: violations = pred.check(model) rule_string = str(pred) if not violations: if verbose: checks.append((rule_string, "OK", "")) else: for violation in violations: checks.append((rule_string, "FAIL", str(violation))) if not dataframe: pretty_print_checks(checks) else: df = pd.DataFrame(checks, columns=['check', 'result', 'violations']) return df