mergeron 2025.739265.0__py3-none-any.whl → 2025.739290.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.

Potentially problematic release.


This version of mergeron might be problematic. Click here for more details.

mergeron/gen/__init__.py CHANGED
@@ -7,12 +7,15 @@ containers for industry data generation and testing.
7
7
  from __future__ import annotations
8
8
 
9
9
  import enum
10
+ from collections.abc import Sequence
10
11
  from dataclasses import dataclass
11
- from typing import ClassVar, NamedTuple, Protocol
12
+ from typing import ClassVar, Protocol
12
13
 
13
14
  import numpy as np
14
- from attrs import Attribute, cmp_using, field, frozen, validators
15
+ from attrs import Attribute, Converter, cmp_using, field, validators
16
+ from attrs import frozen as frozen_attrs
15
17
  from numpy.random import SeedSequence
18
+ from ruamel import yaml
16
19
 
17
20
  from .. import ( # noqa: TID252
18
21
  DEFAULT_REC_RATIO,
@@ -24,25 +27,28 @@ from .. import ( # noqa: TID252
24
27
  ArrayINT,
25
28
  RECForm,
26
29
  UPPAggrSelector,
30
+ this_yaml,
31
+ )
32
+ from ..core.pseudorandom_numbers import ( # noqa: TID252
33
+ DEFAULT_BETA_DIST_PARMS,
34
+ DEFAULT_DIST_PARMS,
27
35
  )
28
- from ..core.pseudorandom_numbers import DEFAULT_DIST_PARMS # noqa: TID252
29
36
 
30
37
  __version__ = VERSION
31
38
 
32
39
 
33
- DEFAULT_EMPTY_ARRAY = np.zeros(2)
34
- DEFAULT_FCOUNT_WTS = np.divide(
35
- (_nr := np.arange(1, 7)[::-1]), _nr.sum(), dtype=np.float64
36
- )
40
+ DEFAULT_FCOUNT_WTS = np.asarray((_nr := np.arange(6, 0, -1)) / _nr.sum(), float)
37
41
 
38
42
 
39
- class SeedSequenceData(NamedTuple):
40
- mktshr_rng_seed_seq: SeedSequence
41
- pcm_rng_seed_seq: SeedSequence
42
- fcount_rng_seed_seq: SeedSequence | None
43
- pr_rng_seed_seq: SeedSequence | None
43
+ @dataclass
44
+ class SeedSequenceData:
45
+ share: SeedSequence
46
+ pcm: SeedSequence
47
+ fcounts: SeedSequence | None
48
+ price: SeedSequence | None
44
49
 
45
50
 
51
+ @this_yaml.register_class
46
52
  @enum.unique
47
53
  class PriceSpec(tuple[bool, str | None], enum.ReprEnum):
48
54
  """Price specification.
@@ -57,6 +63,7 @@ class PriceSpec(tuple[bool, str | None], enum.ReprEnum):
57
63
  CSY = (False, "market-wide cost-symmetry")
58
64
 
59
65
 
66
+ @this_yaml.register_class
60
67
  @enum.unique
61
68
  class SHRDistribution(enum.StrEnum):
62
69
  """Market share distributions."""
@@ -70,7 +77,7 @@ class SHRDistribution(enum.StrEnum):
70
77
  DIR_FLAT_CONSTR = "Flat Dirichlet - Constrained"
71
78
  """Impose minimum probablility weight on each firm-count
72
79
 
73
- Only firm-counts with probability weight of no less than 3%
80
+ Only firm-counts with probability weight of 3% or more
74
81
  are included for data generation.
75
82
  """
76
83
 
@@ -92,11 +99,59 @@ class SHRDistribution(enum.StrEnum):
92
99
  """
93
100
 
94
101
 
95
- @frozen(kw_only=False)
102
+ def _fc_wts_conv(
103
+ _v: Sequence[float | int] | ArrayDouble | ArrayINT | None, _i: ShareSpec
104
+ ) -> ArrayFloat | None:
105
+ if _i.dist_type == SHRDistribution.UNI:
106
+ return None
107
+ elif _v is None or np.array_equal(_v, DEFAULT_FCOUNT_WTS):
108
+ return DEFAULT_FCOUNT_WTS
109
+ else:
110
+ return _tv if (_tv := np.asarray(_v, float)).sum() == 1 else _tv / _tv.sum()
111
+
112
+
113
+ def _shr_dp_conv(_v: Sequence[float] | ArrayFloat | None, _i: ShareSpec) -> ArrayFloat:
114
+ match _v:
115
+ case None if _i.dist_type == SHRDistribution.UNI:
116
+ return DEFAULT_DIST_PARMS
117
+ case None:
118
+ _fc_max = 1 + (
119
+ len(DEFAULT_FCOUNT_WTS)
120
+ if not hasattr(_i, "firm_counts_weights")
121
+ or _i.firm_counts_weights is None
122
+ or len(_i.firm_counts_weights) == 0
123
+ else len(_i.firm_counts_weights)
124
+ )
125
+
126
+ match _i.dist_type:
127
+ case SHRDistribution.DIR_FLAT | SHRDistribution.DIR_FLAT_CONSTR:
128
+ return np.ones(_fc_max, float)
129
+ case SHRDistribution.DIR_ASYM:
130
+ return np.array([2.0] * 6 + [1.5] * 5 + [1.25] * _fc_max, float)
131
+ case SHRDistribution.DIR_COND:
132
+ return np.array([], float)
133
+ case _ if isinstance(_i.dist_type, SHRDistribution):
134
+ raise ValueError(
135
+ f"No default defined for market share distribution, {_i.dist_type!r}"
136
+ )
137
+ case _:
138
+ raise ValueError(
139
+ f"Unsupported distribution for market share generation, {_i.dist_type!r}"
140
+ )
141
+ case _ if isinstance(_v, Sequence | np.ndarray):
142
+ return np.asarray(_v, float)
143
+ case _:
144
+ raise ValueError(
145
+ f"Input, {_v!r} has invalid type. Must be None, Sequence of floats, or Numpy ndarray."
146
+ )
147
+
148
+
149
+ @this_yaml.register_class
150
+ @frozen_attrs
96
151
  class ShareSpec:
97
152
  """Market share specification
98
153
 
99
- A key feature of market-share specification in this package is that
154
+ A salientfeature of market-share specification in this package is that
100
155
  the draws represent markets with multiple different firm-counts.
101
156
  Firm-counts are unspecified if the share distribution is
102
157
  :attr:`mergeron.SHRDistribution.UNI`, for Dirichlet-distributed market-shares,
@@ -105,85 +160,92 @@ class ShareSpec:
105
160
 
106
161
  Notes
107
162
  -----
108
- If :attr:`mergeron.gen.ShareSpec.dist_type` == :attr:`mergeron.gen.SHRDistribution.UNI`,
109
- then it is infeasible that
110
- :attr:`mergeron.gen.ShareSpec.recapture_form` == :attr:`mergeron.RECForm.OUTIN`.
111
- In other words, if the distribution of markets over firm-counts is unspecified,
112
- recapture ratios cannot be estimated using outside-good choice probabilities.
113
-
114
- For a sample with explicit firm counts, market shares must be specified as
115
- having a supported Dirichlet distribution (see :class:`mergeron.gen.SHRDistribution`).
163
+ If :attr:`.dist_type` == :attr:`.SHRDistribution.UNI`, it is then infeasible that
164
+ :attr:`.recapture_form` == :attr:`mergeron.RECForm.OUTIN`.
165
+ In other words, recapture ratios cannot be estimated using
166
+ outside-good choice probabilities if the distribution of markets over firm-counts
167
+ is unspecified.
116
168
 
117
169
  """
118
170
 
119
- dist_type: SHRDistribution = field(default=SHRDistribution.DIR_FLAT)
171
+ dist_type: SHRDistribution = field(kw_only=False)
120
172
  """See :class:`SHRDistribution`"""
121
173
 
122
- @dist_type.validator # pyright: ignore
123
- def __dtv(
124
- _i: ShareSpec, _a: Attribute[SHRDistribution], _v: SHRDistribution
125
- ) -> None:
126
- if _v == SHRDistribution.UNI:
127
- if _i.firm_counts_weights is not None:
128
- raise ValueError(
129
- "The specified value is incompatible with "
130
- " :code:`distypte`=:attr`:`SHRDistribution.UNI`. "
131
- "Set value to None or Consider revising the "
132
- r"distribution type to :attr:`SHRDistribution.DIR_FLAT`, which gives "
133
- "uniformly distributed draws on the :math:`n+1` simplex "
134
- "for firm-count, :math:`n`. "
135
- ""
136
- )
137
- elif _i.recapture_form == RECForm.OUTIN:
138
- raise ValueError(
139
- "Market share specification requires estimation of recapture ratio from "
140
- "generated data. Either delete recapture ratio specification or set it to None."
141
- )
174
+ firm_counts_weights: ArrayFloat | None = field(
175
+ kw_only=True,
176
+ eq=cmp_using(eq=np.array_equal),
177
+ converter=Converter(_fc_wts_conv, takes_self=True), # type: ignore
178
+ )
179
+ """Relative or absolute frequencies of pre-merger firm counts
180
+
181
+ Defaults to :attr:`DEFAULT_FCOUNT_WTS`, which specifies pre-merger
182
+ firm-counts of 2 to 7 with weights in descending order from 6 to 1.
183
+
184
+ ALERT: Firm-count weights are irrelevant when the mergering firmss shares are specified
185
+ to have uniform distribution; therefore this attribute is forced to None if
186
+ :attr:`.dist_type` == :attr:`.SHRDistribution.UNI`.
187
+
188
+ """
189
+
190
+ @firm_counts_weights.default
191
+ def _fcwd(_i: ShareSpec) -> ArrayFloat | None:
192
+ return _fc_wts_conv(None, _i)
193
+
194
+ @firm_counts_weights.validator
195
+ def _fcv(_i: ShareSpec, _a: Attribute[ArrayFloat], _v: ArrayFloat) -> None:
196
+ if _i.dist_type != SHRDistribution.UNI and not len(_v):
197
+ raise ValueError(
198
+ f"Attribute, {'"firm_counts_weights"'} must be populated if the share distribution is "
199
+ "other than uniform distribution."
200
+ )
142
201
 
143
- dist_parms: ArrayFloat | ArrayINT | None = field(
144
- default=None, eq=cmp_using(eq=np.array_equal)
202
+ dist_parms: ArrayFloat = field(
203
+ kw_only=True,
204
+ converter=Converter(_shr_dp_conv, takes_self=True), # type: ignore
205
+ eq=cmp_using(eq=np.array_equal),
145
206
  )
146
207
  """Parameters for tailoring market-share distribution
147
208
 
148
209
  For Uniform distribution, bounds of the distribution; defaults to `(0, 1)`;
149
210
  for Dirichlet-type distributions, a vector of shape parameters of length
150
- no less than the length of firm-count weights below; defaults depend on
211
+ equal to 1 plus the length of firm-count weights below; defaults depend on
151
212
  type of Dirichlet-distribution specified.
152
213
 
153
214
  """
154
215
 
155
- @dist_parms.validator # pyright: ignore
156
- def __dpv(
157
- _i: ShareSpec,
158
- _a: Attribute[ArrayFloat | ArrayINT | None],
159
- _v: ArrayFloat | ArrayINT | None,
160
- ) -> None:
216
+ @dist_parms.default
217
+ def _dpd(_i: ShareSpec) -> ArrayFloat:
218
+ # converters run after defaults, and we
219
+ # avoid redundancy and confusion here
220
+ return _shr_dp_conv(None, _i)
221
+
222
+ @dist_parms.validator
223
+ def _dpv(_i: ShareSpec, _a: Attribute[ArrayFloat], _v: ArrayFloat) -> None:
161
224
  if (
162
225
  _i.firm_counts_weights is not None
163
226
  and _v is not None
164
- and len(_v) < 1 + len(_i.firm_counts_weights)
227
+ and 0 < len(_v) < (1 + len(_i.firm_counts_weights))
165
228
  ):
166
229
  raise ValueError(
167
- "If specified, the number of distribution parameters must be at least "
168
- "the maximum firm-count premerger, which is 1 plus the length of the "
169
- "vector specifying firm-count weights."
230
+ "If specified, the number of distribution parameters must euqal or "
231
+ "exceed the maximum firm-count premerger, which is "
232
+ "1 plus the length of the vector specifying firm-count weights."
170
233
  )
171
234
 
172
- firm_counts_weights: ArrayFloat | ArrayINT | None = field(
173
- default=DEFAULT_FCOUNT_WTS, eq=cmp_using(eq=np.array_equal)
174
- )
175
- """Relative or absolute frequencies of firm counts
176
-
177
- Given frequencies are exogenous to generated market data sample;
178
- for Dirichlet-type distributions, defaults to DEFAULT_FCOUNT_WTS, which specifies
179
- firm-counts of 2 to 6 with weights in descending order from 5 to 1.
180
-
181
- """
182
-
183
235
  recapture_form: RECForm = field(default=RECForm.INOUT)
184
236
  """See :class:`mergeron.RECForm`"""
185
237
 
186
- recapture_ratio: float | None = field(default=DEFAULT_REC_RATIO)
238
+ @recapture_form.validator
239
+ def _rfv(_i: ShareSpec, _a: Attribute[RECForm], _v: RECForm) -> None:
240
+ if _i.dist_type == SHRDistribution.UNI and _v == RECForm.OUTIN:
241
+ raise ValueError(
242
+ "Outside-good choice probabilities cannot be generated if the "
243
+ "merging firms' market shares have uniform distribution over the "
244
+ "3-dimensional simplex with the distribution of markets over "
245
+ "firm-counts unspecified."
246
+ )
247
+
248
+ recapture_ratio: float | None = field(kw_only=True)
187
249
  """A value between 0 and 1.
188
250
 
189
251
  :code:`None` if market share specification requires direct generation of
@@ -203,10 +265,17 @@ class ShareSpec:
203
265
  screens for further investigation, rather than as the basis for presumptions of harm or
204
266
  presumptions no harm.)
205
267
 
268
+ ALERT: If diversion ratios are estimated by specifying a choice probability for the
269
+ outside good, the recapture ratio is set to None, overriding any user-specified value.
270
+
206
271
  """
207
272
 
208
- @recapture_ratio.validator # pyright: ignore
209
- def __rrv(_i: ShareSpec, _a: Attribute[float], _v: float) -> None:
273
+ @recapture_ratio.default
274
+ def _rrd(_i: ShareSpec) -> float | None:
275
+ return None if _i.recapture_form == RECForm.OUTIN else DEFAULT_REC_RATIO
276
+
277
+ @recapture_ratio.validator
278
+ def _rrv(_i: ShareSpec, _a: Attribute[float], _v: float) -> None:
210
279
  if _v and not (0 < _v <= 1):
211
280
  raise ValueError("Recapture ratio must lie in the interval, [0, 1).")
212
281
  elif _v is None and _i.recapture_form != RECForm.OUTIN:
@@ -216,6 +285,22 @@ class ShareSpec:
216
285
  "interval [0, 1)."
217
286
  )
218
287
 
288
+ @classmethod
289
+ def to_yaml(
290
+ cls, _r: yaml.representer.SafeRepresenter, _d: ShareSpec
291
+ ) -> yaml.MappingNode:
292
+ _ret: yaml.MappingNode = _r.represent_mapping(
293
+ f"!{cls.__name__}",
294
+ {_a.name: getattr(_d, _a.name) for _a in _d.__attrs_attrs__},
295
+ )
296
+ return _ret
297
+
298
+ @classmethod
299
+ def from_yaml(
300
+ cls, _c: yaml.constructor.SafeConstructor, _n: yaml.MappingNode
301
+ ) -> ShareSpec:
302
+ return cls(**_c.construct_mapping(_n))
303
+
219
304
 
220
305
  @enum.unique
221
306
  class PCMDistribution(enum.StrEnum):
@@ -236,7 +321,8 @@ class FM2Constraint(enum.StrEnum):
236
321
  SYM = "symmetric"
237
322
 
238
323
 
239
- @frozen
324
+ @this_yaml.register_class
325
+ @frozen_attrs
240
326
  class PCMSpec:
241
327
  """Price-cost margin (PCM) specification
242
328
 
@@ -252,10 +338,14 @@ class PCMSpec:
252
338
 
253
339
  """
254
340
 
255
- dist_type: PCMDistribution = field(kw_only=False, default=PCMDistribution.UNI)
341
+ dist_type: PCMDistribution = field()
256
342
  """See :class:`PCMDistribution`"""
257
343
 
258
- dist_parms: ArrayDouble | None = field(kw_only=False, default=None)
344
+ @dist_type.default
345
+ def _dtd(_i: PCMSpec) -> PCMDistribution:
346
+ return PCMDistribution.UNI
347
+
348
+ dist_parms: ArrayFloat | None = field(kw_only=True, eq=np.array_repr)
259
349
  """Parameter specification for tailoring PCM distribution
260
350
 
261
351
  For Uniform distribution, bounds of the distribution; defaults to `(0, 1)`;
@@ -265,12 +355,24 @@ class PCMSpec:
265
355
 
266
356
  """
267
357
 
268
- @dist_parms.validator # pyright: ignore
269
- def __dpv(
270
- _i: PCMSpec, _a: Attribute[ArrayDouble | None], _v: ArrayDouble | None
358
+ @dist_parms.default
359
+ def _dpwd(_i: PCMSpec) -> ArrayFloat | None:
360
+ match _i.dist_type:
361
+ case PCMDistribution.EMPR:
362
+ return None
363
+ case PCMDistribution.BETA:
364
+ return DEFAULT_BETA_DIST_PARMS
365
+ case PCMDistribution.BETA_BND:
366
+ return np.array([0.5, 1.0, 0.0, 0.1], float)
367
+ case _:
368
+ return DEFAULT_DIST_PARMS
369
+
370
+ @dist_parms.validator
371
+ def _dpv(
372
+ _i: PCMSpec, _a: Attribute[ArrayFloat | None], _v: ArrayFloat | None
271
373
  ) -> None:
272
374
  if _i.dist_type.name.startswith("BETA"):
273
- if _v is None:
375
+ if _v is None or not any(_v.shape):
274
376
  pass
275
377
  elif np.array_equal(_v, DEFAULT_DIST_PARMS):
276
378
  raise ValueError(
@@ -288,17 +390,31 @@ class PCMSpec:
288
390
  f'for PCM with distribution, "{_i.dist_type}" is incorrect.'
289
391
  )
290
392
 
291
- elif _i.dist_type == PCMDistribution.EMPR and _v is not None:
393
+ elif _v is not None and _i.dist_type == PCMDistribution.EMPR:
292
394
  raise ValueError(
293
395
  f"Empirical distribution does not require additional parameters; "
294
396
  f'"given value, {_v!r} is ignored."'
295
397
  )
296
398
 
297
- firm2_pcm_constraint: FM2Constraint = field(
298
- kw_only=False, default=FM2Constraint.IID
299
- )
399
+ firm2_pcm_constraint: FM2Constraint = field(kw_only=True, default=FM2Constraint.IID)
300
400
  """See :class:`FM2Constraint`"""
301
401
 
402
+ @classmethod
403
+ def to_yaml(
404
+ cls, _r: yaml.representer.SafeRepresenter, _d: PCMSpec
405
+ ) -> yaml.MappingNode:
406
+ _ret: yaml.MappingNode = _r.represent_mapping(
407
+ f"!{cls.__name__}",
408
+ {_a.name: getattr(_d, _a.name) for _a in _d.__attrs_attrs__},
409
+ )
410
+ return _ret
411
+
412
+ @classmethod
413
+ def from_yaml(
414
+ cls, _c: yaml.constructor.SafeConstructor, _n: yaml.MappingNode
415
+ ) -> PCMSpec:
416
+ return cls(**_c.construct_mapping(_n))
417
+
302
418
 
303
419
  @enum.unique
304
420
  class SSZConstant(float, enum.ReprEnum):
@@ -340,11 +456,8 @@ class SSZConstant(float, enum.ReprEnum):
340
456
  """When initial set of draws is not restricted in any way."""
341
457
 
342
458
 
343
- # Validators for selected attributes of MarketSpec
344
-
345
-
346
459
  @dataclass(slots=True, frozen=True)
347
- class MarketDataSample:
460
+ class MarketSampleData:
348
461
  """Container for generated markets data sample."""
349
462
 
350
463
  frmshr_array: ArrayDouble
@@ -440,10 +553,10 @@ class MarginDataSample:
440
553
  class INVResolution(enum.StrEnum):
441
554
  CLRN = "clearance"
442
555
  ENFT = "enforcement"
443
- BOTH = "both"
556
+ BOTH = "investigation"
444
557
 
445
558
 
446
- @frozen
559
+ @frozen_attrs
447
560
  class UPPTestRegime:
448
561
  """Configuration for UPP tests."""
449
562
 
@@ -516,3 +629,28 @@ class DataclassInstance(Protocol):
516
629
  """Generic dataclass-instance"""
517
630
 
518
631
  __dataclass_fields__: ClassVar
632
+
633
+
634
+ for _typ in (
635
+ PriceSpec,
636
+ SHRDistribution,
637
+ PCMDistribution,
638
+ FM2Constraint,
639
+ SSZConstant,
640
+ INVResolution,
641
+ ):
642
+ # NOTE: If additional enums are defined in this module,
643
+ # add themn to the list above
644
+
645
+ _, _ = (
646
+ this_yaml.representer.add_representer(
647
+ _typ,
648
+ lambda _r, _d: _r.represent_scalar(f"!{_d.__class__.__name__}", _d.name),
649
+ ),
650
+ this_yaml.constructor.add_constructor(
651
+ f"!{_typ.__name__}",
652
+ lambda _c, _n, /: getattr(
653
+ globals().get(_n.tag.lstrip("!")), _c.construct_scalar(_n)
654
+ ),
655
+ ),
656
+ )