| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """Typed questions and a Beam transform for decision models. | |
| A decision model evaluates named questions against each input state. Choice | |
| selects a label, Boolean estimates the probability of a true statement, and | |
| Score evaluates an ordered rubric. Adapters translate these types to a local | |
| model or a service's API. | |
| Use the same questions and transform when changing providers:: | |
| from apache_beam.ml.inference.decision import ChoiceQuestion | |
| from apache_beam.ml.inference.decision import EvaluateDecisions | |
| questions = { | |
| 'team': ChoiceQuestion( | |
| 'Which team should handle this message?', | |
| {'billing': 'Invoices and refunds', 'support': 'Technical problems'}) | |
| } | |
| decisions = events | EvaluateDecisions(model, questions) | |
| Each output is a :class:`DecisionResult` containing the original state, | |
| normalized answers, provider metadata, and request latency. Applications apply | |
| thresholds and route these results with ordinary Beam transforms. | |
| """ | |
| import abc | |
| import time | |
| from collections.abc import Mapping | |
| from collections.abc import Sequence | |
| from dataclasses import dataclass | |
| from dataclasses import field | |
| from typing import Any | |
| from typing import Union | |
| import apache_beam as beam | |
| from apache_beam.metrics import Metrics | |
| __all__ = [ | |
| 'ChoiceQuestion', | |
| 'BooleanQuestion', | |
| 'ScoreQuestion', | |
| 'Question', | |
| 'ChoiceAnswer', | |
| 'BooleanAnswer', | |
| 'ScoreAnswer', | |
| 'Answer', | |
| 'DecisionResponse', | |
| 'DecisionResult', | |
| 'DecisionModel', | |
| 'EvaluateDecisions', | |
| ] | |
| @dataclass(frozen=True) | |
| class ChoiceQuestion: | |
| """Select one label from ``criteria``, a mapping of labels to descriptions.""" | |
| instructions: str | |
| criteria: Mapping[str, str] | |
| @dataclass(frozen=True) | |
| class BooleanQuestion: | |
| """Estimate the probability that the statement in ``instructions`` is true.""" | |
| instructions: str | |
| @dataclass(frozen=True) | |
| class ScoreQuestion: | |
| """Score an ordered rubric whose ``criteria`` levels are indexed from zero. | |
| Providers may interpolate between levels, returning a fractional score. | |
| """ | |
| instructions: str | |
| criteria: Sequence[str] | |
| Question = Union[ChoiceQuestion, BooleanQuestion, ScoreQuestion] | |
| @dataclass(frozen=True) | |
| class ChoiceAnswer: | |
| """A selected label, with optional label probabilities and confidence. | |
| Probabilities and confidence use [0, 1]. Confidence is a provider's estimate | |
| of answer certainty; ``None`` means the provider did not supply it. | |
| """ | |
| choice: str | |
| probabilities: Mapping[str, float] = field(default_factory=dict) | |
| confidence: float | None = None | |
| @dataclass(frozen=True) | |
| class BooleanAnswer: | |
| """Probability in [0, 1] that the statement is true. | |
| Applications choose their own threshold for acting on this probability. | |
| """ | |
| probability: float | |
| @dataclass(frozen=True) | |
| class ScoreAnswer: | |
| """Rubric score, optional probabilities by level index, and confidence. | |
| The score uses the question's zero-based scale. Probabilities and confidence | |
| use [0, 1]; ``None`` confidence means the provider did not supply it. | |
| """ | |
| score: float | |
| probabilities: Mapping[int, float] = field(default_factory=dict) | |
| confidence: float | None = None | |
| Answer = Union[ChoiceAnswer, BooleanAnswer, ScoreAnswer] | |
| @dataclass(frozen=True) | |
| class DecisionResponse: | |
| """One answer per question name, plus the model and provider identifiers.""" | |
| answers: Mapping[str, Answer] | |
| model: str | |
| provider: str | |
| request_id: str | None = None | |
| @dataclass(frozen=True) | |
| class DecisionResult: | |
| """An input state, its decision response, and evaluation time in milliseconds.""" | |
| state: Any | |
| response: DecisionResponse | |
| latency_ms: float | |
| class DecisionModel(abc.ABC): | |
| """Adapter contract for hosted and local decision models. | |
| Implement :meth:`evaluate` to return the shared answer types. Override the | |
| context manager methods to open and close clients or load model weights. | |
| Constructors hold serializable configuration; Beam enters the context on | |
| the worker and reuses it across elements and bundles on that DoFn instance. | |
| An adapter owns provider timeouts, retries, and conversion to the shared | |
| types. Input states must be serializable by Beam and accepted by the adapter. | |
| """ | |
| def __enter__(self) -> 'DecisionModel': | |
| return self | |
| def __exit__(self, exc_type, exc_value, traceback) -> None: | |
| pass | |
| @abc.abstractmethod | |
| def evaluate( | |
| self, state: Any, questions: Mapping[str, Question]) -> DecisionResponse: | |
| """Evaluate each named question against the same state. | |
| Return exactly one answer of the corresponding type for each question. | |
| Choice labels must belong to the question's criteria; score levels use | |
| their zero-based indices. Raise an exception if evaluation fails. | |
| """ | |
| raise NotImplementedError | |
| class EvaluateDecisions(beam.PTransform): | |
| """Evaluate typed questions for every input element with a reusable model. | |
| Args: | |
| model: A serializable :class:`DecisionModel` adapter. Its context is entered | |
| once per DoFn instance and closed during teardown, which is best effort. | |
| questions: A mapping of question names to Choice, Boolean, or Score | |
| questions. All questions are evaluated against each input state. | |
| Returns: | |
| A PCollection of :class:`DecisionResult` objects, preserving each element's | |
| timestamp and window. Calls run synchronously on the processing thread. | |
| The ``EvaluateDecisions`` metrics namespace records ``requests``, | |
| ``failures``, and the ``request_latency_ms`` distribution for successful | |
| evaluations. Exceptions propagate to the runner. | |
| """ | |
| def __init__(self, model: DecisionModel, questions: Mapping[str, Question]): | |
| super().__init__() | |
| self._model = model | |
| self._questions = dict(questions) | |
| self._exception_handling = None | |
| def with_exception_handling(self, **kwargs): | |
| """Return successful decisions and failed states as tagged PCollections. | |
| Uses :meth:`apache_beam.ParDo.with_exception_handling` with the supplied | |
| options. The default tags are ``good`` and ``bad``; bad records contain | |
| the original state and exception details. Only processing failures are | |
| caught. Model setup and teardown failures still propagate to the runner. | |
| """ | |
| self._exception_handling = kwargs | |
| return self | |
| def expand(self, states): | |
| evaluate = beam.ParDo(_EvaluateDecisionsDoFn(self._model, self._questions)) | |
| if self._exception_handling is not None: | |
| evaluate = evaluate.with_exception_handling(**self._exception_handling) | |
| return states | evaluate | |
| class _EvaluateDecisionsDoFn(beam.DoFn): | |
| def __init__(self, model, questions): | |
| self._model = model | |
| self._questions = questions | |
| self._active_model = None | |
| self._requests = Metrics.counter('EvaluateDecisions', 'requests') | |
| self._failures = Metrics.counter('EvaluateDecisions', 'failures') | |
| self._latency = Metrics.distribution( | |
| 'EvaluateDecisions', 'request_latency_ms') | |
| def setup(self): | |
| self._active_model = self._model.__enter__() | |
| def process(self, state): | |
| self._requests.inc() | |
| started = time.perf_counter() | |
| try: | |
| response = self._active_model.evaluate(state, self._questions) | |
| except Exception: | |
| self._failures.inc() | |
| raise | |
| elapsed_ms = (time.perf_counter() - started) * 1000 | |
| self._latency.update(int(elapsed_ms)) | |
| yield DecisionResult(state, response, elapsed_ms) | |
| def teardown(self): | |
| if self._active_model is not None: | |
| self._model.__exit__(None, None, None) | |
| self._active_model = None |