Skip to content

Commit c853258

Browse files
neuralsorcerermeta-codesync[bot]
authored andcommitted
Support JSON list formulas in CLI (#500)
Summary: Pull Request resolved: #500 Reviewed By: sahil350 Differential Revision: D107012061 Pulled By: talgalili fbshipit-source-id: 8fe4e6bb4ad58ecf94bf176734dfe5a329baff1a
1 parent 7bad722 commit c853258

3 files changed

Lines changed: 168 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
# 0.22.0 (Unreleased - TBD)
2+
3+
## New Features
4+
5+
- **CLI `--formula` now accepts JSON lists for model-matrix formula lists.**
6+
In addition to a single formula string, CLI users can pass values such as
7+
`--formula='["age", "gender"]'`, which are parsed and forwarded as
8+
`list[str]` to IPW/CBPS model-matrix construction. Malformed, empty, or
9+
non-string JSON lists now fail during argument parsing.
10+
111
# 0.21.0 (2026-05-29)
212

313
## New Features

balance/cli.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,71 @@ def _positive_int_arg(value: Any) -> int:
5858
return parsed
5959

6060

61+
def _validate_formula_list(value: list[Any]) -> list[str]:
62+
"""Validate and normalize a JSON/list formula argument."""
63+
if not value:
64+
raise ArgumentTypeError(
65+
"--formula JSON list must contain at least one formula string"
66+
)
67+
68+
normalized: list[str] = []
69+
for item in value:
70+
if not isinstance(item, str):
71+
raise ArgumentTypeError(
72+
"--formula JSON list must contain only non-empty formula strings"
73+
)
74+
stripped_item = item.strip()
75+
if not stripped_item:
76+
raise ArgumentTypeError(
77+
"--formula JSON list must contain only non-empty formula strings"
78+
)
79+
normalized.append(stripped_item)
80+
81+
return normalized
82+
83+
84+
def _formula_arg(value: str | list[str] | None) -> str | list[str] | None:
85+
"""Parse a CLI formula value as a string formula or JSON list of formulas.
86+
87+
Args:
88+
value: Raw formula argument supplied by argparse or tests.
89+
90+
Returns:
91+
A formula string, a list of formula strings, or ``None``.
92+
93+
Raises:
94+
ArgumentTypeError: If a formula string is empty, or if a JSON/list
95+
formula is malformed.
96+
"""
97+
if value is None:
98+
return None
99+
if isinstance(value, list):
100+
return _validate_formula_list(value)
101+
if not isinstance(value, str):
102+
raise ArgumentTypeError(
103+
"--formula must be a formula string, 'None', or a JSON list of formula strings"
104+
)
105+
106+
stripped = value.strip()
107+
if stripped == "None":
108+
return None
109+
if not stripped:
110+
raise ArgumentTypeError("--formula must not be empty")
111+
if stripped[0] not in "[{":
112+
return stripped
113+
114+
try:
115+
parsed = json.loads(stripped)
116+
except json.JSONDecodeError as exc:
117+
raise ArgumentTypeError("--formula JSON list must be valid JSON") from exc
118+
119+
if not isinstance(parsed, list):
120+
raise ArgumentTypeError(
121+
"--formula JSON value must be a list of formula strings"
122+
)
123+
return _validate_formula_list(parsed)
124+
125+
61126
def _parse_csv_columns_arg(value: Optional[str], arg_name: str) -> List[str]:
62127
"""Parse a comma-separated CLI columns argument into a validated list.
63128
@@ -118,7 +183,7 @@ def __init__(self, args: Namespace) -> None:
118183

119184
# Create attributes (to be populated later, which will be used in main)
120185
self._transformations: Dict[str, Any] | str | None = None
121-
self._formula: str | None = None
186+
self._formula: str | list[str] | None = None
122187
self._penalty_factor: None = None
123188
self._one_hot_encoding: bool = False
124189
self._max_de: float | None = None
@@ -523,19 +588,19 @@ def transformations(self) -> str | None:
523588
else:
524589
return self.args.transformations
525590

526-
def formula(self) -> str | None:
527-
"""Return the formula string used for model matrices.
591+
def formula(self) -> str | list[str] | None:
592+
"""Return the formula string or formula list used for model matrices.
528593
529594
Returns:
530-
Formula string or ``None`` if unset.
595+
Formula string, formula list, or ``None`` if unset.
531596
532597
Examples:
533598
.. code-block:: python
534599
from argparse import Namespace
535600
BalanceCLI(Namespace(formula="age + gender")).formula()
536601
# 'age + gender'
537602
"""
538-
return self.args.formula
603+
return _formula_arg(self.args.formula)
539604

540605
def one_hot_encoding(self) -> bool | None:
541606
"""Return the parsed one-hot encoding flag.
@@ -660,7 +725,7 @@ def process_batch(
660725
self,
661726
batch_df: pd.DataFrame,
662727
transformations: Dict[str, Any] | str | None = "default",
663-
formula: str | None = None,
728+
formula: str | list[str] | None = None,
664729
penalty_factor: None = None,
665730
one_hot_encoding: bool = False,
666731
max_de: float | None = 1.5,
@@ -1252,7 +1317,7 @@ def add_arguments_to_parser(parser: ArgumentParser) -> ArgumentParser:
12521317
# True
12531318
"""
12541319
# TODO: add checks for validity of input (including None as input)
1255-
# TODO: add arguments for formula when used as a list and for penalty_factor
1320+
# TODO: add arguments for penalty_factor
12561321
parser.add_argument(
12571322
"--input_file",
12581323
type=Path,
@@ -1469,13 +1534,16 @@ def add_arguments_to_parser(parser: ArgumentParser) -> ArgumentParser:
14691534
"'default' for default transformations."
14701535
),
14711536
)
1472-
# TODO: we currently support only the option of a string formula (or None), not a list of formulas.
14731537
parser.add_argument(
14741538
"--formula",
1539+
type=_formula_arg,
14751540
default=None,
14761541
required=False,
14771542
help=(
1478-
"The formula of the model matrix (in ipw or cbps). If None (default), the formula will be setted to an additive formula using all the covariates."
1543+
"The formula of the model matrix (in ipw or cbps). If None "
1544+
"(default), the formula is set to an additive formula using all "
1545+
'covariates. Pass a JSON list (for example, \'["age", "gender"]\') '
1546+
"to build and concatenate multiple model-matrix formula terms."
14791547
),
14801548
)
14811549
parser.add_argument(

tests/test_cli.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,87 @@ def test_formula_works(self) -> None:
13521352
np.array(["intercept", "age", "age:gender", "gender"]),
13531353
)
13541354

