mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-18 17:04:50 +00:00
Phase 3 of plans/exit.md: the streaming-interface change, the load-bearing piece for #341 (DSL exit statement) and #440 (strict mode). - RecordTransformer.Transform and RecordTransformerFunc now return error. All 69 Transform implementations and their dispatch helpers updated (mechanical rewrite, compiler- and errcheck-verified). - runSingleTransformerBatch, on a Transform error, forwards any output produced before the failure plus an end-of-stream marker downstream, so the rest of the chain and the record-writer drain and finish cleanly; runSingleTransformer then surfaces the error to stream.Stream's select loop (non-blocking send; first error wins) and signals upstream-done so the record-reader stops. This is exactly the flush-then-exit sequencing a future DSL 'exit N' needs. - dataProcessingErrorChannel and FileOutputHandler.recordErroredChannel are now chan error instead of chan bool; ChannelWriter still prints write- error details at the site and sends the 'exiting due to data error' sentinel, preserving the exact stderr shape pinned by regression cases. - Mid-stream os.Exit sites converted to returned errors: put/filter DSL begin/main/end-block errors and the non-boolean filter-expression case, tee write/close failures, split write/open/close failures, join left-file ingest failures (both half-streaming and sorted paths, with full error plumbing through JoinBucketKeeper), histogram/stats2 ingest errors, surv fit errors, and step stepper allocation (tStepperAllocator now returns (tStepper, error); bad EWMA coefficients propagate; negative slwin parameters are reported by the CLI parser via the existing bad-stepper-name pattern). - The two genuinely internal join-bucket-keeper states now use lib.InternalCodingErrorWithMessageIf instead of hand-rolled print+exit. - pkg/transformers is now os.Exit-free. Behavior notes: the non-boolean filter message gains the standard 'mlr: ' prefix and a newline (it previously printed with neither); tee errors now include the underlying cause. All 4779 regression cases pass unchanged; mlr head early-out latency is unaffected (0.02s over 50M records). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package transformers
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/cli"
|
|
"github.com/johnkerl/miller/v6/pkg/lib"
|
|
"github.com/johnkerl/miller/v6/pkg/mlrval"
|
|
"github.com/johnkerl/miller/v6/pkg/transformers/utils"
|
|
"github.com/johnkerl/miller/v6/pkg/types"
|
|
)
|
|
|
|
const verbNameBootstrapCI = "bootstrap-ci"
|
|
|
|
const bootstrapCIDefaultNumResamples = int64(1000)
|
|
const bootstrapCIDefaultConfidenceLevel = 0.95
|
|
|
|
var bootstrapCIOptions = []OptionSpec{
|
|
{Flag: "-a", Arg: "{mean,...}", Type: "enum", Desc: "Names of statistics to bootstrap: one or more of the listed values, as in mlr stats1 -a. Also accepts median (same as p50) and percentiles p{n} for n in 0..100. Defaults to mean.", Values: []string{"count", "null_count", "distinct_count", "mode", "antimode", "sum", "mean", "mad", "var", "stddev", "meaneb", "skewness", "kurtosis", "min", "max", "minlen", "maxlen"}},
|
|
{Flag: "-f", Arg: "{a,b,c}", Type: "csv-list", Desc: "Value-field names on which to compute statistics. Required."},
|
|
{Flag: "-g", Arg: "{d,e,f}", Type: "csv-list", Desc: "Optional group-by-field names."},
|
|
{Flag: "-n", Arg: "{n}", Type: "int", Desc: "Number of bootstrap resamples. Must be positive. Defaults to 1000."},
|
|
{Flag: "-c", Arg: "{level}", Type: "float", Desc: "Confidence level, strictly between 0 and 1. Defaults to 0.95."},
|
|
{Flag: "-i", Type: "bool", Desc: "Use interpolated percentiles, like R's type=7, for percentile statistics as well as for the confidence-interval endpoints; default like type=1."},
|
|
}
|
|
|
|
var BootstrapCISetup = TransformerSetup{
|
|
Verb: verbNameBootstrapCI,
|
|
UsageFunc: transformerBootstrapCIUsage,
|
|
ParseCLIFunc: transformerBootstrapCIParseCLI,
|
|
IgnoresInput: false,
|
|
Options: bootstrapCIOptions,
|
|
}
|
|
|
|
func transformerBootstrapCIUsage(
|
|
o *os.File,
|
|
) {
|
|
fmt.Fprintf(o, "Usage: %s %s [options]\n", "mlr", verbNameBootstrapCI)
|
|
fmt.Fprintf(o,
|
|
`Computes bootstrap confidence intervals for statistics of given fields,
|
|
accumulated across the input record stream: values are resampled with
|
|
replacement many times, the statistic is computed on each resample, and the
|
|
confidence interval is taken from percentiles of the resampled statistics.
|
|
For each value field and statistic, outputs the full-data statistic in
|
|
{field}_{stat}, along with confidence-interval endpoints in {field}_{stat}_lo
|
|
and {field}_{stat}_hi. Use mlr --seed for reproducible results.
|
|
See also %s bootstrap and %s stats1.
|
|
`, "mlr", "mlr")
|
|
WriteVerbOptions(o, bootstrapCIOptions)
|
|
fmt.Fprintln(o,
|
|
"Example: mlr --seed 12345 bootstrap-ci -f x,y")
|
|
fmt.Fprintln(o,
|
|
"Example: mlr --seed 12345 bootstrap-ci -a mean,median -f x -g shape -n 5000 -c 0.99")
|
|
}
|
|
|
|
func transformerBootstrapCIParseCLI(
|
|
pargi *int,
|
|
argc int,
|
|
args []string,
|
|
_ *cli.TOptions,
|
|
doConstruct bool, // false for first pass of CLI-parse, true for second pass
|
|
) (RecordTransformer, error) {
|
|
|
|
// Skip the verb name from the current spot in the mlr command line
|
|
argi := *pargi
|
|
verb := args[argi]
|
|
argi++
|
|
|
|
accumulatorNameList := []string{"mean"}
|
|
valueFieldNameList := []string{}
|
|
groupByFieldNameList := []string{}
|
|
numResamples := bootstrapCIDefaultNumResamples
|
|
confidenceLevel := bootstrapCIDefaultConfidenceLevel
|
|
doInterpolatedPercentiles := false
|
|
|
|
var err error
|
|
for argi < argc /* variable increment: 1 or 2 depending on flag */ {
|
|
opt := args[argi]
|
|
if !strings.HasPrefix(opt, "-") {
|
|
break // No more flag options to process
|
|
}
|
|
if args[argi] == "--" {
|
|
break // All transformers must do this so main-flags can follow verb-flags
|
|
}
|
|
argi++
|
|
|
|
switch opt {
|
|
case "-h", "--help":
|
|
transformerBootstrapCIUsage(os.Stdout)
|
|
return nil, cli.ErrHelpRequested
|
|
|
|
case "-a":
|
|
accumulatorNameList, err = cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case "-f":
|
|
valueFieldNameList, err = cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case "-g":
|
|
groupByFieldNameList, err = cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case "-n":
|
|
numResamples, err = cli.VerbGetIntArg(verb, opt, args, &argi, argc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case "-c":
|
|
confidenceLevel, err = cli.VerbGetFloatArg(verb, opt, args, &argi, argc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case "-i":
|
|
doInterpolatedPercentiles = true
|
|
|
|
default:
|
|
return nil, cli.VerbErrorf(verb, "option \"%s\" not recognized", opt)
|
|
}
|
|
}
|
|
|
|
if len(valueFieldNameList) == 0 {
|
|
return nil, cli.VerbErrorf(verb, "-f option is required")
|
|
}
|
|
if numResamples <= 0 {
|
|
return nil, cli.VerbErrorf(verb, "-n argument must be positive; got %d", numResamples)
|
|
}
|
|
if confidenceLevel <= 0.0 || confidenceLevel >= 1.0 {
|
|
return nil, cli.VerbErrorf(verb, "-c argument must be strictly between 0 and 1; got %v", confidenceLevel)
|
|
}
|
|
|
|
*pargi = argi
|
|
if !doConstruct { // All transformers must do this for main command-line parsing
|
|
return nil, nil
|
|
}
|
|
|
|
transformer, err := NewTransformerBootstrapCI(
|
|
accumulatorNameList,
|
|
valueFieldNameList,
|
|
groupByFieldNameList,
|
|
numResamples,
|
|
confidenceLevel,
|
|
doInterpolatedPercentiles,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return transformer, nil
|
|
}
|
|
|
|
type TransformerBootstrapCI struct {
|
|
// Input:
|
|
accumulatorNameList []string
|
|
valueFieldNameList []string
|
|
groupByFieldNameList []string
|
|
numResamples int64
|
|
confidenceLevel float64
|
|
doInterpolatedPercentiles bool
|
|
|
|
// State:
|
|
accumulatorFactory *utils.Stats1AccumulatorFactory
|
|
|
|
// Retained field values are indexed by
|
|
// groupingKey -> valueFieldName -> array of values
|
|
// using maps that preserve insertion order.
|
|
valuesByGroup *lib.OrderedMap[*lib.OrderedMap[[]*mlrval.Mlrval]]
|
|
|
|
// map[groupingKey]OrderedMap[groupByFieldName]*mlrval.Mlrval
|
|
groupingKeysToGroupByFieldValues map[string]*lib.OrderedMap[*mlrval.Mlrval]
|
|
}
|
|
|
|
func NewTransformerBootstrapCI(
|
|
accumulatorNameList []string,
|
|
valueFieldNameList []string,
|
|
groupByFieldNameList []string,
|
|
numResamples int64,
|
|
confidenceLevel float64,
|
|
doInterpolatedPercentiles bool,
|
|
) (*TransformerBootstrapCI, error) {
|
|
for _, name := range accumulatorNameList {
|
|
if !utils.ValidateStats1AccumulatorName(name) {
|
|
return nil, fmt.Errorf(`mlr %s: accumulator "%s" not found`, verbNameBootstrapCI, name)
|
|
}
|
|
}
|
|
|
|
tr := &TransformerBootstrapCI{
|
|
accumulatorNameList: accumulatorNameList,
|
|
valueFieldNameList: valueFieldNameList,
|
|
groupByFieldNameList: groupByFieldNameList,
|
|
numResamples: numResamples,
|
|
confidenceLevel: confidenceLevel,
|
|
doInterpolatedPercentiles: doInterpolatedPercentiles,
|
|
|
|
accumulatorFactory: utils.NewStats1AccumulatorFactory(),
|
|
valuesByGroup: lib.NewOrderedMap[*lib.OrderedMap[[]*mlrval.Mlrval]](),
|
|
groupingKeysToGroupByFieldValues: make(map[string]*lib.OrderedMap[*mlrval.Mlrval]),
|
|
}
|
|
|
|
return tr, nil
|
|
}
|
|
|
|
// Transform is the function executed for every input record, as well as for
|
|
// the end-of-stream marker.
|
|
func (tr *TransformerBootstrapCI) Transform(
|
|
inrecAndContext *types.RecordAndContext,
|
|
outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
|
|
inputDownstreamDoneChannel <-chan bool,
|
|
outputDownstreamDoneChannel chan<- bool,
|
|
) error {
|
|
HandleDefaultDownstreamDone(inputDownstreamDoneChannel, outputDownstreamDoneChannel)
|
|
if !inrecAndContext.EndOfStream {
|
|
tr.handleInputRecord(inrecAndContext)
|
|
} else {
|
|
tr.handleEndOfRecordStream(inrecAndContext, outputRecordsAndContexts)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tr *TransformerBootstrapCI) handleInputRecord(
|
|
inrecAndContext *types.RecordAndContext,
|
|
) {
|
|
inrec := inrecAndContext.Record
|
|
|
|
// E.g. if grouping by "a" and "b", and the current record has a=circle,
|
|
// b=blue, then groupingKey is the string "circle,blue".
|
|
groupingKey, ok := inrec.GetSelectedValuesJoined(tr.groupByFieldNameList)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
valuesByFieldName := tr.valuesByGroup.Get(groupingKey)
|
|
if valuesByFieldName == nil {
|
|
valuesByFieldName = lib.NewOrderedMap[[]*mlrval.Mlrval]()
|
|
tr.valuesByGroup.Put(groupingKey, valuesByFieldName)
|
|
|
|
// E.g. if grouping by "color" and "shape", and the current record has
|
|
// color=blue, shape=circle, then groupByFieldValues is the map
|
|
// {"color": "blue", "shape": "circle"}.
|
|
groupByFieldValuesArray, ok := inrec.GetSelectedValues(tr.groupByFieldNameList)
|
|
if !ok {
|
|
return
|
|
}
|
|
groupByFieldValues := lib.NewOrderedMap[*mlrval.Mlrval]()
|
|
for i, groupByFieldValue := range groupByFieldValuesArray {
|
|
groupByFieldValues.Put(tr.groupByFieldNameList[i], groupByFieldValue.Copy())
|
|
}
|
|
tr.groupingKeysToGroupByFieldValues[groupingKey] = groupByFieldValues
|
|
}
|
|
|
|
for _, valueFieldName := range tr.valueFieldNameList {
|
|
valueFieldValue := inrec.Get(valueFieldName)
|
|
if valueFieldValue == nil || valueFieldValue.IsVoid() {
|
|
continue
|
|
}
|
|
values := valuesByFieldName.Get(valueFieldName)
|
|
valuesByFieldName.Put(valueFieldName, append(values, valueFieldValue.Copy()))
|
|
}
|
|
}
|
|
|
|
func (tr *TransformerBootstrapCI) handleEndOfRecordStream(
|
|
inrecAndContext *types.RecordAndContext,
|
|
outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
|
|
) {
|
|
for pa := tr.valuesByGroup.Head; pa != nil; pa = pa.Next {
|
|
groupingKey := pa.Key
|
|
valuesByFieldName := pa.Value
|
|
|
|
newrec := mlrval.NewMlrmapAsRecord()
|
|
|
|
groupByFieldValues := tr.groupingKeysToGroupByFieldValues[groupingKey]
|
|
for pb := groupByFieldValues.Head; pb != nil; pb = pb.Next {
|
|
newrec.PutCopy(pb.Key, pb.Value)
|
|
}
|
|
|
|
for pc := valuesByFieldName.Head; pc != nil; pc = pc.Next {
|
|
valueFieldName := pc.Key
|
|
values := pc.Value
|
|
if len(values) == 0 {
|
|
continue
|
|
}
|
|
for _, accumulatorName := range tr.accumulatorNameList {
|
|
tr.emitConfidenceInterval(groupingKey, valueFieldName, accumulatorName, values, newrec)
|
|
}
|
|
}
|
|
|
|
*outputRecordsAndContexts = append(*outputRecordsAndContexts,
|
|
types.NewRecordAndContext(newrec, &inrecAndContext.Context))
|
|
}
|
|
|
|
*outputRecordsAndContexts = append(*outputRecordsAndContexts, inrecAndContext) // end-of-stream marker
|
|
}
|
|
|
|
// emitConfidenceInterval computes, for one group's values of one field, the
|
|
// full-data statistic along with its bootstrap confidence interval, placing
|
|
// the results into outrec.
|
|
func (tr *TransformerBootstrapCI) emitConfidenceInterval(
|
|
groupingKey string,
|
|
valueFieldName string,
|
|
accumulatorName string,
|
|
values []*mlrval.Mlrval,
|
|
outrec *mlrval.Mlrmap,
|
|
) {
|
|
// Point estimate: the statistic computed over the full data.
|
|
pointEstimate := tr.computeStatistic(groupingKey, valueFieldName, accumulatorName, values, false)
|
|
|
|
// Compute the statistic over each of the bootstrap resamples, retaining
|
|
// the resampled statistics in a percentile-keeper.
|
|
replicateStats := utils.NewPercentileKeeper(tr.doInterpolatedPercentiles)
|
|
for rep := int64(0); rep < tr.numResamples; rep++ {
|
|
replicateStats.Ingest(
|
|
tr.computeStatistic(groupingKey, valueFieldName, accumulatorName, values, true),
|
|
)
|
|
}
|
|
|
|
// E.g. for confidence level 0.95, the interval endpoints are the 2.5th
|
|
// and 97.5th percentiles of the resampled statistics.
|
|
alpha := (1.0 - tr.confidenceLevel) / 2.0
|
|
lo := replicateStats.Emit(100.0 * alpha)
|
|
hi := replicateStats.Emit(100.0 * (1.0 - alpha))
|
|
|
|
outputFieldNameBase := valueFieldName + "_" + accumulatorName
|
|
outrec.PutCopy(outputFieldNameBase, pointEstimate)
|
|
outrec.PutCopy(outputFieldNameBase+"_lo", lo)
|
|
outrec.PutCopy(outputFieldNameBase+"_hi", hi)
|
|
}
|
|
|
|
// computeStatistic computes a single stats1-style statistic over the given
|
|
// values -- either as-is, or over one same-length resample with replacement.
|
|
func (tr *TransformerBootstrapCI) computeStatistic(
|
|
groupingKey string,
|
|
valueFieldName string,
|
|
accumulatorName string,
|
|
values []*mlrval.Mlrval,
|
|
resample bool,
|
|
) *mlrval.Mlrval {
|
|
// Reset the factory so that percentile-statistic accumulators get a fresh
|
|
// percentile-keeper for each computation, rather than sharing one.
|
|
tr.accumulatorFactory.Reset()
|
|
accumulator := tr.accumulatorFactory.MakeAccumulator(
|
|
accumulatorName,
|
|
groupingKey,
|
|
valueFieldName,
|
|
tr.doInterpolatedPercentiles,
|
|
)
|
|
// Accumulator names were pre-validated at construction time.
|
|
lib.InternalCodingErrorIf(accumulator == nil)
|
|
|
|
n := int64(len(values))
|
|
if resample {
|
|
for i := int64(0); i < n; i++ {
|
|
accumulator.Ingest(values[lib.RandRange(0, n)])
|
|
}
|
|
} else {
|
|
for _, value := range values {
|
|
accumulator.Ingest(value)
|
|
}
|
|
}
|
|
|
|
return accumulator.Emit()
|
|
}
|