Module dp_policy.titlei.allocators

Expand source code Browse git
from dp_policy.titlei.utils import \
    weighting
from dp_policy.titlei.thresholders import Threshold, HardThresholder

import numpy as np
import pandas as pd

from typing import Tuple, List


class Allocator:
    def __init__(
        self,
        estimates: pd.DataFrame,
        prefixes: Tuple[str] = ("true", "est", "dp", "dpest"),
        congress_cap: float = 0.4,
        adj_sppe_bounds: List[float] = [0.32, 0.48],
        adj_sppe_bounds_efig: List[float] = [0.34, 0.46],
        appropriation: float = None,
        verbose: bool = False
    ):
        """Class for allocating Title I funds.

        Args:
            estimates (pd.DataFrame): The poverty estimates, including
                randomized estimates.
            prefixes (Tuple[str], optional): Prefixes for different kinds of
                estimates. Defaults to ("true", "est", "dp", "dpest").
            congress_cap (float, optional): Congressional cap. Defaults to 0.4.
            adj_sppe_bounds (List[float], optional): Bounds on adjusted SPPE.
                Defaults to [0.32, 0.48].
            adj_sppe_bounds_efig (List[float], optional): Bounds on adjusted
                SPPE for EFIG grants. Defaults to [0.34, 0.46].
            appropriation (float, optional): Total congressional appropriation.
                Defaults to None.
            verbose (bool, optional): Defaults to False.
        """
        self.estimates = estimates
        self.prefixes = prefixes
        self.congress_cap = congress_cap
        self.adj_sppe_bounds = adj_sppe_bounds
        self.adj_sppe_bounds_efig = adj_sppe_bounds_efig
        self.appropriation_total = appropriation
        self.verbose = verbose

    def allocations(
        self,
        normalize: bool = False
    ) -> pd.DataFrame:
        """Compute allocations.

        Args:
            normalize (bool, optional): Whether to normalize the authorization
                amounts. Defaults to False.

        Returns:
            pd.DataFrame: Estimates dataframe with computed allocations added.
        """
        self.calc_auth()
        if normalize:
            self.normalize()
        return self.estimates

    def calc_auth(self):
        """
        Appends the allocated grants as columns to the estimates DataFrame.

        Must generate at least `true_grant_total` and `est_grant_total`.

        returns:
            pd.DataFrame: current estimates
        """
        raise NotImplementedError

    def normalize(self):
        """Normalize authorization amounts to allocation amounts.
        """

    def adj_sppe(self):
        """Calculate adjusted SPPE using Sonnenberg, 2016 pg. 18 algorithm.
        """
        # Get baseline average across all 50 states and territories
        average = np.round(
            self.estimates.sppe.groupby("State FIPS Code").first().mean(),
            decimals=2
        )
        # Each state’s and each territory’s SPPE is multiplied by the
        # congressional cap and rounded to the second decimal place
        # (for dollars and cents).
        scaled = np.round(self.estimates.sppe * self.congress_cap, decimals=2)
        # No state recieves above/below the bounds set by law
        adj_sppe_trunc = scaled.clip(
            # bound by some % of the average, given in the law - round to cents
            *np.round(np.array(self.adj_sppe_bounds)*average, decimals=2)
        )
        adj_sppe_efig = scaled.clip(
            # bound %s are different for EFIG
            *np.round(np.array(self.adj_sppe_bounds_efig)*average, decimals=2)
        )
        return adj_sppe_trunc, adj_sppe_efig


class AbowdAllocator(Allocator):
    """
    As described in https://arxiv.org/pdf/1808.06303.pdf
    """
    def grant_types(self):
        return (
            "total"
        )

    def calc_auth(self):
        adj_sppe, _ = self.adj_sppe()

        self.estimates["adj_sppe"] = adj_sppe
        for prefix in self.prefixes:
            self.estimates[f"{prefix}_grant_total"] = \
                adj_sppe * self.estimates[f"{prefix}_children_eligible"]

        return self.estimates


