Skip to content

熵平衡

牛顿法求解矩约束的熵平衡权重。

核心代码

py
def run_ebalance(
    df: pd.DataFrame,
    covariates: List[str],
    *,
    mode: str,
    treat_var: Optional[str],
    target_orders: List[int],
    manual_targets: Optional[List[float]],
    base_weight_var: Optional[str],
    weight_treat: bool,
    normconst: float,
    maxiter: int,
    tolerance: float,
    weight_var_name: str,
) -> EbalanceResult:
    """执行与 Stata ebalance 行为保持一致的熵平衡。"""

    normalized_mode = str(mode or 'treat').strip().lower()
    if normalized_mode not in {'treat', 'manual'}:
        raise ValueError(_('熵平衡模式无效'))

    covariates = [str(item).strip() for item in covariates if str(item).strip()]
    if not covariates:
        raise ValueError(_('熵平衡至少需要选择一个配平协变量'))

    if normalized_mode == 'manual':
        if weight_treat:
            raise ValueError(_('单组手动目标模式不支持“处理组权重也使用 base weight”'))
        if not manual_targets or len(manual_targets) != len(covariates):
            raise ValueError(_('单组手动目标模式下,目标均值个数必须与协变量个数一致'))
        target_orders = [1] * len(covariates)
    else:
        if not treat_var:
            raise ValueError(_('请先选择处理组变量'))
        if not target_orders or len(target_orders) != len(covariates):
            raise ValueError(_('每个协变量都必须指定目标阶数'))

    for order in target_orders:
        if int(order) not in {1, 2, 3}:
            raise ValueError(_('targets 仅支持 1、2、3 阶矩'))

    full_work = df.copy()
    needed_columns = list(covariates)
    if normalized_mode == 'treat':
        needed_columns.append(str(treat_var).strip())
    if base_weight_var:
        needed_columns.append(str(base_weight_var).strip())

    work = full_work[needed_columns].copy()
    for covariate in covariates:
        work[covariate] = _to_numeric_series(work, covariate)

    if normalized_mode == 'treat':
        treat_name = str(treat_var).strip()
        work[treat_name] = _to_numeric_series(work, treat_name)
        base_mask = work[covariates + [treat_name]].notna().all(axis=1)
    else:
        treat_name = '__ebalance_treat__'
        base_mask = work[covariates].notna().all(axis=1)

    notes: List[str] = []

    if base_weight_var:
        weight_name = str(base_weight_var).strip()
        work[weight_name] = _to_numeric_series(work, weight_name)
        if normalized_mode == 'manual':
            missing_weight_mask = base_mask & work[weight_name].isna()
            if bool(missing_weight_mask.any()):
                notes.append(_('已删除 %(count)s 个 base weight 缺失的样本。') % {'count': int(missing_weight_mask.sum())})
            touse_mask = base_mask & work[weight_name].notna()
            base_weights = work.loc[touse_mask, weight_name].astype(float).to_numpy()
            treated_base_weights = np.array([1.0], dtype=float)
        else:
            treat_full = work[treat_name].copy()
            invalid_treat_mask = base_mask & ~treat_full.isin([0, 1])
            if bool(invalid_treat_mask.any()):
                raise ValueError(_('处理组变量 %(treat_name)s 必须是 0/1 二元变量') % {'treat_name': treat_name})

            if weight_treat:
                missing_weight_mask = base_mask & work[weight_name].isna()
                if bool(missing_weight_mask.any()):
                    notes.append(_('已删除 %(count)s 个 base weight 缺失的样本。') % {'count': int(missing_weight_mask.sum())})
                touse_mask = base_mask & work[weight_name].notna()
                base_weights = work.loc[touse_mask, weight_name].astype(float).to_numpy()
                treated_base_weights = base_weights[work.loc[touse_mask, treat_name].astype(int).to_numpy() == 1]
            else:
                missing_control_weight_mask = base_mask & work[weight_name].isna() & (treat_full == 0)
                if bool(missing_control_weight_mask.any()):
                    notes.append(_('已删除 %(count)s 个控制组 base weight 缺失样本。') % {'count': int(missing_control_weight_mask.sum())})
                ignored_treated_weight_mask = base_mask & work[weight_name].notna() & (treat_full == 1)
                if bool(ignored_treated_weight_mask.any()):
                    notes.append(_('处理组 base weight 已忽略;若希望处理组也带权,请勾选“处理组也使用 base weight”。'))
                touse_mask = base_mask & ~(missing_control_weight_mask)
                filtered_treat = work.loc[touse_mask, treat_name].astype(int).to_numpy()
                base_weights = np.where(
                    filtered_treat == 0,
                    work.loc[touse_mask, weight_name].astype(float).to_numpy(),
                    1.0,
                )
                treated_base_weights = np.ones(int((filtered_treat == 1).sum()), dtype=float)
    else:
        if weight_treat:
            raise ValueError(_('勾选“处理组也使用 base weight”时,必须指定 base weight 变量'))
        touse_mask = base_mask.copy()
        base_weights = np.ones(int(touse_mask.sum()), dtype=float)
        treated_base_weights = np.ones(1, dtype=float)

    sample_df = full_work.loc[touse_mask, :].copy().reset_index(drop=True)
    if sample_df.empty:
        raise ValueError(_('熵平衡可用样本为空,请检查变量缺失情况'))

    if normalized_mode == 'manual':
        sample_df[treat_name] = 0
        treat_array = sample_df[treat_name].astype(int).to_numpy()
        if len(sample_df) <= 1:
            raise ValueError(_('单组手动目标模式下,有效样本量至少需要 2 个'))
    else:
        sample_df[treat_name] = sample_df[treat_name].astype(int)
        treat_array = sample_df[treat_name].to_numpy()
        if np.any(~np.isin(treat_array, [0, 1])):
            raise ValueError(_('处理组变量 %(treat_name)s 必须是 0/1 二元变量') % {'treat_name': treat_name})
        if treat_array.max() == 0 or treat_array.min() == 1:
            raise ValueError(_('处理组变量 %(treat_name)s 必须同时包含处理组和控制组样本') % {'treat_name': treat_name})

    if normalized_mode == 'treat' and int((treat_array == 0).sum()) <= 1:
        raise ValueError(_('控制组有效样本量不足,无法执行熵平衡'))

    sample_base_weights = np.asarray(base_weights, dtype=float)

    first_order_blocks: List[tuple[str, np.ndarray]] = []
    second_order_blocks: List[tuple[str, np.ndarray]] = []
    third_order_blocks: List[tuple[str, np.ndarray]] = []
    skipped_second_order: List[str] = []
    skipped_third_order: List[str] = []

    covariate_arrays: Dict[str, np.ndarray] = {
        covariate: sample_df[covariate].astype(float).to_numpy()
        for covariate in covariates
    }

    if normalized_mode == 'manual':
        control_mask = np.ones(len(sample_df), dtype=bool)
        control_count = int(control_mask.sum())
        treated_count = 1

        for covariate in covariates:
            values = covariate_arrays[covariate]
            target_value = float(manual_targets[covariates.index(covariate)])
            if target_value < np.min(values) or target_value > np.max(values):
                raise ValueError(_('手动目标值超出了 %(covariate)s 的样本范围') % {'covariate': covariate})
            first_order_blocks.append((covariate, values.copy()))

        treated_matrix = np.array([manual_targets], dtype=float)
        control_matrix = np.column_stack([values for _, values in first_order_blocks])
        treated_constraint_weights = np.array([1.0], dtype=float)
        control_base_weights = sample_base_weights.copy()
    else:
        control_mask = treat_array == 0
        treated_mask = treat_array == 1
        control_count = int(control_mask.sum())
        treated_count = int(treated_mask.sum())

        for covariate, target_order in zip(covariates, target_orders):
            values = covariate_arrays[covariate]
            treated_values = values[treated_mask]
            control_values = values[control_mask]

            if treated_values.max() < control_values.min() or control_values.max() < treated_values.min():
                raise ValueError(_('%(covariate)s 在处理组与控制组之间没有重叠区间') % {'covariate': covariate})

            first_order_blocks.append((covariate, values.copy()))

            if int(target_order) >= 2:
                if np.std(treated_values, ddof=1) == 0 or np.std(control_values, ddof=1) == 0:
                    skipped_second_order.append(covariate)
                    if int(target_order) >= 3:
                        skipped_third_order.append(covariate)
                    continue

                treated_mean = _weighted_mean(treated_values, sample_base_weights[treated_mask])
                treated_n = int(treated_mask.sum())
                control_n = int(control_mask.sum())

                second_order = np.empty_like(values, dtype=float)
                second_order[control_mask] = (control_values - treated_mean) ** 2 * control_n / (control_n - 1)
                second_order[treated_mask] = (treated_values - treated_mean) ** 2 * treated_n / (treated_n - 1)
                second_order_blocks.append((covariate, second_order))

                if int(target_order) >= 3:
                    third_order = np.empty_like(values, dtype=float)
                    third_order[control_mask] = (control_values - treated_mean) ** 3 * (control_n / (control_n - 1)) ** 1.5
                    third_order[treated_mask] = (treated_values - treated_mean) ** 3 * (treated_n / (treated_n - 1)) ** 1.5
                    third_order_blocks.append((covariate, third_order))

        if skipped_second_order:
            notes.append(_('以下变量因样本内方差不足,自动跳过了高阶矩约束:%(columns)s') % {'columns': ', '.join(skipped_second_order)})

        all_constraints = first_order_blocks + second_order_blocks + third_order_blocks
        treated_matrix = np.column_stack([values[treated_mask] for _, values in all_constraints])
        control_matrix = np.column_stack([values[control_mask] for _, values in all_constraints])
        treated_constraint_weights = sample_base_weights[treated_mask].copy()
        control_base_weights = sample_base_weights[control_mask].copy()

    if normalized_mode == 'manual':
        all_constraints = first_order_blocks
    else:
        all_constraints = first_order_blocks + second_order_blocks + third_order_blocks

    constraint_labels = [name for name, _ in all_constraints]
    constraint_orders = ([1] * len(first_order_blocks)) + ([2] * len(second_order_blocks)) + ([3] * len(third_order_blocks))

    treated_moments = np.sum(treated_constraint_weights[:, None] * treated_matrix, axis=0) / np.sum(treated_constraint_weights)
    moments_with_constant = np.concatenate(([1.0], treated_moments))
    moment_scale = _safe_power_of_ten(moments_with_constant)
    scaled_target_moments = moments_with_constant / moment_scale
    scaled_control_matrix = np.column_stack([np.ones(control_matrix.shape[0]), control_matrix]) / moment_scale

    coefficients = np.zeros(scaled_control_matrix.shape[1], dtype=float)
    raw_control_weights = np.exp(scaled_control_matrix @ coefficients) * control_base_weights
    gradient = raw_control_weights @ scaled_control_matrix - scaled_target_moments
    maxdiff = float(np.max(np.abs(gradient)))
    maxdist = int(np.argmax(np.abs(gradient)))
    iteration_logs: List[str] = []
    zero_step = False
    current_iter = 1

    while maxdiff > float(tolerance) and current_iter <= int(maxiter):
        raw_control_weights = np.exp(scaled_control_matrix @ coefficients) * control_base_weights
        weighted_sums = raw_control_weights @ scaled_control_matrix
        gradient = weighted_sums - scaled_target_moments
        hessian = scaled_control_matrix.T @ (raw_control_weights[:, None] * scaled_control_matrix)

        previous_coefficients = coefficients.copy()
        newton_step = _solve_linear_system(hessian, gradient)
        coefficients = coefficients - newton_step

        loss_new = float(np.max(np.abs((np.exp(scaled_control_matrix @ coefficients) * control_base_weights) @ scaled_control_matrix - scaled_target_moments)))
        loss_old = float(np.max(np.abs((np.exp(scaled_control_matrix @ previous_coefficients) * control_base_weights) @ scaled_control_matrix - scaled_target_moments)))

        if loss_old <= loss_new:
            best_loss = float('inf')
            best_coefficients = coefficients.copy()
            for divisor in range(1, 6):
                candidate = previous_coefficients - (newton_step / divisor)
                candidate_loss = float(np.max(np.abs((np.exp(scaled_control_matrix @ candidate) * control_base_weights) @ scaled_control_matrix - scaled_target_moments)))
                if candidate_loss < best_loss:
                    best_loss = candidate_loss
                    best_coefficients = candidate
            coefficients = best_coefficients
            zero_step = False

        maxdiff = float(np.max(np.abs(gradient)) / scaled_target_moments[0])
        maxdist = int(np.argmax(np.abs(gradient)))
        iteration_logs.append(f'Iteration {current_iter}: Max Difference = {maxdiff}')

        if zero_step:
            break
        current_iter += 1

    converged = bool(maxdiff <= float(tolerance))
    if not converged:
        notes.append(_format_constraint_note(
            maxdist=maxdist,
            xnum1=len(first_order_blocks),
            xnum2=len(second_order_blocks),
            constrt1=[name for name, _ in first_order_blocks],
            constrt2=[name for name, _ in second_order_blocks],
        ))

    # 与 Stata 保持一致:输出权重使用“最后一次迭代更新前”的控制组权重。
    final_control_weights = raw_control_weights.copy()

    if normalized_mode == 'manual':
        treated_weight_total = float(len(sample_df))
        control_weight_total = float(np.sum(final_control_weights))
        normalized_control_weights = final_control_weights * float(normconst) * len(sample_df) / control_weight_total
        final_weights_array = normalized_control_weights
        treated_total_weight = float(np.sum(normalized_control_weights))
        control_total_weight = treated_total_weight
    else:
        treated_weights_array = sample_base_weights[treated_mask].copy() if weight_treat else np.ones(treated_count, dtype=float)
        treated_weight_total = float(np.sum(treated_weights_array))
        control_weight_total = float(np.sum(final_control_weights))
        normalized_control_weights = final_control_weights * float(normconst) * treated_weight_total / control_weight_total
        final_weights_array = np.empty(len(sample_df), dtype=float)
        final_weights_array[treated_mask] = treated_weights_array
        final_weights_array[control_mask] = normalized_control_weights
        treated_total_weight = float(np.sum(treated_weights_array))
        control_total_weight = float(np.sum(normalized_control_weights))

    final_weights = pd.Series(final_weights_array, name=weight_var_name)

    if normalized_mode == 'manual':
        pre_rows = []
        post_rows = []
        for covariate in covariates:
            values = sample_df[covariate].astype(float).to_numpy()
            pre_stats = _summary_triplet(values, sample_base_weights)
            post_stats = _summary_triplet(values, final_weights.to_numpy())
            pre_rows.append({
                'Xname': covariate,
                'mean_Pre': pre_stats['mean'],
                'var_Pre': pre_stats['variance'],
                'skew_Pre': pre_stats['skewness'],
            })
            post_rows.append({
                'Xname': covariate,
                'mean_Post': post_stats['mean'],
                'var_Post': post_stats['variance'],
                'skew_Post': post_stats['skewness'],
            })

        pre_balance = pd.DataFrame(pre_rows)
        post_balance = pd.DataFrame(post_rows)
        balance_table = pre_balance.merge(post_balance, on='Xname', how='left')
        weight_summary = pd.DataFrame([
            {'项目': _('调整样本量'), '取值': int(len(sample_df))},
            {'项目': _('权重总和'), '取值': float(control_total_weight)},
            {'项目': _('是否收敛'), '取值': _('是') if converged else _('否')},
            {'项目': _('最大偏差'), '取值': float(maxdiff)},
        ])
    else:
        pre_rows = []
        post_rows = []
        for covariate in covariates:
            treated_values = sample_df.loc[treat_array == 1, covariate].astype(float).to_numpy()
            control_values = sample_df.loc[treat_array == 0, covariate].astype(float).to_numpy()
            treated_stats = _summary_triplet(treated_values, sample_base_weights[treat_array == 1] if weight_treat else np.ones(treated_count, dtype=float))
            control_pre_stats = _summary_triplet(control_values, sample_base_weights[treat_array == 0])
            control_post_stats = _summary_triplet(control_values, final_weights.loc[treat_array == 0].to_numpy())

            pre_rows.append({
                'Xname': covariate,
                'mean_Tr': treated_stats['mean'],
                'var_Tr': treated_stats['variance'],
                'skew_Tr': treated_stats['skewness'],
                'mean_Co_Pre': control_pre_stats['mean'],
                'var_Co_Pre': control_pre_stats['variance'],
                'skew_Co_Pre': control_pre_stats['skewness'],
            })
            post_rows.append({
                'Xname': covariate,
                'mean_Tr': treated_stats['mean'],
                'var_Tr': treated_stats['variance'],
                'skew_Tr': treated_stats['skewness'],
                'mean_Co_Post': control_post_stats['mean'],
                'var_Co_Post': control_post_stats['variance'],
                'skew_Co_Post': control_post_stats['skewness'],
            })

        pre_balance = pd.DataFrame(pre_rows)
        post_balance = pd.DataFrame(post_rows)
        balance_table = pre_balance.merge(post_balance[['Xname', 'mean_Co_Post', 'var_Co_Post', 'skew_Co_Post']], on='Xname', how='left')
        balance_table['sdiff_Pre'] = (balance_table['mean_Tr'] - balance_table['mean_Co_Pre']) / np.sqrt(balance_table['var_Tr'])
        balance_table['sdiff_Post'] = (balance_table['mean_Tr'] - balance_table['mean_Co_Post']) / np.sqrt(balance_table['var_Tr'])
        weight_summary = pd.DataFrame([
            {'项目': _('处理组样本量'), '取值': int(treated_count)},
            {'项目': _('处理组权重总和'), '取值': float(treated_total_weight)},
            {'项目': _('控制组样本量'), '取值': int(control_count)},
            {'项目': _('控制组权重总和'), '取值': float(control_total_weight)},
            {'项目': _('是否收敛'), '取值': _('是') if converged else _('否')},
            {'项目': _('最大偏差'), '取值': float(maxdiff)},
        ])

    lambdas_df = pd.DataFrame({
        'Xname': constraint_labels,
        'order': constraint_orders,
        'lambda': coefficients[1:],
    })
    moments_df = pd.DataFrame({
        'Xname': constraint_labels,
        'order': constraint_orders,
        'mconstrnt': moments_with_constant[1:],
    })

    return EbalanceResult(
        sample_df=sample_df,
        final_weights=final_weights,
        weight_var_name=weight_var_name,
        mode=normalized_mode,
        converged=converged,
        maxdiff=float(maxdiff),
        maxdist=maxdist,
        iteration_logs=iteration_logs,
        notes=notes,
        covariates=covariates,
        constraint_labels=constraint_labels,
        constraint_orders=constraint_orders,
        lambdas=lambdas_df,
        moments=moments_df,
        pre_balance=pre_balance,
        post_balance=post_balance,
        balance_table=balance_table,
        weight_summary=weight_summary,
        treated_count=treated_count,
        control_count=control_count,
        treated_total_weight=float(treated_total_weight),
        control_total_weight=float(control_total_weight),
    )

Released under the AGPL-3.0 License.