Skip to main content

(C)ompute (O)perations in (D)ependency (O)rder

Project description

CODO: (C)ompute (O)perations in (D)ependency (O)rder

CODO is an easy-to-use library which abstracts the programming pattern where a set of operations (functions) with the same parameter lists must be computed over the same data, but some of those operations might depend on others' outputs.

  • 75 source lines of code
  • Full type safety thanks to typing.Generic

A simple example is computing precision, recall, accuracy, and f1 scores for classification tasks in machine learning.

All of these could be computed independently, for example with four functions that each take two arrays of true and predicted class labels, but then we'd be computing TN, FN, FP, and FN multiple times over, and we'd even have to re-compute precision and recall to get the f1 score.

Typically, you'd solve this by just computing all these metrics in the scope of a single function, but for more complex tasks, it's often desirable to isolate this kind of logic into separate functions that each have one job.

CODO to the rescue: this library lets you to define operations to isolate logic with a simple API, automatically figures out the right order to compute them in, computes the "shared" operations only once, and (optionally) parallelizes where possible*.

* Parallelization not yet implemented, PRs welcome!

Quickstart

What we'll build

codo = CODO[MetricsParams](
    [
        Precision(),
        Recall(),
        Accuracy(),
        F1Score(),
        TruePositives(silent=True),
        TrueNegatives(silent=True),
        FalsePositives(silent=True),
        FalseNegatives(silent=True),
    ]
)

y_true = [1, 0, 1, 1, 0, 1]
y_pred = [1, 1, 1, 0, 0, 1]

print(codo(MetricsParams(y_true, y_pred)))
'''
{
    <class '__main__.Precision'>: 0.75,
    <class '__main__.Recall'>: 0.75,
    <class '__main__.Accuracy'>: 0.6666666666666666,
    <class '__main__.F1Score'>: 0.75
}
'''

Let's go!

  1. Define the params that each Operation will take. Important: All Operations you plan to compute in the same CODO object must accept the same argument list. We wrap those params with a dataclass to enable full type safety. In our example, we'll define the types for the "true" and "predicted" class labels.

    from dataclasses import dataclass
    
    @dataclass
    class MetricsParams:
        y_true: list[int]
        y_pred: list[int]
    
  2. Define operations by subclassing codo.Operation. The library defines this base class with two generic parameters that specify the input and output types for its __call__() method, respectively (see code comments for details).

    Subclasses must override the __call__() method, and may set a list of its dependencies as a class variable.

    The __call__() method takes two parameters:

    • acc: A codo.Accumulator[ParamsT] object, where ParamsT is a generic variable that, in our case, will be the MetricsParams dataclass. Essentially this is a glorified dict providing full type safety that stores the results of operations computed earlier in the dependency tree. You can access those results by keying into acc with the operation's class.
    • params: An instance of the MetricsParams dataclass we defined above.

    from codo import Operation
    from typing import override
    
    
    # When we subclass `Operation[MetricsParams, int]` below, the generics
    # are providing a shorthand for the following signature for the `__call__()` method:
    #
    #     def __call__(
    #         self,
    #         acc: codo.Accumulator[MetricsParams], 
    #         params: MetricsParams
    #     ) -> int:
    #         ...
    #
    # The first generic param sets the type for `params`,
    # and the second sets the method's return type.
    class TruePositives(Operation[MetricsParams, int]):
        @override
        def __call__(self, acc, params):
            return sum(t == 1 and p == 1 for t, p in zip(true, pred))
    
    
    class FalsePositives(Operation[MetricsParams, int]):
        @override
        def __call__(self, acc, params):
            return sum(t == 0 and p == 1 for t, p in zip(true, pred))
    
    
    class Precision(Operation[MetricsParams, float]):
        dependencies = [TruePositives, FalsePositives]
    
        @override
        def __call__(self, acc, params):
            tp = acc[TruePositives]
            fp = acc[FalsePositives]
            # ^ Both resolve to `int` types here thanks to
            # generics on TruePositives/FalsePositives!
    
            return tp / (tp + fp)
    
  3. Create a CODO object with your Operations, then call it like a function to get the outputs.

    Notice that we specify the generic type in-line for the CODO object to get type safety on the result it returns, which is a codo.Accumulator[MetricsParams] object that has the same interface as a Python dict. This is where type safety gets cool, because result[Precision] will have type float and result[TruePositives] will have type int.

    You may have Operations for intermediate steps where you don't actually care about the output—in those cases, you can set silent=True to discard their outputs, unless you set with_silents=True.

    from codo import CODO
    
    codo = CODO[MetricsParams](
        [
            Precision(),
            TruePositives(silent=True),
            FalsePositives(silent=True),
        ]
    )
    # ^ Order doesn't matter in this list, Operations
    # will be computed in a valid dependency order.
    
    result = codo(
        MetricsParams(
            y_true=[1, 0, 1, 1, 0, 1],
            y_pred=[1, 1, 1, 0, 0, 1]
        ),
        # with_silents=True
    )
    
    print(result)
    # {<class '__main__.Precision'>: 0.75}
    