class Authorizer(Allocator):
    """An allocator that uses a normalization strategy to convert
    authorization amounts to allocation amounts.
    """
    def grant_types(self):
        raise NotImplementedError

    def calc_total(self):
        for prefix in self.prefixes:
            self.estimates[f"{prefix}_grant_total"] = self.estimates[[
                f"{prefix}_grant_{grant_type}"
                for grant_type in self.grant_types()
            ]].sum(axis=1)
        return self.estimates

    def allocations(
        self, normalize=True, **kwargs
    ) -> pd.DataFrame:
        super().allocations(**kwargs)
        if normalize:
            for grant_type in self.grant_types():
                for prefix in self.prefixes:
                    appropriation = \
                        self._calc_appropriation_total(grant_type)
                    self._normalize(grant_type, prefix, appropriation)
            self.calc_total()
            if self.verbose:
                total_approp = self.estimates[[
                    f"official_{grant_type}_alloc"
                    for grant_type in self.grant_types()
                ]].sum().sum()
                print(
                    "After normalization, appropriation is",
                    total_approp,
                    "and true allocation is",
                    self.estimates.true_grant_total.sum()
                )
        return self.estimates

    def _calc_appropriation_total(
        self,
        grant_type: str
    ) -> float:
        """Using official figures, calculate appropriation for this grant type.

        Args:
            grant_type (str): The grant type. One of "basic", "concentration",
                "targeted".

        Returns:
            float: The total appropration for this grant type.
        """
        appropriation = self.estimates[f"official_{grant_type}_alloc"].sum()
        if self.appropriation_total is not None:
            if self.verbose:
                print("Usual appropriation:", appropriation)
                print(
                    "Usual total:",
                    self.estimates[f"official_total_alloc"].sum()
                )
                print(
                    self.appropriation_total /
                    self.estimates[f"official_total_alloc"].sum()
                )
                print("New appropriation:", (
                    appropriation * self.appropriation_total /
                    self.estimates[f"official_total_alloc"].sum()
                ))
            # scale appropriation to total budget
            return (
                appropriation * self.appropriation_total /
                self.estimates[f"official_total_alloc"].sum()
            )
        return appropriation

    def _normalize(
        self,
        grant_type: str,
        prefix: str,
        appropriation: float,
        hold_harmless: pd.Series = None,
        state_minimum: bool = False
    ):
        """Normalize funding amounts, honoring special provisions.

        Args:
            grant_type (str):  The grant type. One of "basic", "concentration",
                "targeted".
            prefix (str): Prefix for estimate. One of
                ("true", "est", "dp", "dpest").
            appropriation (float): The total appropriation available.
            hold_harmless (pd.DataFrame, optional): If provided, which
                districts are held harmless. Defaults to None.
            state_minimum (bool, optional): Whether to apply the state minimum.
                Defaults to False.
        """
        if hold_harmless is None:
            hold_harmless = np.zeros(len(self.estimates)).astype(bool)

        current_budget = \
            self.estimates[f"{prefix}_grant_{grant_type}"].sum()

        # do a round of hold harmless normalization
        # (to get the alloc instead of auth amounts)
        self._normalize_segment(
                np.ones(len(self.estimates)).astype(bool),
                hold_harmless,
                grant_type, prefix,
                appropriation
            )

        if state_minimum:
            glob_min = \
                SonnenbergAuthorizer._state_minimum_global(grant_type)
            formula_children = self.estimates[f"{prefix}_children_eligible"]\
                .where(
                    self.estimates[f"{prefix}_eligible_{grant_type}"],
                    0
                )
            state_eligible = formula_children\
                .groupby("State FIPS Code").transform('sum')
            # nat'l avg per-pupil payment (???)
            napp = appropriation \
                / self.estimates[f"{prefix}_children_total"].sum()
            eligib_comp = state_eligible * 1.5 * napp
            if grant_type == "concentration":
                # for concentration, this amount is at minimum 340000
                eligib_comp = np.clip(eligib_comp, 340000, None)

            # the state minimum is the smaller of
            state_minimums = np.minimum(
                # 1) the global minimum - derived from the official
                # FY 2021 state allocs
                np.ones(len(self.estimates)) * glob_min,
                # and 2) the average of
                1/2 * (
                    # a) global minimum and
                    # b) the state's elibility count * 1.5 * avg SPPE
                    glob_min + eligib_comp
                )
            )
            # identify LEAs in states under the minimum
            under_minimum = self.estimates[f"{prefix}_grant_{grant_type}"]\
                .groupby("State FIPS Code").transform('sum') < state_minimums
            if self.verbose:
                print(
                    "These states meet the state minimum:",
                    np.unique(
                        under_minimum[under_minimum].index
                        .get_level_values("State FIPS Code").values
                    )
                )

            # first, normalize all the over-minimum states
            total_minimum = state_minimums[under_minimum]\
                .groupby("State FIPS Code").first().sum()
            self._normalize_segment(
                ~under_minimum,
                hold_harmless,
                grant_type, prefix,
                appropriation - total_minimum
            )

            # then normalize the under-minimum states, state-by-state
            for _, group in self.estimates[under_minimum].groupby(
                "State FIPS Code"
            ):
                minimum = state_minimums[group.index].iloc[0]
                self._normalize_segment(
                    self.estimates.index.isin(group.index),
                    hold_harmless,
                    grant_type, prefix,
                    minimum
                )

        if self.verbose:
            print(
                f"{current_budget} authorized for {grant_type} reduced "
                f"to {appropriation} allocated."
            )

    def _normalize_segment(
        self,
        segment: pd.Series,
        hold_harmless: pd.Series,
        grant_type: str,
        prefix: str,
        segment_appropriation: float
    ):
        """Normalize the budget for a segment of the districts.

        Args:
            segment (pd.Series): A boolean mask indicating the segment to
                normalize.
            hold_harmless (pd.Series): Which districts should be held harmless.
            grant_type (str): Grant type to normalize.
            prefix (str): Which treatment to normalize (generally "true",
                "est" or "dpest").
            segment_appropriation (float): The total budget for this segment.
        """
        # available budget is the full budget minus hold harmless districts
        remaining_budget = segment_appropriation - self.estimates.loc[
                segment & hold_harmless, f"{prefix}_grant_{grant_type}"
            ].sum()
        # redistribute the remaining budget between non-harmless districts
        self.estimates.loc[
            segment & ~hold_harmless, f"{prefix}_grant_{grant_type}"
        ] = \
            Authorizer.normalize_to_budget(
                self.estimates.loc[
                    segment & ~hold_harmless, f"{prefix}_grant_{grant_type}"
                ], remaining_budget
            )

    @staticmethod
    def normalize_to_budget(
        authorizations: pd.Series,
        total_budget: int
    ) -> pd.Series:
        """Scale authorizations proportional to total federal budget.

        Args:
            authorizations (pd.Series): Authorization amounts.
            total_budget (int): Estimated total budget for Title I this year.

        Returns:
            pd.Series: Normalized authorization amounts.
        """
        return authorizations / authorizations.sum() * total_budget


