New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[python-package] fix access to Dataset metadata in scikit-learn custom metrics and objectives #6108
Changes from 3 commits
c91e01a
0119e57
48de342
8874f3b
7ea687e
7386ecd
ff01f80
e3b9cd2
5c60a31
017e5e5
96e346f
2fd4979
1d5528f
31877b6
bb94198
ada18f8
061c94d
29422e5
c0b507e
58306e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -368,31 +368,31 @@ def _data_to_2d_numpy( | |
"It should be list of lists, numpy 2-D array or pandas DataFrame") | ||
|
||
|
||
def _cfloat32_array_to_numpy(cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
def _cfloat32_array_to_numpy(*, cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
"""Convert a ctypes float pointer array to a numpy array.""" | ||
if isinstance(cptr, ctypes.POINTER(ctypes.c_float)): | ||
return np.ctypeslib.as_array(cptr, shape=(length,)).copy() | ||
else: | ||
raise RuntimeError('Expected float pointer') | ||
|
||
|
||
def _cfloat64_array_to_numpy(cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
def _cfloat64_array_to_numpy(*, cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
"""Convert a ctypes double pointer array to a numpy array.""" | ||
if isinstance(cptr, ctypes.POINTER(ctypes.c_double)): | ||
return np.ctypeslib.as_array(cptr, shape=(length,)).copy() | ||
else: | ||
raise RuntimeError('Expected double pointer') | ||
|
||
|
||
def _cint32_array_to_numpy(cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
def _cint32_array_to_numpy(*, cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
"""Convert a ctypes int pointer array to a numpy array.""" | ||
if isinstance(cptr, ctypes.POINTER(ctypes.c_int32)): | ||
return np.ctypeslib.as_array(cptr, shape=(length,)).copy() | ||
else: | ||
raise RuntimeError('Expected int32 pointer') | ||
|
||
|
||
def _cint64_array_to_numpy(cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
def _cint64_array_to_numpy(*, cptr: "ctypes._Pointer", length: int) -> np.ndarray: | ||
"""Convert a ctypes int pointer array to a numpy array.""" | ||
if isinstance(cptr, ctypes.POINTER(ctypes.c_int64)): | ||
return np.ctypeslib.as_array(cptr, shape=(length,)).copy() | ||
|
@@ -1229,18 +1229,18 @@ def __create_sparse_native( | |
data_indices_len = out_shape[0] | ||
indptr_len = out_shape[1] | ||
if indptr_type == _C_API_DTYPE_INT32: | ||
out_indptr = _cint32_array_to_numpy(out_ptr_indptr, indptr_len) | ||
out_indptr = _cint32_array_to_numpy(cptr=out_ptr_indptr, length=indptr_len) | ||
elif indptr_type == _C_API_DTYPE_INT64: | ||
out_indptr = _cint64_array_to_numpy(out_ptr_indptr, indptr_len) | ||
out_indptr = _cint64_array_to_numpy(cptr=out_ptr_indptr, length=indptr_len) | ||
else: | ||
raise TypeError("Expected int32 or int64 type for indptr") | ||
if data_type == _C_API_DTYPE_FLOAT32: | ||
out_data = _cfloat32_array_to_numpy(out_ptr_data, data_indices_len) | ||
out_data = _cfloat32_array_to_numpy(cptr=out_ptr_data, length=data_indices_len) | ||
elif data_type == _C_API_DTYPE_FLOAT64: | ||
out_data = _cfloat64_array_to_numpy(out_ptr_data, data_indices_len) | ||
out_data = _cfloat64_array_to_numpy(cptr=out_ptr_data, length=data_indices_len) | ||
else: | ||
raise TypeError("Expected float32 or float64 type for data") | ||
out_indices = _cint32_array_to_numpy(out_ptr_indices, data_indices_len) | ||
out_indices = _cint32_array_to_numpy(cptr=out_ptr_indices, length=data_indices_len) | ||
# break up indptr based on number of rows (note more than one matrix in multiclass case) | ||
per_class_indptr_shape = cs.indptr.shape[0] | ||
# for CSC there is extra column added | ||
|
@@ -2504,6 +2504,12 @@ def set_field( | |
def get_field(self, field_name: str) -> Optional[np.ndarray]: | ||
"""Get property from the Dataset. | ||
|
||
Can only be run on a constructed Dataset. | ||
|
||
Unlike ``get_group()``, ``get_init_score()``, ``get_label()``, ``get_position()``, and ``get_weight()``, | ||
this method ignores any raw data passed into ``lgb.Dataset()`` on the Python side, and will only read | ||
data from the constructed C++ ``Dataset`` object. | ||
|
||
Parameters | ||
---------- | ||
field_name : str | ||
|
@@ -2530,11 +2536,20 @@ def get_field(self, field_name: str) -> Optional[np.ndarray]: | |
if tmp_out_len.value == 0: | ||
return None | ||
if out_type.value == _C_API_DTYPE_INT32: | ||
arr = _cint32_array_to_numpy(ctypes.cast(ret, ctypes.POINTER(ctypes.c_int32)), tmp_out_len.value) | ||
arr = _cint32_array_to_numpy( | ||
cptr=ctypes.cast(ret, ctypes.POINTER(ctypes.c_int32)), | ||
length=tmp_out_len.value | ||
) | ||
elif out_type.value == _C_API_DTYPE_FLOAT32: | ||
arr = _cfloat32_array_to_numpy(ctypes.cast(ret, ctypes.POINTER(ctypes.c_float)), tmp_out_len.value) | ||
arr = _cfloat32_array_to_numpy( | ||
cptr=ctypes.cast(ret, ctypes.POINTER(ctypes.c_float)), | ||
length=tmp_out_len.value | ||
) | ||
elif out_type.value == _C_API_DTYPE_FLOAT64: | ||
arr = _cfloat64_array_to_numpy(ctypes.cast(ret, ctypes.POINTER(ctypes.c_double)), tmp_out_len.value) | ||
arr = _cfloat64_array_to_numpy( | ||
cptr=ctypes.cast(ret, ctypes.POINTER(ctypes.c_double)), | ||
length=tmp_out_len.value | ||
) | ||
else: | ||
raise TypeError("Unknown type") | ||
if field_name == 'init_score': | ||
|
@@ -2834,7 +2849,7 @@ def get_feature_name(self) -> List[str]: | |
ptr_string_buffers)) | ||
return [string_buffers[i].value.decode('utf-8') for i in range(num_feature)] | ||
|
||
def get_label(self) -> Optional[np.ndarray]: | ||
def get_label(self) -> Optional[_LGBM_LabelType]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These issues in the Here's an example. import lightgbm as lgb
import numpy as np
X = np.array([[1.0, 2.0], [3.0, 4.0]])
dtrain = lgb.Dataset(
data=np.array([[1.0, 2.0], [3.0, 4.0]]),
label=[1, 2],
params={
"min_data_in_bin": 1,
"min_data_in_leaf": 1,
},
)
# 'label' was passed in as a list, get_label() returns that list
type(dtrain.get_label())
# <class 'list'>
# after construction, this is pulled from the C++ side, and is a numpy array
dtrain.construct()
type(dtrain.get_label())
# <class 'numpy.ndarray'> |
||
"""Get the label of the Dataset. | ||
|
||
Returns | ||
|
@@ -2846,7 +2861,7 @@ def get_label(self) -> Optional[np.ndarray]: | |
self.label = self.get_field('label') | ||
return self.label | ||
|
||
def get_weight(self) -> Optional[np.ndarray]: | ||
def get_weight(self) -> Optional[_LGBM_WeightType]: | ||
"""Get the weight of the Dataset. | ||
|
||
Returns | ||
|
@@ -2858,7 +2873,7 @@ def get_weight(self) -> Optional[np.ndarray]: | |
self.weight = self.get_field('weight') | ||
return self.weight | ||
|
||
def get_init_score(self) -> Optional[np.ndarray]: | ||
def get_init_score(self) -> Optional[_LGBM_InitScoreType]: | ||
"""Get the initial score of the Dataset. | ||
|
||
Returns | ||
|
@@ -2902,7 +2917,7 @@ def get_data(self) -> Optional[_LGBM_TrainDataType]: | |
"set free_raw_data=False when construct Dataset to avoid this.") | ||
return self.data | ||
|
||
def get_group(self) -> Optional[np.ndarray]: | ||
def get_group(self) -> Optional[_LGBM_GroupType]: | ||
"""Get the group of the Dataset. | ||
|
||
Returns | ||
|
@@ -2921,7 +2936,7 @@ def get_group(self) -> Optional[np.ndarray]: | |
self.group = np.diff(self.group) | ||
return self.group | ||
|
||
def get_position(self) -> Optional[np.ndarray]: | ||
def get_position(self) -> Optional[_LGBM_PositionType]: | ||
"""Get the position of the Dataset. | ||
|
||
Returns | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -151,14 +151,18 @@ def __call__(self, preds: np.ndarray, dataset: Dataset) -> Tuple[np.ndarray, np. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
The value of the second order derivative (Hessian) of the loss | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with respect to the elements of preds for each sample point. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
labels = dataset.get_label() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
labels = dataset.get_field("label") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After correcting the type hints in With many instances like this:
That's because we say in documentation and type hints that LightGBM/python-package/lightgbm/sklearn.py Lines 35 to 51 in 7c9a985
LightGBM/python-package/lightgbm/sklearn.py Lines 101 to 115 in 7c9a985
This PR proposes fixing that by switching from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you looked into the performance impact of this? This method is called on each iteration and we're currently just returning an attribute from the dataset but this would require making a call to C++ to get the field each time. I think it just returns the pointer so it may not be that expensive but it's something to consider given that we'd be making several calls to get all the fields that this method needs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, I didn't consider that this would be a performance issue. You're right, it could be!
Nah, your instincts that this might be more expensive were good! This call to LightGBM/python-package/lightgbm/basic.py Lines 2522 to 2527 in 63a882b
... is just populating a pointer ... Line 1697 in 63a882b
Line 956 in 63a882b
...but then that get materialized into a new LightGBM/python-package/lightgbm/basic.py Lines 2532 to 2537 in 63a882b
LightGBM/python-package/lightgbm/basic.py Line 374 in 63a882b
So I think you're right, calling Thanks to these calls in LightGBM/python-package/lightgbm/basic.py Lines 1958 to 1967 in 63a882b
So given all that....let me think about whether there's a better way to resolve these errors from Thanks for bringing it up! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright @jmoralez I think I found a better way to do this. I just pushed 017e5e5, with the following changes:
@jmoralez whenever you have time, could you please take another look? Thanks again for bringing this up! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't we do for group what we do for other fields like init_score and weight where after we set the field we set the Dataset attribute to the result of get_field? LightGBM/python-package/lightgbm/basic.py Lines 2742 to 2743 in 8ed371c
LightGBM/python-package/lightgbm/basic.py Lines 2720 to 2721 in 8ed371c
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could but that would be a user-facing breaking change. Look at the test cases in this PR... even after construction, Do you think it's worth the breaking change to get that consistency? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is, it's weird having all of the arguments of a custom objective be arrays except for group. Although the main difference I think is not really the data structure but the format that they're in (group boundaries vs lengths). Given that the docstring says array and it's more consistent with the other fields I'd prefer we override it with get_field. On the other hand I haven't used ranking a lot so I'm not sure which format is more convenient for the objective function. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok I think your reasoning makes sense and that we should change it to an array. I'm especially since tonight I found that it'll be set to an array of boundaries the first time you call import lightgbm as lgb
import numpy as np
X = np.array([[1.0, 2.0], [3.0, 4.0]])
dtrain = lgb.Dataset(
X,
params={
"min_data_in_bin": 1,
"min_data_in_leaf": 1,
"verbosity": -1
},
group=[1, 1],
label=[1, 2],
)
dtrain.construct()
# get_group() returns a list of sizes
assert dtrain.get_group() == [1, 1]
# get_field() returns a numpy array of bounadries
np.testing.assert_array_equal(
dtrain.get_field("group"),
np.array([0, 1, 2])
)
# round-trip to and from binary file
dtrain.save_binary("dtrain.bin")
dtrain2 = lgb.Dataset(
data="dtrain.bin",
params={
"min_data_in_bin": 1,
"min_data_in_leaf": 1,
"verbosity": -1
}
)
# before construction, group is empty
assert dtrain2.group is None
# after construction, get_group() returns a numpy array of sizes
dtrain2.construct()
np.testing.assert_array_equal(
dtrain.get_group(),
np.array([1, 1])
)
# ... and get_field() returns a numpy array of boundaries
np.testing.assert_array_equal(
dtrain.get_field("group"),
np.array([0, 1, 2])
) That doesn't matter specifically for the I just did the following:
Thanks for talking through it with me, I know this is way down in the depths of the library and that reviewing it takes a lot of effort. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I won't merge this until you've had another chance to review @jmoralez . Take your time! |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
argc = len(signature(self.func).parameters) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if argc == 2: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
grad, hess = self.func(labels, preds) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
elif argc == 3: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
grad, hess = self.func(labels, preds, dataset.get_weight()) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
grad, hess = self.func(labels, preds, dataset.get_field("weight")) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
elif argc == 4: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
grad, hess = self.func(labels, preds, dataset.get_weight(), dataset.get_group()) # type: ignore [call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
group = dataset.get_field("group") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if group is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_field("weight"), np.diff(group)) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_field("weight"), group) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
jameslamb marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raise TypeError(f"Self-defined objective function should have 2, 3 or 4 arguments, got {argc}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return grad, hess | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -229,14 +233,18 @@ def __call__( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
is_higher_better : bool | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Is eval result higher better, e.g. AUC is ``is_higher_better``. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
labels = dataset.get_label() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
labels = dataset.get_field("label") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
argc = len(signature(self.func).parameters) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if argc == 2: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
elif argc == 3: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_weight()) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_field("weight")) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
elif argc == 4: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_weight(), dataset.get_group()) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
group = dataset.get_field("group") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if group is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_field("weight"), np.diff(group)) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self.func(labels, preds, dataset.get_field("weight"), group) # type: ignore[call-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raise TypeError(f"Self-defined eval function should have 2, 3 or 4 arguments, got {argc}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ensures that this function is only called with keyword arguments.
That syntax has been available since Python 3.0: https://peps.python.org/pep-3102/
Doing that eliminates the possibility of accidentally-passed-arguments-in-the-wrong-order types of bugs, and (in my opinion) makes the code a bit easier to read.