meddeid-data 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Synthetic Belgian clinical de-identification dataset tooling."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
meddeid_data/cli.py ADDED
@@ -0,0 +1,457 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shlex
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from meddeid_core import build_artifact_manifest, validate_record
10
+ from meddeid_language_nl import get_profile
11
+
12
+ from .clinical_cases import generate_case_records
13
+ from .generator import (
14
+ generate_documents,
15
+ render_documents_from_case_records,
16
+ write_jsonl,
17
+ )
18
+ from .judge import judge_documents, write_report
19
+ from .projects import (
20
+ import_documents,
21
+ init_project,
22
+ load_import_mapping,
23
+ package_annotation_set,
24
+ split_project,
25
+ )
26
+ from .synthea_adapter import load_or_generate_synthea_csv_seeds
27
+ from .training_views import prepare_training_views
28
+
29
+
30
+ def _add_import_options(parser: argparse.ArgumentParser) -> None:
31
+ parser.add_argument("source", type=Path)
32
+ parser.add_argument(
33
+ "--mapping-config",
34
+ type=Path,
35
+ help=(
36
+ "reusable YAML/JSON import mapping; the effective mapping is saved "
37
+ "in the project and reused on later imports"
38
+ ),
39
+ )
40
+ parser.add_argument("--text-column", help="text column (default: text)")
41
+ parser.add_argument("--id-column")
42
+ parser.add_argument(
43
+ "--metadata-column",
44
+ action="append",
45
+ dest="metadata_columns",
46
+ help=(
47
+ "table column to copy into metadata; repeat as needed. By default all "
48
+ "columns other than text/ID are copied"
49
+ ),
50
+ )
51
+ parser.add_argument(
52
+ "--metadata-json-column",
53
+ help=(
54
+ "table column containing a JSON object to merge into metadata; "
55
+ "metadata or metadata_json is detected automatically"
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--no-metadata",
60
+ action="store_true",
61
+ help=(
62
+ "discard unselected raw table columns; explicitly mapped canonical "
63
+ "name metadata is retained"
64
+ ),
65
+ )
66
+ parser.add_argument(
67
+ "--patient-name-column",
68
+ help="source column containing one patient's full name",
69
+ )
70
+ parser.add_argument(
71
+ "--patient-given-name-column",
72
+ help="source column containing the patient's given name(s)",
73
+ )
74
+ parser.add_argument(
75
+ "--patient-family-name-column",
76
+ help="source column containing the patient's family name",
77
+ )
78
+ parser.add_argument(
79
+ "--caregiver-column",
80
+ action="append",
81
+ dest="caregiver_columns",
82
+ help=(
83
+ "source column containing complete caregiver name(s); repeat for "
84
+ "several full-name columns"
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "--caregiver-delimiter",
89
+ help=(
90
+ "literal delimiter used for multiple caregiver names in a cell; "
91
+ "omit when each cell contains at most one name"
92
+ ),
93
+ )
94
+
95
+
96
+ def _run_import(args: argparse.Namespace) -> tuple[Path, dict]:
97
+ if args.no_metadata and (args.metadata_columns or args.metadata_json_column):
98
+ raise ValueError(
99
+ "--no-metadata cannot be combined with --metadata-column or "
100
+ "--metadata-json-column"
101
+ )
102
+ saved_mapping = (
103
+ args.directory.expanduser().resolve()
104
+ / "manifests"
105
+ / "import-mapping.json"
106
+ )
107
+ if args.mapping_config:
108
+ mapping = load_import_mapping(args.mapping_config)
109
+ elif saved_mapping.is_file():
110
+ mapping = load_import_mapping(saved_mapping)
111
+ else:
112
+ mapping = {}
113
+
114
+ def selected(name: str, default=None):
115
+ value = getattr(args, name)
116
+ return value if value is not None else mapping.get(name, default)
117
+
118
+ if args.no_metadata:
119
+ include_metadata = False
120
+ elif args.metadata_columns or args.metadata_json_column:
121
+ include_metadata = True
122
+ else:
123
+ include_metadata = bool(mapping.get("include_metadata", True))
124
+ caregiver_mappings = (
125
+ None if args.caregiver_columns is not None else mapping.get("caregivers")
126
+ )
127
+ return import_documents(
128
+ args.directory,
129
+ args.source,
130
+ text_column=selected("text_column", "text"),
131
+ id_column=selected("id_column"),
132
+ metadata_columns=selected("metadata_columns"),
133
+ metadata_json_column=selected("metadata_json_column"),
134
+ include_metadata=include_metadata,
135
+ patient_name_column=selected("patient_name_column"),
136
+ patient_given_name_column=selected("patient_given_name_column"),
137
+ patient_family_name_column=selected("patient_family_name_column"),
138
+ caregiver_columns=selected("caregiver_columns"),
139
+ caregiver_delimiter=selected("caregiver_delimiter"),
140
+ caregivers=caregiver_mappings,
141
+ )
142
+
143
+
144
+ def _print_annotation_next_steps(directory: Path, artifact: Path) -> None:
145
+ assignment = directory.expanduser().resolve() / "assignments" / "primary.jsonl"
146
+ artifact_arg = shlex.quote(str(artifact))
147
+ assignment_arg = shlex.quote(str(assignment))
148
+ print("\nNext: create an annotation assignment initialized by the local model:")
149
+ print(
150
+ f" meddeid batch {artifact_arg} --output {assignment_arg} "
151
+ "--model stighellemans/meddeid-dutch-synth --device cpu"
152
+ )
153
+ print("\nThen annotate that current state:")
154
+ print(
155
+ f" MEDDEID_ANNOTATIONS_PATH={assignment_arg} "
156
+ "npm --prefix /path/to/meddeid-annotate run dev"
157
+ )
158
+
159
+
160
+ def _add_generation_options(parser: argparse.ArgumentParser) -> None:
161
+ parser.add_argument("--count", type=int, default=5)
162
+ parser.add_argument("--seed", type=int, default=20260508)
163
+ parser.add_argument("--output", type=Path, required=True)
164
+ parser.add_argument("--pretty-output", type=Path)
165
+ parser.add_argument("--judge-report", type=Path)
166
+ parser.add_argument("--synthea-csv-dir", type=Path)
167
+ parser.add_argument("--auto-synthea", action="store_true")
168
+ parser.add_argument(
169
+ "--synthea-repo-dir", type=Path, default=Path("external/synthea")
170
+ )
171
+ parser.add_argument("--synthea-population", type=int)
172
+ parser.add_argument("--force-synthea", action="store_true")
173
+ parser.add_argument("--require-synthea", action="store_true")
174
+
175
+
176
+ def _write_pretty(rows: list[dict], path: Path | None) -> None:
177
+ if path is None:
178
+ return
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
181
+
182
+
183
+ def _read_jsonl(path: Path) -> list[dict]:
184
+ with path.open(encoding="utf-8") as handle:
185
+ return [json.loads(line) for line in handle if line.strip()]
186
+
187
+
188
+ def _write_jsonl_raw(rows: list[dict], path: Path) -> None:
189
+ path.parent.mkdir(parents=True, exist_ok=True)
190
+ with path.open("w", encoding="utf-8") as handle:
191
+ for row in rows:
192
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
193
+
194
+
195
+ def _write_dataset_manifest(rows: list[dict], path: Path, *, role: str) -> None:
196
+ profile = get_profile("nl-BE", version="1")
197
+ manifest = build_artifact_manifest(
198
+ role=role,
199
+ artifact_path=path,
200
+ records=rows,
201
+ producer={"name": "meddeid-data", "version": "0.2.0"},
202
+ contracts={"language_profile": "nl-BE", "language_profile_version": "1"},
203
+ )
204
+ manifest["language_profile"] = profile.manifest()
205
+ _write_pretty(manifest, path.with_suffix(path.suffix + ".manifest.json"))
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ parser = argparse.ArgumentParser(prog="meddeid-data")
210
+ sub = parser.add_subparsers(dest="command", required=True)
211
+
212
+ generate = sub.add_parser(
213
+ "generate", help="generate synthetic Belgian clinical notes"
214
+ )
215
+ _add_generation_options(generate)
216
+
217
+ sample = sub.add_parser(
218
+ "sample", help="alias for an offline synthetic generation run"
219
+ )
220
+ _add_generation_options(sample)
221
+
222
+ cases = sub.add_parser(
223
+ "build-cases", help="write structured synthetic case records"
224
+ )
225
+ _add_generation_options(cases)
226
+ cases.add_argument("--start-index", type=int, default=0)
227
+
228
+ render = sub.add_parser("render-cases", help="render structured case JSONL")
229
+ render.add_argument("input", type=Path)
230
+ render.add_argument("--output", type=Path, required=True)
231
+ render.add_argument("--pretty-output", type=Path)
232
+ render.add_argument("--judge-report", type=Path)
233
+ render.add_argument("--seed", type=int, default=20260508)
234
+
235
+ validate = sub.add_parser(
236
+ "validate", help="validate canonical JSONL offsets and labels"
237
+ )
238
+ validate.add_argument("path", type=Path)
239
+
240
+ project = sub.add_parser("project", help="create/import/split a canonical hospital project")
241
+ project_sub = project.add_subparsers(dest="project_command", required=True)
242
+ project_create = project_sub.add_parser(
243
+ "create",
244
+ help="create a project and import TXT, CSV, TSV, or Parquet in one step",
245
+ )
246
+ project_create.add_argument("directory", type=Path)
247
+ project_create.add_argument("--namespace", required=True)
248
+ project_create.add_argument("--language-profile", default="nl-BE")
249
+ _add_import_options(project_create)
250
+ project_init = project_sub.add_parser("init", help="create a canonical project directory")
251
+ project_init.add_argument("directory", type=Path)
252
+ project_init.add_argument("--namespace", required=True)
253
+ project_init.add_argument("--language-profile", default="nl-BE")
254
+ project_import = project_sub.add_parser(
255
+ "import", help="import TXT, CSV, TSV, or Parquet documents"
256
+ )
257
+ project_import.add_argument("directory", type=Path)
258
+ _add_import_options(project_import)
259
+ project_split = project_sub.add_parser("split", help="create deterministic train/validation/test files")
260
+ project_split.add_argument("directory", type=Path)
261
+ project_split.add_argument("--seed", type=int, default=20260508)
262
+ project_split.add_argument("--train", type=float, default=0.8)
263
+ project_split.add_argument("--validation", type=float, default=0.1)
264
+ project_package = project_sub.add_parser(
265
+ "package-annotation", help="validate a completed assignment and emit its curation manifest"
266
+ )
267
+ project_package.add_argument("directory", type=Path)
268
+ project_package.add_argument("annotations", type=Path)
269
+ project_package.add_argument("--annotation-set-id", required=True)
270
+ project_package.add_argument("--annotator-id")
271
+ project_training = project_sub.add_parser(
272
+ "prepare-training",
273
+ help="create checksum-pinned one-stage and publication training views",
274
+ )
275
+ project_training.add_argument("directory", type=Path)
276
+ development_inputs = project_training.add_mutually_exclusive_group(required=True)
277
+ development_inputs.add_argument(
278
+ "--development",
279
+ type=Path,
280
+ help="one reviewed file containing both train and validation documents",
281
+ )
282
+ development_inputs.add_argument(
283
+ "--selection-train",
284
+ type=Path,
285
+ help="reviewed training split (requires --selection-validation)",
286
+ )
287
+ project_training.add_argument(
288
+ "--selection-validation",
289
+ type=Path,
290
+ help="reviewed validation split used with --selection-train",
291
+ )
292
+ project_training.add_argument("--test-gold", type=Path, required=True)
293
+ project_training.add_argument("--output", type=Path)
294
+ args = parser.parse_args(argv)
295
+
296
+ if args.command == "project":
297
+ if args.project_command == "create":
298
+ init_project(
299
+ args.directory,
300
+ namespace=args.namespace,
301
+ language_profile=args.language_profile,
302
+ )
303
+ artifact, manifest = _run_import(args)
304
+ print(
305
+ f"Created {args.directory.expanduser().resolve()} with "
306
+ f"{manifest['counts']['documents']} annotation-ready documents."
307
+ )
308
+ print(f"Canonical dataset: {artifact}")
309
+ print(f"Manifest: {args.directory.expanduser().resolve() / 'manifests' / 'input-documents.json'}")
310
+ import_mapping = (
311
+ args.directory.expanduser().resolve()
312
+ / "manifests"
313
+ / "import-mapping.json"
314
+ )
315
+ print(
316
+ f"Reusable import mapping: {import_mapping}"
317
+ )
318
+ _print_annotation_next_steps(args.directory, artifact)
319
+ return 0
320
+ if args.project_command == "init":
321
+ init_project(
322
+ args.directory,
323
+ namespace=args.namespace,
324
+ language_profile=args.language_profile,
325
+ )
326
+ print(f"Created MedDeID project at {args.directory.expanduser().resolve()}")
327
+ return 0
328
+ if args.project_command == "import":
329
+ artifact, manifest = _run_import(args)
330
+ print(f"Imported {manifest['counts']['documents']} documents into {artifact}")
331
+ import_mapping = (
332
+ args.directory.expanduser().resolve()
333
+ / "manifests"
334
+ / "import-mapping.json"
335
+ )
336
+ print(
337
+ f"Reusable import mapping: {import_mapping}"
338
+ )
339
+ _print_annotation_next_steps(args.directory, artifact)
340
+ return 0
341
+ if args.project_command == "package-annotation":
342
+ manifest_path, manifest = package_annotation_set(
343
+ args.directory,
344
+ args.annotations,
345
+ annotation_set_id=args.annotation_set_id,
346
+ annotator_id=args.annotator_id,
347
+ )
348
+ print(f"Packaged {manifest['counts']['documents']} completed documents: {manifest_path}")
349
+ print("Select this manifest and its JSONL together in meddeid-curate.")
350
+ return 0
351
+ if args.project_command == "prepare-training":
352
+ if bool(args.selection_train) != bool(args.selection_validation):
353
+ parser.error(
354
+ "prepare-training requires --selection-train and "
355
+ "--selection-validation together"
356
+ )
357
+ if args.development and args.selection_validation:
358
+ parser.error(
359
+ "--development cannot be combined with --selection-validation"
360
+ )
361
+ output, manifest = prepare_training_views(
362
+ args.directory,
363
+ development=args.development,
364
+ selection_train=args.selection_train,
365
+ selection_validation=args.selection_validation,
366
+ test_gold=args.test_gold,
367
+ output=args.output,
368
+ )
369
+ print(
370
+ f"Prepared {manifest['development_documents']} development and "
371
+ f"{manifest['test_documents']} test documents in {output}"
372
+ )
373
+ print(
374
+ "One-time fit: "
375
+ f"meddeid-train fit --data {output / 'fit'} ..."
376
+ )
377
+ print(
378
+ "Epoch selection: "
379
+ f"meddeid-train select-epochs --data {output / 'selection'} ..."
380
+ )
381
+ print(
382
+ "Full refit: "
383
+ f"meddeid-train refit --data {output / 'refit'} ..."
384
+ )
385
+ return 0
386
+ manifest = split_project(
387
+ args.directory,
388
+ seed=args.seed,
389
+ train_fraction=args.train,
390
+ validation_fraction=args.validation,
391
+ )
392
+ print(json.dumps(manifest["files"], indent=2))
393
+ return 0
394
+
395
+ if args.command in {"generate", "sample"}:
396
+ docs = generate_documents(
397
+ args.count,
398
+ seed=args.seed,
399
+ synthea_csv_dir=args.synthea_csv_dir,
400
+ auto_synthea=args.auto_synthea,
401
+ synthea_repo_dir=args.synthea_repo_dir,
402
+ synthea_population=args.synthea_population,
403
+ force_synthea=args.force_synthea,
404
+ require_synthea=args.require_synthea,
405
+ )
406
+ docs, results, model_reviews = judge_documents(docs)
407
+ write_jsonl(docs, args.output)
408
+ _write_dataset_manifest(docs, args.output, role="synthetic_corpus")
409
+ _write_pretty(docs, args.pretty_output)
410
+ if args.judge_report:
411
+ write_report(results, model_reviews, args.judge_report)
412
+ return 1 if any(not result.passed for result in results) else 0
413
+
414
+ if args.command == "build-cases":
415
+ synthea_seeds, _ = load_or_generate_synthea_csv_seeds(
416
+ args.synthea_csv_dir,
417
+ limit=args.count,
418
+ auto_generate=args.auto_synthea,
419
+ synthea_repo_dir=args.synthea_repo_dir,
420
+ population=args.synthea_population,
421
+ seed=args.seed,
422
+ force=args.force_synthea,
423
+ require=args.require_synthea,
424
+ )
425
+ rows = generate_case_records(
426
+ args.count,
427
+ seed=args.seed,
428
+ synthea_seeds=synthea_seeds,
429
+ start_index=args.start_index,
430
+ )
431
+ _write_jsonl_raw(rows, args.output)
432
+ _write_pretty(rows, args.pretty_output)
433
+ return 0
434
+
435
+ if args.command == "render-cases":
436
+ docs = render_documents_from_case_records(
437
+ _read_jsonl(args.input), seed=args.seed
438
+ )
439
+ docs, results, model_reviews = judge_documents(docs)
440
+ write_jsonl(docs, args.output)
441
+ _write_dataset_manifest(docs, args.output, role="synthetic_corpus")
442
+ _write_pretty(docs, args.pretty_output)
443
+ if args.judge_report:
444
+ write_report(results, model_reviews, args.judge_report)
445
+ return 1 if any(not result.passed for result in results) else 0
446
+
447
+ errors = 0
448
+ for line_number, row in enumerate(_read_jsonl(args.path), start=1):
449
+ problems = validate_record(row)
450
+ if problems:
451
+ errors += 1
452
+ print(f"line {line_number}: {problems}", file=sys.stderr)
453
+ return 1 if errors else 0
454
+
455
+
456
+ if __name__ == "__main__":
457
+ raise SystemExit(main())