class SonnenbergAuthorizer(Authorizer):
    """Authorizer allocator described by Sonnenberg (2007). Official process
    used by Dep Ed.
    """
    def __init__(
        self,
        *args,
        **kwargs
    ):
        self.hold_harmless = kwargs.pop('hold_harmless', False)
        self.state_minimum = kwargs.pop('state_minimum', False)
        self.thresholder = kwargs.pop('thresholder', HardThresholder())
        super().__init__(*args, **kwargs)
        if self.state_minimum and self.verbose:
            print(
                "[WARN] State minimum works using 2021 data. "
                "Will be wrong for earlier years."
            )

    def allocations(
        self, **kwargs
    ) -> pd.DataFrame:
        super().allocations(**kwargs)
        if self.hold_harmless or self.state_minimum:
            self._provisions()
        return self.estimates

    def grant_types(self):
        return (
            "basic",
            "concentration",
            "targeted"
        )

    def calc_auth(self):
        # calc adj. SPPE
        adj_sppe, _ = self.adj_sppe()
        self.thresholder.set_cv(self.estimates.cv)

        # calculate grant amounts for true/randomized values
        for prefix in self.prefixes:
            # BASIC GRANTS
            # authorization calculation
            self.estimates[f"{prefix}_grant_basic"] = \
                self.estimates[f"{prefix}_children_eligible"] * adj_sppe
            # For basic grants, LEA must have
            # >10 eligible children
            # AND >2% eligible
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_basic"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(10, prop=False),
                        Threshold(0.02, prop=True)
                    ],
                    comb_func=np.logical_and.reduce
                )
            self.estimates.loc[:, f"{prefix}_grant_basic"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_basic"] = eligible

            # CONCENTRATION GRANTS
            # For concentration grants, LEAs must meet basic eligibility
            # AND have either
            # a) >6500 eligible
            # b) 15% of pop. is eligible
            self.estimates[f"{prefix}_grant_concentration"] = \
                self.estimates[f"{prefix}_grant_basic"]
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_concentration"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(6500, prop=False),
                        Threshold(0.15, prop=True)
                    ],
                    comb_func=lambda x:
                        self.estimates[f"{prefix}_eligible_basic"]
                        & np.logical_or.reduce(x)
                )
            self.estimates.loc[:, f"{prefix}_grant_concentration"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_concentration"] = \
                eligible

            # TARGETED GRANTS
            # weighted by an exogenous step function - see documentation
            weighted_eligible = self.estimates[[
                f"{prefix}_children_eligible", f"{prefix}_children_total"
            ]].apply(
                lambda x: weighting(x[0], x[1]),
                axis=1
            )
            self.estimates[
                f"{prefix}_grant_targeted"
            ] = weighted_eligible * adj_sppe
            # for targeted grants, LEAs must:
            # meet basic eligibility AND have >5% eligible
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_targeted"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(10, prop=False),
                        Threshold(0.05, prop=True)
                    ],
                    comb_func=np.logical_and.reduce
                )
            self.estimates.loc[:, f"{prefix}_grant_targeted"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_targeted"] = eligible

            # EFIG
            # TODO

            # clip lower bound to zero
            for grant_type in ["basic", "concentration", "targeted"]:
                self.estimates.loc[
                    self.estimates[f"{prefix}_grant_{grant_type}"] < 0.0,
                    f"{prefix}_grant_{grant_type}"
                ] = 0.0

        self.calc_total()

    def _provisions(self):
        """
        Apply post-formula provisions (hold harmless and state minimum).
        Achieved by recursively updating allocations until all provisions are
        satisfied.
        """
        if self.verbose:
            if self.hold_harmless:
                print("Applying hold harmless")
            if self.state_minimum:
                print("Applying state minimum")

        # load last year's allocs - watch out for endogeneity
        # get this year's budget
        for grant_type in self.grant_types():
            alloc_previous = \
                self.estimates[f"official_{grant_type}_hold_harmless"]
            appropriation = self._calc_appropriation_total(grant_type)
            for prefix in self.prefixes:
                # hold harmless
                self._harmless_rate = SonnenbergAuthorizer._hold_harmless_rate(
                    self.estimates[f"{prefix}_children_eligible"] /
                    self.estimates[f"{prefix}_children_total"]
                )
                self._provisions_recursive(
                    0,
                    prefix,
                    grant_type,
                    appropriation,
                    alloc_previous
                )

        self.calc_total()
        if self.verbose:
            total_approp = self.estimates[[
                f"official_{grant_type}_alloc"
                for grant_type in self.grant_types()
            ]].sum().sum()
            print(
                "After provision(s), appropriation is",
                total_approp,
                "and true allocation is",
                self.estimates.true_grant_total.sum()
            )

    def _provisions_recursive(
        self,
        depth: int,
        prefix: str,
        grant_type: str,
        appropriation: float,
        alloc_previous: pd.Series,
        held_harmless: bool = None,
        max_depth: int = 10
    ):
        """Apply one iteration of post-formula provisions.

        Args:
            depth (int): Current iteration of recursion.
            prefix (str): Prefix of allocation to adjust.
            grant_type (str): Grant type to adjust.
            appropriation (float): Appropriation for this grant type.
            alloc_previous (pd.Series): Previous year's allocations.
            held_harmless (bool, optional): Boolena mask for districts to hold
                harmless. Defaults to None.
            max_depth (int, optional): Maximum number of iterations to run
                before stopping. Defaults to 10.
        """
        # assume no LEAs in violation of provisions
        leas_in_violation = np.zeros(len(self.estimates)).astype(bool)

        if self.hold_harmless:
            if held_harmless is None:
                held_harmless = np.zeros(len(self.estimates)).astype(bool)
            # identify LEAs suffering excessive harm
            hold_harmless = self._excessive_loss(
                prefix, grant_type, alloc_previous, self._harmless_rate
            )
            if self.verbose and depth > 0:
                print(
                    "Hold harmless iter", depth,
                    f"- {hold_harmless.sum()} hold harm districts remaining"
                )
            # limit losses to the appropriate harmless rate
            if hold_harmless.any():
                held_harmless = held_harmless | hold_harmless
                leas_in_violation = leas_in_violation | hold_harmless
                self.estimates.loc[
                    hold_harmless, f"{prefix}_grant_{grant_type}"
                ] = alloc_previous * self._harmless_rate
                self._normalize(
                    grant_type, prefix, appropriation,
                    hold_harmless=held_harmless,
                    state_minimum=self.state_minimum
                )
        else:
            self._normalize(
                grant_type, prefix, appropriation,
                state_minimum=self.state_minimum
            )

        # once no LEAs are in violation, finish
        if not leas_in_violation.any():
            return

        if depth >= max_depth:
            if self.hold_harmless:
                print(f"[WARN]: {hold_harmless.sum()} not held harmless.")
            print(
                f"[WARN] Could not converge after {max_depth} iterations."
            )
            return

        return self._provisions_recursive(
            depth+1,
            prefix,
            grant_type,
            appropriation,
            alloc_previous,
            held_harmless=held_harmless,
            max_depth=max_depth
        )

    def _excessive_loss(
        self,
        prefix: str,
        grant_type: str,
        alloc_previous: pd.Series,
        harmless_rate: pd.Series
    ) -> pd.Series:
        """Determine whether a district has suffered excessive loss.

        Returns:
            pd.Series: Boolean mask for each district indicating whether it
                has suffered excessive loss compared to the previous
                year's allocation.
        """
        return (
            self.estimates[f"{prefix}_grant_{grant_type}"] <
            harmless_rate * alloc_previous
        )

    @staticmethod
    def _hold_harmless_rate(prop_eligible: pd.Series) -> np.ndarray:
        """Determine the hold harmless rate for a district based on the
        proportion of eligible children.
        """
        return np.where(
            prop_eligible < 0.15,
            0.85,
            np.where(
                prop_eligible < 0.3,
                0.9,
                0.95
            )
        )

    @staticmethod
    def _state_minimum_global(grant_type: str) -> int:
        # drawing this manually from the FY 2021 state-level data
        # NOTE: only works for 2021 data
        if grant_type == "basic":
            return 17744098
        if grant_type == "concentration":
            return 3378479
        if grant_type == "targeted":
            return 15083659
        raise ValueError(f"Unmatched grant type: {grant_type}")

Classes

class AbowdAllocator (estimates: pandas.core.frame.DataFrame, prefixes: Tuple[str] = ('true', 'est', 'dp', 'dpest'), congress_cap: float = 0.4, adj_sppe_bounds: List[float] = [0.32, 0.48], adj_sppe_bounds_efig: List[float] = [0.34, 0.46], appropriation: float = None, verbose: bool = False)

As described in https://arxiv.org/pdf/1808.06303.pdf

Class for allocating Title I funds.

Args

estimates : pd.DataFrame
The poverty estimates, including randomized estimates.
prefixes : Tuple[str], optional
Prefixes for different kinds of estimates. Defaults to ("true", "est", "dp", "dpest").
congress_cap : float, optional
Congressional cap. Defaults to 0.4.
adj_sppe_bounds : List[float], optional
Bounds on adjusted SPPE. Defaults to [0.32, 0.48].
adj_sppe_bounds_efig : List[float], optional
Bounds on adjusted SPPE for EFIG grants. Defaults to [0.34, 0.46].
appropriation : float, optional
Total congressional appropriation. Defaults to None.
verbose : bool, optional
Defaults to False.
Expand source code Browse git
class AbowdAllocator(Allocator):
    """
    As described in https://arxiv.org/pdf/1808.06303.pdf
    """
    def grant_types(self):
        return (
            "total"
        )

    def calc_auth(self):
        adj_sppe, _ = self.adj_sppe()

        self.estimates["adj_sppe"] = adj_sppe
        for prefix in self.prefixes:
            self.estimates[f"{prefix}_grant_total"] = \
                adj_sppe * self.estimates[f"{prefix}_children_eligible"]

        return self.estimates

Ancestors

Methods

def grant_types(self)
Expand source code Browse git
def grant_types(self):
    return (
        "total"
    )

Inherited members

class Allocator (estimates: pandas.core.frame.DataFrame, prefixes: Tuple[str] = ('true', 'est', 'dp', 'dpest'), congress_cap: float = 0.4, adj_sppe_bounds: List[float] = [0.32, 0.48], adj_sppe_bounds_efig: List[float] = [0.34, 0.46], appropriation: float = None, verbose: bool = False)

Class for allocating Title I funds.

Args

estimates : pd.DataFrame
The poverty estimates, including randomized estimates.
prefixes : Tuple[str], optional
Prefixes for different kinds of estimates. Defaults to ("true", "est", "dp", "dpest").
congress_cap : float, optional
Congressional cap. Defaults to 0.4.
adj_sppe_bounds : List[float], optional
Bounds on adjusted SPPE. Defaults to [0.32, 0.48].
adj_sppe_bounds_efig : List[float], optional
Bounds on adjusted SPPE for EFIG grants. Defaults to [0.34, 0.46].
appropriation : float, optional
Total congressional appropriation. Defaults to None.
verbose : bool, optional
Defaults to False.
Expand source code Browse git
class Allocator:
    def __init__(
        self,
        estimates: pd.DataFrame,
        prefixes: Tuple[str] = ("true", "est", "dp", "dpest"),
        congress_cap: float = 0.4,
        adj_sppe_bounds: List[float] = [0.32, 0.48],
        adj_sppe_bounds_efig: List[float] = [0.34, 0.46],
        appropriation: float = None,
        verbose: bool = False
    ):
        """Class for allocating Title I funds.

        Args:
            estimates (pd.DataFrame): The poverty estimates, including
                randomized estimates.
            prefixes (Tuple[str], optional): Prefixes for different kinds of
                estimates. Defaults to ("true", "est", "dp", "dpest").
            congress_cap (float, optional): Congressional cap. Defaults to 0.4.
            adj_sppe_bounds (List[float], optional): Bounds on adjusted SPPE.
                Defaults to [0.32, 0.48].
            adj_sppe_bounds_efig (List[float], optional): Bounds on adjusted
                SPPE for EFIG grants. Defaults to [0.34, 0.46].
            appropriation (float, optional): Total congressional appropriation.
                Defaults to None.
            verbose (bool, optional): Defaults to False.
        """
        self.estimates = estimates
        self.prefixes = prefixes
        self.congress_cap = congress_cap
        self.adj_sppe_bounds = adj_sppe_bounds
        self.adj_sppe_bounds_efig = adj_sppe_bounds_efig
        self.appropriation_total = appropriation
        self.verbose = verbose

    def allocations(
        self,
        normalize: bool = False
    ) -> pd.DataFrame:
        """Compute allocations.

        Args:
            normalize (bool, optional): Whether to normalize the authorization
                amounts. Defaults to False.

        Returns:
            pd.DataFrame: Estimates dataframe with computed allocations added.
        """
        self.calc_auth()
        if normalize:
            self.normalize()
        return self.estimates

    def calc_auth(self):
        """
        Appends the allocated grants as columns to the estimates DataFrame.

        Must generate at least `true_grant_total` and `est_grant_total`.

        returns:
            pd.DataFrame: current estimates
        """
        raise NotImplementedError

    def normalize(self):
        """Normalize authorization amounts to allocation amounts.
        """

    def adj_sppe(self):
        """Calculate adjusted SPPE using Sonnenberg, 2016 pg. 18 algorithm.
        """
        # Get baseline average across all 50 states and territories
        average = np.round(
            self.estimates.sppe.groupby("State FIPS Code").first().mean(),
            decimals=2
        )
        # Each state’s and each territory’s SPPE is multiplied by the
        # congressional cap and rounded to the second decimal place
        # (for dollars and cents).
        scaled = np.round(self.estimates.sppe * self.congress_cap, decimals=2)
        # No state recieves above/below the bounds set by law
        adj_sppe_trunc = scaled.clip(
            # bound by some % of the average, given in the law - round to cents
            *np.round(np.array(self.adj_sppe_bounds)*average, decimals=2)
        )
        adj_sppe_efig = scaled.clip(
            # bound %s are different for EFIG
            *np.round(np.array(self.adj_sppe_bounds_efig)*average, decimals=2)
        )
        return adj_sppe_trunc, adj_sppe_efig

Subclasses

Methods

def adj_sppe(self)

Calculate adjusted SPPE using Sonnenberg, 2016 pg. 18 algorithm.

Expand source code Browse git
def adj_sppe(self):
    """Calculate adjusted SPPE using Sonnenberg, 2016 pg. 18 algorithm.
    """
    # Get baseline average across all 50 states and territories
    average = np.round(
        self.estimates.sppe.groupby("State FIPS Code").first().mean(),
        decimals=2
    )
    # Each state’s and each territory’s SPPE is multiplied by the
    # congressional cap and rounded to the second decimal place
    # (for dollars and cents).
    scaled = np.round(self.estimates.sppe * self.congress_cap, decimals=2)
    # No state recieves above/below the bounds set by law
    adj_sppe_trunc = scaled.clip(
        # bound by some % of the average, given in the law - round to cents
        *np.round(np.array(self.adj_sppe_bounds)*average, decimals=2)
    )
    adj_sppe_efig = scaled.clip(
        # bound %s are different for EFIG
        *np.round(np.array(self.adj_sppe_bounds_efig)*average, decimals=2)
    )
    return adj_sppe_trunc, adj_sppe_efig
def allocations(self, normalize: bool = False) ‑> pandas.core.frame.DataFrame

Compute allocations.

Args

normalize : bool, optional
Whether to normalize the authorization amounts. Defaults to False.

Returns

pd.DataFrame
Estimates dataframe with computed allocations added.
Expand source code Browse git
def allocations(
    self,
    normalize: bool = False
) -> pd.DataFrame:
    """Compute allocations.

    Args:
        normalize (bool, optional): Whether to normalize the authorization
            amounts. Defaults to False.

    Returns:
        pd.DataFrame: Estimates dataframe with computed allocations added.
    """
    self.calc_auth()
    if normalize:
        self.normalize()
    return self.estimates
def calc_auth(self)

Appends the allocated grants as columns to the estimates DataFrame.

Must generate at least true_grant_total and est_grant_total.

returns: pd.DataFrame: current estimates

Expand source code Browse git
def calc_auth(self):
    """
    Appends the allocated grants as columns to the estimates DataFrame.

    Must generate at least `true_grant_total` and `est_grant_total`.

    returns:
        pd.DataFrame: current estimates
    """
    raise NotImplementedError
def normalize(self)

Normalize authorization amounts to allocation amounts.

Expand source code Browse git
def normalize(self):
    """Normalize authorization amounts to allocation amounts.
    """
class Authorizer (estimates: pandas.core.frame.DataFrame, prefixes: Tuple[str] = ('true', 'est', 'dp', 'dpest'), congress_cap: float = 0.4, adj_sppe_bounds: List[float] = [0.32, 0.48], adj_sppe_bounds_efig: List[float] = [0.34, 0.46], appropriation: float = None, verbose: bool = False)

An allocator that uses a normalization strategy to convert authorization amounts to allocation amounts.

Class for allocating Title I funds.

Args

estimates : pd.DataFrame
The poverty estimates, including randomized estimates.
prefixes : Tuple[str], optional
Prefixes for different kinds of estimates. Defaults to ("true", "est", "dp", "dpest").
congress_cap : float, optional
Congressional cap. Defaults to 0.4.
adj_sppe_bounds : List[float], optional
Bounds on adjusted SPPE. Defaults to [0.32, 0.48].
adj_sppe_bounds_efig : List[float], optional
Bounds on adjusted SPPE for EFIG grants. Defaults to [0.34, 0.46].
appropriation : float, optional
Total congressional appropriation. Defaults to None.
verbose : bool, optional
Defaults to False.
Expand source code Browse git
class Authorizer(Allocator):
    """An allocator that uses a normalization strategy to convert
    authorization amounts to allocation amounts.
    """
    def grant_types(self):
        raise NotImplementedError

    def calc_total(self):
        for prefix in self.prefixes:
            self.estimates[f"{prefix}_grant_total"] = self.estimates[[
                f"{prefix}_grant_{grant_type}"
                for grant_type in self.grant_types()
            ]].sum(axis=1)
        return self.estimates

    def allocations(
        self, normalize=True, **kwargs
    ) -> pd.DataFrame:
        super().allocations(**kwargs)
        if normalize:
            for grant_type in self.grant_types():
                for prefix in self.prefixes:
                    appropriation = \
                        self._calc_appropriation_total(grant_type)
                    self._normalize(grant_type, prefix, appropriation)
            self.calc_total()
            if self.verbose:
                total_approp = self.estimates[[
                    f"official_{grant_type}_alloc"
                    for grant_type in self.grant_types()
                ]].sum().sum()
                print(
                    "After normalization, appropriation is",
                    total_approp,
                    "and true allocation is",
                    self.estimates.true_grant_total.sum()
                )
        return self.estimates

    def _calc_appropriation_total(
        self,
        grant_type: str
    ) -> float:
        """Using official figures, calculate appropriation for this grant type.

        Args:
            grant_type (str): The grant type. One of "basic", "concentration",
                "targeted".

        Returns:
            float: The total appropration for this grant type.
        """
        appropriation = self.estimates[f"official_{grant_type}_alloc"].sum()
        if self.appropriation_total is not None:
            if self.verbose:
                print("Usual appropriation:", appropriation)
                print(
                    "Usual total:",
                    self.estimates[f"official_total_alloc"].sum()
                )
                print(
                    self.appropriation_total /
                    self.estimates[f"official_total_alloc"].sum()
                )
                print("New appropriation:", (
                    appropriation * self.appropriation_total /
                    self.estimates[f"official_total_alloc"].sum()
                ))
            # scale appropriation to total budget
            return (
                appropriation * self.appropriation_total /
                self.estimates[f"official_total_alloc"].sum()
            )
        return appropriation

    def _normalize(
        self,
        grant_type: str,
        prefix: str,
        appropriation: float,
        hold_harmless: pd.Series = None,
        state_minimum: bool = False
    ):
        """Normalize funding amounts, honoring special provisions.

        Args:
            grant_type (str):  The grant type. One of "basic", "concentration",
                "targeted".
            prefix (str): Prefix for estimate. One of
                ("true", "est", "dp", "dpest").
            appropriation (float): The total appropriation available.
            hold_harmless (pd.DataFrame, optional): If provided, which
                districts are held harmless. Defaults to None.
            state_minimum (bool, optional): Whether to apply the state minimum.
                Defaults to False.
        """
        if hold_harmless is None:
            hold_harmless = np.zeros(len(self.estimates)).astype(bool)

        current_budget = \
            self.estimates[f"{prefix}_grant_{grant_type}"].sum()

        # do a round of hold harmless normalization
        # (to get the alloc instead of auth amounts)
        self._normalize_segment(
                np.ones(len(self.estimates)).astype(bool),
                hold_harmless,
                grant_type, prefix,
                appropriation
            )

        if state_minimum:
            glob_min = \
                SonnenbergAuthorizer._state_minimum_global(grant_type)
            formula_children = self.estimates[f"{prefix}_children_eligible"]\
                .where(
                    self.estimates[f"{prefix}_eligible_{grant_type}"],
                    0
                )
            state_eligible = formula_children\
                .groupby("State FIPS Code").transform('sum')
            # nat'l avg per-pupil payment (???)
            napp = appropriation \
                / self.estimates[f"{prefix}_children_total"].sum()
            eligib_comp = state_eligible * 1.5 * napp
            if grant_type == "concentration":
                # for concentration, this amount is at minimum 340000
                eligib_comp = np.clip(eligib_comp, 340000, None)

            # the state minimum is the smaller of
            state_minimums = np.minimum(
                # 1) the global minimum - derived from the official
                # FY 2021 state allocs
                np.ones(len(self.estimates)) * glob_min,
                # and 2) the average of
                1/2 * (
                    # a) global minimum and
                    # b) the state's elibility count * 1.5 * avg SPPE
                    glob_min + eligib_comp
                )
            )
            # identify LEAs in states under the minimum
            under_minimum = self.estimates[f"{prefix}_grant_{grant_type}"]\
                .groupby("State FIPS Code").transform('sum') < state_minimums
            if self.verbose:
                print(
                    "These states meet the state minimum:",
                    np.unique(
                        under_minimum[under_minimum].index
                        .get_level_values("State FIPS Code").values
                    )
                )

            # first, normalize all the over-minimum states
            total_minimum = state_minimums[under_minimum]\
                .groupby("State FIPS Code").first().sum()
            self._normalize_segment(
                ~under_minimum,
                hold_harmless,
                grant_type, prefix,
                appropriation - total_minimum
            )

            # then normalize the under-minimum states, state-by-state
            for _, group in self.estimates[under_minimum].groupby(
                "State FIPS Code"
            ):
                minimum = state_minimums[group.index].iloc[0]
                self._normalize_segment(
                    self.estimates.index.isin(group.index),
                    hold_harmless,
                    grant_type, prefix,
                    minimum
                )

        if self.verbose:
            print(
                f"{current_budget} authorized for {grant_type} reduced "
                f"to {appropriation} allocated."
            )

    def _normalize_segment(
        self,
        segment: pd.Series,
        hold_harmless: pd.Series,
        grant_type: str,
        prefix: str,
        segment_appropriation: float
    ):
        """Normalize the budget for a segment of the districts.

        Args:
            segment (pd.Series): A boolean mask indicating the segment to
                normalize.
            hold_harmless (pd.Series): Which districts should be held harmless.
            grant_type (str): Grant type to normalize.
            prefix (str): Which treatment to normalize (generally "true",
                "est" or "dpest").
            segment_appropriation (float): The total budget for this segment.
        """
        # available budget is the full budget minus hold harmless districts
        remaining_budget = segment_appropriation - self.estimates.loc[
                segment & hold_harmless, f"{prefix}_grant_{grant_type}"
            ].sum()
        # redistribute the remaining budget between non-harmless districts
        self.estimates.loc[
            segment & ~hold_harmless, f"{prefix}_grant_{grant_type}"
        ] = \
            Authorizer.normalize_to_budget(
                self.estimates.loc[
                    segment & ~hold_harmless, f"{prefix}_grant_{grant_type}"
                ], remaining_budget
            )

    @staticmethod
    def normalize_to_budget(
        authorizations: pd.Series,
        total_budget: int
    ) -> pd.Series:
        """Scale authorizations proportional to total federal budget.

        Args:
            authorizations (pd.Series): Authorization amounts.
            total_budget (int): Estimated total budget for Title I this year.

        Returns:
            pd.Series: Normalized authorization amounts.
        """
        return authorizations / authorizations.sum() * total_budget

Ancestors

Subclasses

Static methods

def normalize_to_budget(authorizations: pandas.core.series.Series, total_budget: int) ‑> pandas.core.series.Series

Scale authorizations proportional to total federal budget.

Args

authorizations : pd.Series
Authorization amounts.
total_budget : int
Estimated total budget for Title I this year.

Returns

pd.Series
Normalized authorization amounts.
Expand source code Browse git
@staticmethod
def normalize_to_budget(
    authorizations: pd.Series,
    total_budget: int
) -> pd.Series:
    """Scale authorizations proportional to total federal budget.

    Args:
        authorizations (pd.Series): Authorization amounts.
        total_budget (int): Estimated total budget for Title I this year.

    Returns:
        pd.Series: Normalized authorization amounts.
    """
    return authorizations / authorizations.sum() * total_budget

Methods

def calc_total(self)
Expand source code Browse git
def calc_total(self):
    for prefix in self.prefixes:
        self.estimates[f"{prefix}_grant_total"] = self.estimates[[
            f"{prefix}_grant_{grant_type}"
            for grant_type in self.grant_types()
        ]].sum(axis=1)
    return self.estimates
def grant_types(self)
Expand source code Browse git
def grant_types(self):
    raise NotImplementedError

Inherited members

class SonnenbergAuthorizer (*args, **kwargs)

Authorizer allocator described by Sonnenberg (2007). Official process used by Dep Ed.

Class for allocating Title I funds.

Args

estimates : pd.DataFrame
The poverty estimates, including randomized estimates.
prefixes : Tuple[str], optional
Prefixes for different kinds of estimates. Defaults to ("true", "est", "dp", "dpest").
congress_cap : float, optional
Congressional cap. Defaults to 0.4.
adj_sppe_bounds : List[float], optional
Bounds on adjusted SPPE. Defaults to [0.32, 0.48].
adj_sppe_bounds_efig : List[float], optional
Bounds on adjusted SPPE for EFIG grants. Defaults to [0.34, 0.46].
appropriation : float, optional
Total congressional appropriation. Defaults to None.
verbose : bool, optional
Defaults to False.
Expand source code Browse git
class SonnenbergAuthorizer(Authorizer):
    """Authorizer allocator described by Sonnenberg (2007). Official process
    used by Dep Ed.
    """
    def __init__(
        self,
        *args,
        **kwargs
    ):
        self.hold_harmless = kwargs.pop('hold_harmless', False)
        self.state_minimum = kwargs.pop('state_minimum', False)
        self.thresholder = kwargs.pop('thresholder', HardThresholder())
        super().__init__(*args, **kwargs)
        if self.state_minimum and self.verbose:
            print(
                "[WARN] State minimum works using 2021 data. "
                "Will be wrong for earlier years."
            )

    def allocations(
        self, **kwargs
    ) -> pd.DataFrame:
        super().allocations(**kwargs)
        if self.hold_harmless or self.state_minimum:
            self._provisions()
        return self.estimates

    def grant_types(self):
        return (
            "basic",
            "concentration",
            "targeted"
        )

    def calc_auth(self):
        # calc adj. SPPE
        adj_sppe, _ = self.adj_sppe()
        self.thresholder.set_cv(self.estimates.cv)

        # calculate grant amounts for true/randomized values
        for prefix in self.prefixes:
            # BASIC GRANTS
            # authorization calculation
            self.estimates[f"{prefix}_grant_basic"] = \
                self.estimates[f"{prefix}_children_eligible"] * adj_sppe
            # For basic grants, LEA must have
            # >10 eligible children
            # AND >2% eligible
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_basic"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(10, prop=False),
                        Threshold(0.02, prop=True)
                    ],
                    comb_func=np.logical_and.reduce
                )
            self.estimates.loc[:, f"{prefix}_grant_basic"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_basic"] = eligible

            # CONCENTRATION GRANTS
            # For concentration grants, LEAs must meet basic eligibility
            # AND have either
            # a) >6500 eligible
            # b) 15% of pop. is eligible
            self.estimates[f"{prefix}_grant_concentration"] = \
                self.estimates[f"{prefix}_grant_basic"]
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_concentration"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(6500, prop=False),
                        Threshold(0.15, prop=True)
                    ],
                    comb_func=lambda x:
                        self.estimates[f"{prefix}_eligible_basic"]
                        & np.logical_or.reduce(x)
                )
            self.estimates.loc[:, f"{prefix}_grant_concentration"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_concentration"] = \
                eligible

            # TARGETED GRANTS
            # weighted by an exogenous step function - see documentation
            weighted_eligible = self.estimates[[
                f"{prefix}_children_eligible", f"{prefix}_children_total"
            ]].apply(
                lambda x: weighting(x[0], x[1]),
                axis=1
            )
            self.estimates[
                f"{prefix}_grant_targeted"
            ] = weighted_eligible * adj_sppe
            # for targeted grants, LEAs must:
            # meet basic eligibility AND have >5% eligible
            grants, eligible = \
                self.thresholder.process(
                    self.estimates[f"{prefix}_grant_targeted"],
                    self.estimates[f"{prefix}_children_eligible"],
                    self.estimates[f"{prefix}_children_total"],
                    [
                        Threshold(10, prop=False),
                        Threshold(0.05, prop=True)
                    ],
                    comb_func=np.logical_and.reduce
                )
            self.estimates.loc[:, f"{prefix}_grant_targeted"] = grants
            self.estimates.loc[:, f"{prefix}_eligible_targeted"] = eligible

            # EFIG
            # TODO

            # clip lower bound to zero
            for grant_type in ["basic", "concentration", "targeted"]:
                self.estimates.loc[
                    self.estimates[f"{prefix}_grant_{grant_type}"] < 0.0,
                    f"{prefix}_grant_{grant_type}"
                ] = 0.0

        self.calc_total()

    def _provisions(self):
        """
        Apply post-formula provisions (hold harmless and state minimum).
        Achieved by recursively updating allocations until all provisions are
        satisfied.
        """
        if self.verbose:
            if self.hold_harmless:
                print("Applying hold harmless")
            if self.state_minimum:
                print("Applying state minimum")

        # load last year's allocs - watch out for endogeneity
        # get this year's budget
        for grant_type in self.grant_types():
            alloc_previous = \
                self.estimates[f"official_{grant_type}_hold_harmless"]
            appropriation = self._calc_appropriation_total(grant_type)
            for prefix in self.prefixes:
                # hold harmless
                self._harmless_rate = SonnenbergAuthorizer._hold_harmless_rate(
                    self.estimates[f"{prefix}_children_eligible"] /
                    self.estimates[f"{prefix}_children_total"]
                )
                self._provisions_recursive(
                    0,
                    prefix,
                    grant_type,
                    appropriation,
                    alloc_previous
                )

        self.calc_total()
        if self.verbose:
            total_approp = self.estimates[[
                f"official_{grant_type}_alloc"
                for grant_type in self.grant_types()
            ]].sum().sum()
            print(
                "After provision(s), appropriation is",
                total_approp,
                "and true allocation is",
                self.estimates.true_grant_total.sum()
            )

    def _provisions_recursive(
        self,
        depth: int,
        prefix: str,
        grant_type: str,
        appropriation: float,
        alloc_previous: pd.Series,
        held_harmless: bool = None,
        max_depth: int = 10
    ):
        """Apply one iteration of post-formula provisions.

        Args:
            depth (int): Current iteration of recursion.
            prefix (str): Prefix of allocation to adjust.
            grant_type (str): Grant type to adjust.
            appropriation (float): Appropriation for this grant type.
            alloc_previous (pd.Series): Previous year's allocations.
            held_harmless (bool, optional): Boolena mask for districts to hold
                harmless. Defaults to None.
            max_depth (int, optional): Maximum number of iterations to run
                before stopping. Defaults to 10.
        """
        # assume no LEAs in violation of provisions
        leas_in_violation = np.zeros(len(self.estimates)).astype(bool)

        if self.hold_harmless:
            if held_harmless is None:
                held_harmless = np.zeros(len(self.estimates)).astype(bool)
            # identify LEAs suffering excessive harm
            hold_harmless = self._excessive_loss(
                prefix, grant_type, alloc_previous, self._harmless_rate
            )
            if self.verbose and depth > 0:
                print(
                    "Hold harmless iter", depth,
                    f"- {hold_harmless.sum()} hold harm districts remaining"
                )
            # limit losses to the appropriate harmless rate
            if hold_harmless.any():
                held_harmless = held_harmless | hold_harmless
                leas_in_violation = leas_in_violation | hold_harmless
                self.estimates.loc[
                    hold_harmless, f"{prefix}_grant_{grant_type}"
                ] = alloc_previous * self._harmless_rate
                self._normalize(
                    grant_type, prefix, appropriation,
                    hold_harmless=held_harmless,
                    state_minimum=self.state_minimum
                )
        else:
            self._normalize(
                grant_type, prefix, appropriation,
                state_minimum=self.state_minimum
            )

        # once no LEAs are in violation, finish
        if not leas_in_violation.any():
            return

        if depth >= max_depth:
            if self.hold_harmless:
                print(f"[WARN]: {hold_harmless.sum()} not held harmless.")
            print(
                f"[WARN] Could not converge after {max_depth} iterations."
            )
            return

        return self._provisions_recursive(
            depth+1,
            prefix,
            grant_type,
            appropriation,
            alloc_previous,
            held_harmless=held_harmless,
            max_depth=max_depth
        )

    def _excessive_loss(
        self,
        prefix: str,
        grant_type: str,
        alloc_previous: pd.Series,
        harmless_rate: pd.Series
    ) -> pd.Series:
        """Determine whether a district has suffered excessive loss.

        Returns:
            pd.Series: Boolean mask for each district indicating whether it
                has suffered excessive loss compared to the previous
                year's allocation.
        """
        return (
            self.estimates[f"{prefix}_grant_{grant_type}"] <
            harmless_rate * alloc_previous
        )

    @staticmethod
    def _hold_harmless_rate(prop_eligible: pd.Series) -> np.ndarray:
        """Determine the hold harmless rate for a district based on the
        proportion of eligible children.
        """
        return np.where(
            prop_eligible < 0.15,
            0.85,
            np.where(
                prop_eligible < 0.3,
                0.9,
                0.95
            )
        )

    @staticmethod
    def _state_minimum_global(grant_type: str) -> int:
        # drawing this manually from the FY 2021 state-level data
        # NOTE: only works for 2021 data
        if grant_type == "basic":
            return 17744098
        if grant_type == "concentration":
            return 3378479
        if grant_type == "targeted":
            return 15083659
        raise ValueError(f"Unmatched grant type: {grant_type}")

Ancestors

Methods

def grant_types(self)
Expand source code Browse git
def grant_types(self):
    return (
        "basic",
        "concentration",
        "targeted"
    )

Inherited members