1355+
# test JSON list formula support, including whitespace normalization
1356+
parser = make_parser()
1357+
args = parser.parse_args(
1358+
[
1359+
"--input_file",
1360+
input_file.name,
1361+
"--output_file",
1362+
output_file,
1363+
"--diagnostics_output_file",
1364+
diagnostics_output_file,
1365+
"--covariate_columns",
1366+
features,
1367+
"--num_lambdas=1",
1368+
"--transformations=None",
1369+
'--formula=[" age ", "gender"]',
1370+
]
1371+
)
1372+
self.assertEqual(args.formula, ["age", "gender"])
1373+
cli = BalanceCLI(args)
1374+
self.assertEqual(cli.formula(), ["age", "gender"])
1375+
cli.update_attributes_for_main_used_by_adjust()
1376+
cli.main()
1377+
diagnostics_output = pd.read_csv(diagnostics_output_file, sep=",")
1378+
self.assertEqual(
1379+
diagnostics_output[diagnostics_output["metric"] == "model_coef"][
1380+
"var"
1381+
].values,
1382+
np.array(["intercept", "age", "gender"]),
1383+
)
1384+
1385+
def test_formula_json_list_rejects_invalid_values(self) -> None:
1386+
"""The CLI rejects malformed formula lists during argument parsing."""
1387+
parser = make_parser()
1388+
base_args = [
1389+
"--input_file",
1390+
"input.csv",
1391+
"--output_file",
1392+
"output.csv",
1393+
"--covariate_columns",
1394+
"age,gender",
1395+
]
1396+
1397+
invalid_formulas = (
1398+
"",
1399+
" ",
1400+
"[",
1401+
"[]",
1402+
"{}",
1403+
'["age", 1]',
1404+
'["age", null]',
1405+
'["age", " "]',
1406+
)
1407+
for formula in invalid_formulas:
1408+
with self.subTest(formula=formula):
1409+
with self.assertRaises(SystemExit):
1410+
parser.parse_args([*base_args, f"--formula={formula}"])
1411+
1412+
def test_formula_parser_normalizes_namespace_values(self) -> None:
1413+
"""BalanceCLI.formula validates direct Namespace values consistently."""
1414+
self.assertEqual(
1415+
BalanceCLI(Namespace(formula=" age + gender ")).formula(), "age + gender"
1416+
)
1417+
self.assertIsNone(BalanceCLI(Namespace(formula="None")).formula())
1418+
self.assertEqual(
1419+
BalanceCLI(Namespace(formula=[" age ", "gender"])).formula(),
1420+
["age", "gender"],
1421+
)
1422+
1423+
invalid_namespace_values = (
1424+
"",
1425+
" ",
1426+
[],
1427+
["age", 1],
1428+
["age", " "],
1429+
123,
1430+
)
1431+
for formula in invalid_namespace_values:
1432+
with self.subTest(formula=formula):
1433+
with self.assertRaises(ArgumentTypeError):
1434+
BalanceCLI(Namespace(formula=formula)).formula()
1435+
13551436
def test_cli_return_df_with_original_dtypes(self) -> None:
13561437
"""Test CLI flag for preserving original data types in output DataFrames."""
13571438
out_True = check_some_flags(True, "--return_df_with_original_dtypes")

0 commit comments

Comments
 (0)