Full example code

The code below computes precision, recall, accuracy, and f1 score.

from typing import override

"""
Define parameter list for Operation types
"""

@dataclass
class MetricsParams:
    y_true: list[int]
    y_pred: list[int]

"""
Define all Operation types
"""

class TruePositives(Operation[MetricsParams, int]):
    @override
    def __call__(self, acc, params):
        return sum(t == p == 1 for t, p in zip(params.y_true, params.y_pred))


class TrueNegatives(Operation[MetricsParams, int]):
    @override
    def __call__(self, acc, params):
        return sum(t == p == 0 for t, p in zip(params.y_true, params.y_pred))


class FalsePositives(Operation[MetricsParams, int]):
    @override
    def __call__(self, acc, params):
        return sum(t == 0 and p == 1 for t, p in zip(params.y_true, params.y_pred))


class FalseNegatives(Operation[MetricsParams, int]):
    @override
    def __call__(self, acc, params):
        return sum(t == 1 and p == 0 for t, p in zip(params.y_true, params.y_pred))


class Precision(Operation[MetricsParams, float]):
    dependencies = [TruePositives, FalsePositives]

    @override
    def __call__(self, acc, params):
        tp = acc[TruePositives]  # Resolves to `int` type
        fp = acc[FalsePositives]
        return tp / (tp + fp)


class Recall(Operation[MetricsParams, float]):
    dependencies = [TruePositives, FalseNegatives]

    @override
    def __call__(self, acc, params):
        tp = acc[TruePositives]
        fn = acc[FalseNegatives]
        return tp / (tp + fn)


class Accuracy(Operation[MetricsParams, float]):
    dependencies = [TruePositives, TrueNegatives, FalsePositives, FalseNegatives]

    @override
    def __call__(self, acc, params):
        tp = acc[TruePositives]
        tn = acc[TrueNegatives]
        fp = acc[FalsePositives]
        fn = acc[FalseNegatives]
        return (tp + tn) / (tp + tn + fp + fn)


class F1Score(Operation[MetricsParams, float]):
    dependencies = [Precision, Recall]

    @override
    def __call__(self, acc, params):
        precision = acc[Precision]
        recall = acc[Recall]
        return 2 * (precision * recall) / (precision + recall)


# Wrap all Operations in a CODO object. Defining the generic param type
# in-line allows you to query into `result` with type safety.
# Dependency order doesn't matter in this list.
codo = CODO[MetricsParams](
    [
        Precision(),
        Recall(),
        Accuracy(),
        F1Score(),
        TruePositives(silent=True),
        TrueNegatives(silent=True),
        FalsePositives(silent=True),
        FalseNegatives(silent=True),
    ]
)

# Call your object like a function on arbitrary data
result = codo(
    MetricsParams(y_true=[1, 0, 1, 1, 0, 1], y_pred=[1, 1, 1, 0, 0, 1]),
    # with_silents=True,
)

# accuracy = result[Accuracy]  # Resolves to `float` type

print(result)

'''
{
    <class '__main__.Precision'>: 0.75,
    <class '__main__.Recall'>: 0.75,
    <class '__main__.Accuracy'>: 0.6666666666666666,
    <class '__main__.F1Score'>: 0.75
}
'''

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

codo_python-1.0.4.tar.gz (6.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

codo_python-1.0.4-py3-none-any.whl (6.2 kB view details)

Uploaded Python 3

File details

Details for the file codo_python-1.0.4.tar.gz.

File metadata

  • Download URL: codo_python-1.0.4.tar.gz
  • Upload date:
  • Size: 6.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.8

File hashes

Hashes for codo_python-1.0.4.tar.gz
Algorithm Hash digest
SHA256 28cef23ca167bb4cd887a5ea529c3be967279a737f81fcf5fa7d8979a5279706
MD5 9e305d8d4d62d605cb61d8e2b65e4b5d
BLAKE2b-256 8fa091905a646d97b1c8a9ec43a6bddff4406633b5c375789d25ba45e421cc41

See more details on using hashes here.

File details

Details for the file codo_python-1.0.4-py3-none-any.whl.

File metadata

  • Download URL: codo_python-1.0.4-py3-none-any.whl
  • Upload date:
  • Size: 6.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.8

File hashes

Hashes for codo_python-1.0.4-py3-none-any.whl
Algorithm Hash digest
SHA256 abefcf04c9baf55feb6ae54b5b85bf86de5fe50232db6f148b4ef3f5a377bf10
MD5 91898e7b50441aff7527c7e67fc92b81
BLAKE2b-256 0dca16bb5ede4c0bccb20fbaeef1da26e282adea040d0db9353cff76fe2ea91c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page