A study’s datasets, gathered for CDISC conformance validation.
SubmissionPackage(
datasets=dict(),
define=None,
ct_version=None,
standard="sdtmig",
standard_version="3.4",
study_id=None,
)
A SubmissionPackage holds the datasets of a study (SDTM domains, SUPP– qualifiers, RELREC and ADaM datasets) together with the study’s Define-XML. Where validate_sdtm() and validate_adam() look at one dataset at a time, a package knows about all of them, so it can check how they relate: for example, whether every subject in AE has a record in DM. It is the data-level counterpart of MetadataPackage, which holds the metadata of many datasets.
A package can be built from DataFrames, as here, or from a folder of XPT and Dataset-JSON files with from_folder(). Its validate_conformance() method runs the conformance checks and returns a ConformanceReport. The package also has some helpers for looking across datasets, such as orphan_ids(), which finds the subjects in one dataset that are missing from another.
Parameters
datasets: dict[str, Any] = dict()
-
A dictionary that maps each dataset name (a domain code such as "DM", "AE", "SUPPAE" or "ADSL") to the dataset, as a Polars or Pandas DataFrame. The names are case-insensitive and are stored in uppercase. The default is an empty dictionary.
define: Any = None
-
The study’s Define-XML, as a path to a define.xml file or as a MetadataPackage from import_metadata(). With the built-in rules engine, it activates the rules that check the datasets against Define-XML. The CORE engine only accepts a path. The default is None.
ct_version: str | None = None
-
The version of CDISC Controlled Terminology that the study uses (such as "2024-03-29"). It is shown by summary() but doesn’t affect the checks. To choose the terminology the built-in engine checks against, use the ct_packages= argument of validate_conformance(). The default is None.
standard: str = "sdtmig"
-
The data standard that the datasets follow, "sdtmig" or "adamig". The default is "sdtmig".
standard_version: str = "3.4"
-
The version of the standard, such as "3.4" for SDTMIG 3.4. The default is "3.4".
study_id: str | None = None
-
A study identifier. It is shown by
summary() and used in the labels of the validations made by validate_conformance(engine="validate"). The default is None.
Examples
Let’s build a package from a Demographics (DM) and an Adverse Events (AE) dataset. One of the adverse events belongs to subject STUDY01-009, who isn’t in DM:
import pointblank as pb
import polars as pl
dm = pl.DataFrame(
{
"STUDYID": ["STUDY01"] * 3,
"DOMAIN": ["DM"] * 3,
"USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003"],
"SEX": ["M", "F", "F"],
}
)
ae = pl.DataFrame(
{
"STUDYID": ["STUDY01"] * 3,
"DOMAIN": ["AE"] * 3,
"USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-009"],
"AESEQ": [1, 1, 1],
"AETERM": ["HEADACHE", "NAUSEA", "RASH"],
}
)
study = pb.SubmissionPackage(datasets={"dm": dm, "AE": ae}, study_id="STUDY01")
print(study)
Submission Package
Study: STUDY01
Standard: sdtmig 3.4
Datasets (2): AE, DM
The dataset names are stored in uppercase, so "dm" became DM. The package can find the subject in AE that has no DM record:
The validation checks the whole package. The "validate" engine builds a Validate for each dataset, with the structural checks from the SDTM templates and the checks across datasets:
report = study.validate_conformance(engine="validate")
print(report)
ConformanceReport
[AE] 12 steps — FAIL (1 test units)
[DM] 9 steps — PASS
The AE validation has one failing test unit, from the check that each USUBJID in AE exists in DM. The default engine, the built-in rules engine, checks the package against the SDTMIG rules instead. See validate_conformance() for how the engines differ.
Attributes
domains
The names of the datasets in the package, in alphabetical order.
The names are the domain codes (such as "AE" and "DM") under which the datasets were added, in uppercase. This is a property, so it’s used without parentheses.
Examples
Let’s make a package with three datasets and get their names:
import pointblank as pb
import polars as pl
dm = pl.DataFrame({"USUBJID": ["S-001", "S-002"]})
ae = pl.DataFrame({"USUBJID": ["S-001"], "AESEQ": [1]})
suppae = pl.DataFrame({"USUBJID": ["S-001"], "RDOMAIN": ["AE"]})
study = pb.SubmissionPackage(datasets={"dm": dm, "SUPPAE": suppae, "ae": ae})
study.domains
The names are in uppercase and in alphabetical order, whatever order the datasets were given in. A name can also be tested with in, which ignores case ("ae" in study is True), and len(study) gives the number of datasets.
Methods
from_folder()
from_folder(
path,
define=None,
standard="sdtmig",
standard_version="3.4",
ct_version=None,
study_id=None,
)
Submission datasets are usually delivered as files, one per dataset, in a single folder. The from_folder() method reads the folder so the datasets don’t have to be loaded one by one. It reads these files (the folder’s subfolders are not searched):
- SAS Transport files (
.xpt), which need the pyreadstat package. The dataset name is the file name without its extension, in uppercase, so dm.xpt becomes DM.
- CDISC Dataset-JSON files (
.json), in the Dataset-JSON 1.1 layout (top-level columns and rows) or the older layout (clinicalData or referenceData). The dataset name is taken from the file’s name (or its item group OID), or else from the file name. .json files that aren’t Dataset-JSON are skipped.
- A Define-XML file: an
.xml file whose name starts with define. It is used as the package’s define unless define= is given.
The datasets are read into Pandas DataFrames. The package remembers the folder, so that validate_conformance(engine="core") can give the files straight to the CORE engine instead of writing the datasets out again.
Parameters
path: str | Path
-
The path to the folder that holds the dataset files.
define: str | Path | Any | None = None
-
The study’s Define-XML, as a path to a define.xml file or a MetadataPackage. If None (the default), a Define-XML file in the folder is used, if there is one.
standard: str = "sdtmig"
-
The data standard that the datasets follow, "sdtmig" or "adamig". The default is "sdtmig".
standard_version: str = "3.4"
-
The version of the standard. The default is "3.4".
ct_version: str | None = None
-
The version of CDISC Controlled Terminology that the study uses, which is shown by summary(). The default is None.
study_id: str | None = None
-
A study identifier. The default is
None.
Returns
SubmissionPackage
-
A package with a dataset for each dataset file in the folder.
Raises
NotADirectoryError
-
If path isn’t a folder.
ImportError
-
If the folder has an
.xpt file and pyreadstat isn’t installed.
Examples
Let’s make a folder with a DM dataset as an XPT file and a Define-XML file for the study. Both files come with Pointblank:
import pointblank as pb
import shutil
import tempfile
from pathlib import Path
folder = Path(tempfile.mkdtemp())
shutil.copy(pb.load_metadata_example("dm.xpt"), folder / "dm.xpt")
shutil.copy(pb.load_metadata_example("define.xml"), folder / "define.xml")
study = pb.SubmissionPackage.from_folder(folder, study_id="XYZ789")
print(study)
Submission Package
Study: XYZ789
Standard: sdtmig 3.4
Define-XML: present
Datasets (1): DM
The package has the DM dataset from dm.xpt, and it found the Define-XML file. The dataset is a Pandas DataFrame:
study.get_dataset("DM").head(3)
|
STUDYID |
DOMAIN |
USUBJID |
SUBJID |
RFSTDTC |
RFENDTC |
SITEID |
AGE |
SEX |
RACE |
ARMCD |
ARM |
| 0 |
XYZ789 |
DM |
XYZ789-101 |
101 |
2024-01-15 |
2024-07-15 |
S01 |
45.0 |
M |
WHITE |
TRT |
Active 10mg |
| 1 |
XYZ789 |
DM |
XYZ789-102 |
102 |
2024-01-20 |
2024-07-20 |
S01 |
62.0 |
F |
BLACK |
PBO |
Placebo |
| 2 |
XYZ789 |
DM |
XYZ789-103 |
103 |
2024-02-01 |
2024-08-01 |
S02 |
38.0 |
M |
ASIAN |
TRT |
Active 10mg |
Since a Define-XML file was found, the built-in rules engine also runs the rules that check the datasets against it. Here, one of the subjects has a RACE of BLACK, which isn’t a term in the study’s codelist for RACE:
report = study.validate_conformance()
[i["message"] for i in report.issues() if i["rule_type"] == "DEFINE_CODELIST_CHECK"]
['DM.RACE contains a value not in the Define-XML declared codelist.']
The other issues in the report come from rules that don’t need Define-XML, such as the rules for variables that DM should have.
get_dataset()
Get one of the package’s datasets by name.
The dataset is returned as it was given to the package, so a Polars DataFrame stays a Polars DataFrame. Indexing the package (as in study["DM"]) also gets a dataset, but get_dataset() gives a more helpful error for a name that isn’t in the package.
Parameters
name: str
-
The name of the dataset, such as
"DM". This is case-insensitive.
Returns
Any
-
The dataset, as a DataFrame.
Raises
KeyError
-
If the package has no dataset with that name. The error message lists the names that the package has.
Examples
Let’s make a package with two datasets and get one of them:
import pointblank as pb
import polars as pl
dm = pl.DataFrame({"USUBJID": ["S-001", "S-002"], "SEX": ["M", "F"]})
ae = pl.DataFrame({"USUBJID": ["S-001"], "AESEQ": [1], "AETERM": ["HEADACHE"]})
study = pb.SubmissionPackage(datasets={"DM": dm, "AE": ae})
study.get_dataset("ae")
shape: (1, 3)| USUBJID | AESEQ | AETERM |
|---|
| str | i64 | str |
| "S-001" | 1 | "HEADACHE" |
The name is case-insensitive, so "ae" gets the AE dataset. Asking for a dataset that isn’t in the package raises a KeyError:
try:
study.get_dataset("LB")
except KeyError as e:
print(e)
"No dataset named 'LB'. Available: ['AE', 'DM']"
The message lists the datasets that the package has.
subject_ids()
Get the unique subject identifiers in a dataset.
subject_ids(dataset="DM")
USUBJID identifies each subject across all the datasets of a study. DM has one record per subject, so its USUBJID values are the study’s subjects, and the subjects in the other datasets should all be among them. The subject_ids() method gets these values for one dataset, which is useful for comparing the subjects of two datasets.
Parameters
dataset: str = "DM"
-
The name of the dataset, which is case-insensitive. The default is
"DM".
Returns
set
-
The different values of
USUBJID in the dataset, leaving out nulls. The set is empty if the package has no dataset with that name, or if the dataset has no USUBJID column.
Examples
Let’s make a package where the AE dataset has two records for one subject:
import pointblank as pb
import polars as pl
dm = pl.DataFrame({"USUBJID": ["S-001", "S-002", "S-003"]})
ae = pl.DataFrame({"USUBJID": ["S-001", "S-001", "S-003"], "AESEQ": [1, 2, 1]})
study = pb.SubmissionPackage(datasets={"DM": dm, "AE": ae})
sorted(study.subject_ids("AE"))
Each subject appears once in the set, however many records they have. Sets can be compared directly, so here’s a check that every subject with an adverse event is in DM:
study.subject_ids("AE") <= study.subject_ids()
For a dataset that isn’t in the package, subject_ids() returns an empty set rather than raising an error. To find the subjects that are missing from DM, use orphan_ids().
orphan_ids()
Find the key values in one dataset that are missing from another.
orphan_ids(
child,
parent="DM",
column="USUBJID",
)
In a study, the records of one dataset often refer to those of another. Each subject in AE should have a record in DM, for example. A value with nothing to refer to (an orphan) usually means that a record is missing or that a key was mistyped. The orphan_ids() method finds these values, by default the subjects of a dataset that are missing from DM.
Parameters
child: str
-
The name of the dataset whose values are looked up, such as "AE".
parent: str = "DM"
-
The name of the dataset that the values should be in. The default is "DM".
column: str = "USUBJID"
-
The key column, which is compared between the two datasets. The default is
"USUBJID".
Returns
set
-
The values of
column in child that aren’t in column of parent. Null values are left out. If child has no such column, the set is empty. If parent has no such column, every value of child is returned.
Raises
KeyError
-
If
child or parent isn’t a dataset in the package.
Examples
Let’s make a package where the AE dataset has a subject, S-009, who isn’t in DM:
import pointblank as pb
import polars as pl
dm = pl.DataFrame({"USUBJID": ["S-001", "S-002", "S-003"]})
ae = pl.DataFrame({"USUBJID": ["S-001", "S-009", "S-009"], "AESEQ": [1, 1, 2]})
study = pb.SubmissionPackage(datasets={"DM": dm, "AE": ae})
study.orphan_ids("AE")
The orphan subject is found once, though they have two records in AE. The datasets that are compared can be changed with parent=. Here, a SUPPAE dataset holds supplemental qualifiers for adverse events, so each of its subjects should have a record in AE:
suppae = pl.DataFrame({"USUBJID": ["S-001", "S-002"], "QNAM": ["AETRTEM", "AETRTEM"]})
study = pb.SubmissionPackage(datasets={"DM": dm, "AE": ae, "SUPPAE": suppae})
print(study.orphan_ids("SUPPAE"))
print(study.orphan_ids("SUPPAE", parent="AE"))
Both SUPPAE subjects are in DM, but S-002 has no adverse events, so their supplemental qualifier has no AE record to belong to. A key other than USUBJID can be compared with column=.
summary()
Summarize the package’s contents as text.
The summary is a quick way to check what a package holds, such as after reading it with from_folder(). It has the study identifier, the standard and its version, the controlled terminology version, whether there is a Define-XML, and the names of the datasets. Lines for the study identifier, the terminology version and the Define-XML are left out when the package doesn’t have them. Printing the package gives the same text.
Returns
str
-
The summary, over several lines.
Examples
Let’s make a package and summarize it:
import pointblank as pb
import polars as pl
dm = pl.DataFrame({"USUBJID": ["S-001", "S-002"]})
ae = pl.DataFrame({"USUBJID": ["S-001"], "AESEQ": [1]})
study = pb.SubmissionPackage(
datasets={"DM": dm, "AE": ae}, study_id="STUDY01", ct_version="2024-09-27"
)
print(study.summary())
Submission Package
Study: STUDY01
Standard: sdtmig 3.4
CT version: 2024-09-27
Datasets (2): AE, DM
The package has no define, so there’s no line for a Define-XML. The datasets are listed in alphabetical order.