import pandas as pd
import numpy as np
from pyomo import environ
from pyomo.environ import *


def optimization_MIP(model, model_master, global_trust_region = None):

    def logistic_x(proba):
        if proba == 0:
            proba = 0.00001
        if proba == 1:
            proba = 0.99999
        return - np.log(1 / proba - 1)

    def constraints_linear(model, outcome, task, coefficients, var_features, contex_features, lb=None, ub=None, weight_objective=0, SCM=None, features=None):
        '''
        Embed a trained linear predictive model for 'outcome' into the master 'model'.
        'Coefficients' is a model file generated by the constraint_extrapolation_skEN() function.
        'lb/ub' specify the lower/upper bounds if 'outcome' is to be incorporated as a constraint.
        'weight_objective' specifies the weight to use if incorporating 'outcome' as a term in the objective.
        '''
        # Row-level information: row = single constraint (multiple rows can correspond to single leaf)
        intercept = coefficients['intercept'][0]
        coeff = coefficients.drop(['intercept'], axis=1, inplace=False).loc[0, :]
        model.add_component('LR'+outcome, Constraint(expr=model.y[outcome] == sum(model.x[i] * coeff.loc[i] for i in var_features) + sum(contex_features[i] * coeff.loc[i] for i in contex_features.keys()) + intercept))

    def constraints_tree(model, outcome, tree_table, var_features, contex_features, lb=None, ub=None, M=1e5, weight_objective=0, SCM=None, features=None):
        '''
        Embed a trained decision tree predictive model for 'outcome' into the master 'model'.
        'tree_table' is a model file generated by the constraint_extrapolation_skTree() function, where each row encodes a single constraint (multiple rows can correspond to single leaf)
        'lb/ub' specify the lower/upper bounds if 'outcome' is to be incorporated as a constraint.
        'weight_objective' specifies the weight to use if incorporating 'outcome' as a term in the objective.
        'M' is an upper bound on the value at any node.
        '''
        leaf_values = tree_table.loc[:, ['ID', 'prediction']].drop_duplicates().set_index('ID')
        # Row-level information:
        intercept = tree_table['threshold']
        coeff = tree_table.drop(['ID', 'threshold', 'prediction'], axis=1, inplace=False).reset_index(drop=True)
        l_ids = tree_table['ID']
        n_constr = coeff.shape[0]
        L = np.unique(tree_table['ID'])
        def constraintsTree_1(model, j):
            return sum(model.x[i]*coeff.loc[j, i] for i in var_features) + sum(contex_features[i]*coeff.loc[j, i] for i in contex_features.keys()) <= intercept.iloc[j] + M*(1-model.l[(outcome,str(l_ids.iloc[j]))])

        def constraintsTree_2(model):
            return sum(model.l[(outcome, str(i))] for i in L) == 1

        def constraintTree(model):
            return model.y[outcome] == sum(leaf_values.loc[i, 'prediction'] * model.l[(outcome, str(i))] for i in L)

        model.add_component(outcome+'_splits', Constraint(range(n_constr), rule=constraintsTree_1))
        model.add_component('DT'+outcome, Constraint(rule=constraintTree))
        model.add_component(outcome+'_oneleaf', Constraint(rule=constraintsTree_2))

    def constraints_rf(model, outcome, forest_table, var_features, contex_features, ub=None, lb=None, max_violation=None, weight_objective=0, SCM=None, features=None):
        '''
        Embed a trained random forest predictive model for 'outcome' into the master 'model'.
        'forest_table' is a model file generated by the constraint_extrapolation_skRF() function, where each row encodes a single constraint (multiple rows can correspond to single leaf)
        'lb/ub' specify the lower/upper bounds if 'outcome' is to be incorporated as a constraint.
        'weight_objective' specifies the weight to use if incorporating 'outcome' as a term in the objective.
        'max_violation' specifies the allowable violation proportion for a constraint (e.g. 0.2 -> 20% of trees can violate the chosen lb/ub)
        '''
        forest_table['Tree_id'] = [outcome + '_' + str(i) for i in forest_table['Tree_id']]
        T = np.unique(forest_table['Tree_id'])

        ## For each tree in the forest, add tree to model and define outcome y
        for i, t in enumerate(T):
            tree_table = forest_table.loc[forest_table['Tree_id'] == t, :].drop('Tree_id', axis=1)
            # don't set LB, UB, or objective for individual trees
            constraints_tree(model, t, tree_table, var_features, contex_features, lb=None, ub=None, weight_objective=0, SCM=None, features=features)

        ## Compute average (as y[outcome]), either for avg. constraint or objective
        model.add_component('RF'+outcome, Constraint(rule=model.y[outcome] == 1 / len(T) * quicksum(model.y[j] for j in T)))

    def constraints_gbm(model, outcome, task, gbm_table, var_features, contex_features, ub=None, lb=None, weight_objective=0, SCM=None, features=None):
        '''
        Embed a trained gradient-boosting machine model for 'outcome' into the master 'model'.
        'gbm_table' is a model file generated by the constraint_extrapolation_skGBM() function, where each row encodes a single constraint (multiple rows can correspond to single leaf)
        'lb/ub' specify the lower/upper bounds if 'outcome' is to be incorporated as a constraint.
        'weight_objective' specifies the weight to use if incorporating 'outcome' as a term in the objective.
        '''
        gbm_table['Tree_id'] = [outcome + '_' + str(i) for i in gbm_table['Tree_id']]
        T = np.unique(gbm_table['Tree_id'])

        ## For each tree in the forest, add tree to model and define outcome y
        for i, t in enumerate(T):
            tree_table = gbm_table.loc[gbm_table['Tree_id'] == t, :].drop(
                ['Tree_id', 'initial_prediction', 'learning_rate'], axis=1, inplace=False)
            # don't set LB, UB, or objective for individual trees
            constraints_tree(model, t, tree_table, var_features, contex_features, lb=None, ub=None, weight_objective=0, SCM=None, features=features)

        # ## Compute average (as y[outcome]), either for avg. constraint or objective
        def constraint_gbm(model):
            return model.y[outcome] == np.unique(gbm_table['initial_prediction']).item() + np.unique(gbm_table['learning_rate']).item() * quicksum(model.y[j] for j in T)

        model.add_component('GBM'+outcome, Constraint(rule=constraint_gbm))

    def constraints_mlp(model, data, outcome, task, weights, var_features, contex_features, lb=None, ub=None, weight_objective=0, SCM=None, features=None, M_l=-1e5, M_u=1e5):
        '''
        Embed a trained multi-layer perceptron model for 'outcome' into the master 'model'.
        'weights' is a model file generated by the constraint_extrapolation_skMLP() function, where each row encodes the coefficients for a single node/layer pair
        'lb/ub' specify the lower/upper bounds if 'outcome' is to be incorporated as a constraint.
        'weight_objective' specifies the weight to use if incorporating 'outcome' as a term in the objective.
        'M_l' and 'M_u' are lower/upper bounds on the value at any node/layer pair.
        '''
        # Recursively generate constraints linking nodes between layers, starting from input
        nodes_input = range(len(features))
        # v_input = [x[f'col{i}'] for i in nodes_input]
        v_input = []  #[model.x[i] for i in features]
        for i in features:
            try:
                v_input.append(contex_features[i])
            except Exception:
                v_input.append(model.x[i])

        max_layer = max(weights['layer'])
        for l in range(max_layer + 1):
            df_layer = weights.query('layer == %d' % l)
            max_nodes = [k for k in df_layer.columns if 'node_' in k]
            # coeffs_layer = np.array(df_layer.iloc[:, range(len(max_nodes))].dropna(axis=1))
            coeffs_layer = np.array(df_layer.loc[:, max_nodes].dropna(axis=1))
            intercepts_layer = np.array(df_layer['intercept'])
            nodes = df_layer['node']

            if l == max_layer:
                node = nodes.iloc[0]  # only one node in last layer
                model.add_component('MLP'+outcome, Constraint(rule=model.y[outcome] == sum(v_input[i] * coeffs_layer[node, i] for i in nodes_input) + intercepts_layer[
                        node]))
            else:
                # Save v_pos for input to next layer
                v_pos_list = []
                for node in nodes:
                    ## Initialize variables

                    v_pos_list.append(model.v[(outcome, l, node)])

                    model.add_component('constraint_1_' + str(l) + '_'+str(node)+outcome,
                                        Constraint(rule=model.v[(outcome, l, node)] >= sum(v_input[i] * coeffs_layer[node, i] for i in nodes_input) + intercepts_layer[node]))
                    model.add_component('constraint_2_' + str(l)+'_' + str(node) + outcome,
                                        Constraint(rule=model.v[(outcome, l, node)] <= M_u * (model.v_ind[(outcome, l, node)])))
                    model.add_component('constraint_3_' + str(l)+'_' + str(node) + outcome,
                                        Constraint(rule=model.v[(outcome, l, node)] <= sum(v_input[i] * coeffs_layer[node, i] for i in nodes_input) + intercepts_layer[node] - M_l * (1 - model.v_ind[(outcome, l, node)])))
                ## Prepare nodes_input for next layer
                nodes_input = nodes
                v_input = v_pos_list

    def constraints_tr(model, outcome, data, var_features, contex_features, clustering_model, enlarge):
        '''
        Add constraints for the trust region using the convex hull of 'data'.
        If a clustering_model is specified, the solution is constrained to lie within the convex hull of a single cluster.
        '''
        samples = data.index
        print(f'Generating constraints for the trust region using {len(samples)} samples.')

        if not pd.isna(clustering_model):
            print('Using the clustering algorithm')
            assert enlarge[0] != 1, 'Enlargement not supported with clustering'
            n_clusters = np.unique(clustering_model.labels_)
            model.u = Var(n_clusters, name=['cluster_%d' % x for x in n_clusters], domain=Binary)

            def constraint_CTR1(model, i):
                if i in var_features:
                    return model.x[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)
                else:
                    contex_features[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)

            def constraint_CTR2(model, label):
                cluster = data[clustering_model.labels_ == label]
                cluster_samples = cluster.index
                return sum(model.lam[outcome, k] for k in cluster_samples) == model.u[label]

            model.add_component(otcome+'ConstraintClusteredTrustRegion1', Constraint(data.columns, rule=constraint_CTR1))
            model.add_component(otcome+'ConstraintClusteredTrustRegion2', Constraint(n_clusters, rule=constraint_CTR2))
            model.add_component(otcome+'ConstraintClusteredTrustRegion3', Constraint(rule=sum(model.u[label] for label in n_clusters) == 1))
        else:
            if enlarge[0] == 1:
                print('The l1 norm is used for the enlarged CH trust region')
                def constraint_ETR1(model, i):
                    if i in var_features:
                        return model.x[i] + model.e[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)
                    else: contex_features[i] + model.e[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)
                def constraint_ETR21(model, i):
                    return model.eHelp[i] >= model.e[i]
                def constraint_ETR22(model, i):
                    return model.eHelp[i] >= - model.e[i]
                model.add_component('constraint_ETR0', Constraint(rule=sum(model.lam[outcome, k] for k in samples) == 1))
                model.add_component('constraint_ETR1', Constraint(data.columns, rule=constraint_ETR1))
                model.add_component('constraint_ETR21', Constraint(data.columns, rule=constraint_ETR21))
                model.add_component('constraint_ETR22', Constraint(data.columns, rule=constraint_ETR22))

                if enlarge[1] == 0:  # Enlarge using a bounding constraint
                    ub = enlarge[2]
                    print(f'The trust region is being enlarged with a constraint upper bounded by: {ub}.')
                    def constraint_ETR3(model):
                        return sum(model.eHelp[i] for i in data.columns) <= ub
                    model.add_component(outcome+'constraint_ETR3',Constraint(rule=constraint_ETR3))
                else:  # Enlarge using a penalty in the objective function
                    beta = enlarge[2]
                    print(f'The trust region is being enlarged with penatly coeff: {beta}.')
                    model.OBJ.set_value(expr=model.OBJ.expr + model.OBJ.sense * beta * sum(model.eHelp[i] for i in data.columns))
            else:
                def constraint_CTR1(model, i):
                    if i in var_features:
                        return model.x[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)
                    else:
                        return contex_features[i] == sum(model.lam[outcome, k] * data.loc[k, i] for k in samples)
                model.add_component(outcome+'ConstraintClusteredTrustRegion1', Constraint(rule=sum(model.lam[outcome, k] for k in samples) == 1))
                model.add_component(outcome+'ConstraintClusteredTrustRegion2', Constraint(data.columns, rule=constraint_CTR1))

        print('... Trust region defined.')

    ## Identify decision variable indices
    # N = data.columns
    ## Add trust region constraints (with optional pre-trained cluster model)
    ## Initialize variables
    model.y = Var(Any, dense=False, domain=Reals)
    model.l = Var(Any, dense=False, domain=Binary)
    model.y_viol = Var(Any, dense=False, domain=Binary)
    model.v = Var(Any, dense=False, domain=NonNegativeReals)
    model.v_ind = Var(Any, dense=False, domain=Binary)
    model.lam = Var(Any, dense=False, domain=Reals, bounds=(0,1))
    model.eHelp = Var(Any, dense=False, domain=Reals)
    model.e = Var(Any, dense=False, domain=Reals)

    if global_trust_region != None:
        constraints_tr(model, 
            outcome = 'global', 
            data = global_trust_region['data'],
            var_features = global_trust_region['data'].columns, 
            contex_features = None, 
            clustering_model = global_trust_region['clustering_model'], 
            enlarge = global_trust_region['enlargement'])

    ## Iterate over all learned models
    outcomes = model_master.index
    for o, row in model_master.iterrows():
        task=row['task']
        weight_objective=row['objective']
        SCM=row['SCM_counterfactuals']
        lb=row['lb']
        ub=row['ub']
        features = row['features']
        var_features = row['var_features']
        contex_features = row['contex_features']
        data = pd.DataFrame(pd.read_csv(row['dataset_path'])).loc[:, row['features']]
        SCM = row['SCM_counterfactuals']
        grouped = row['group_models']
        group_method = row['group_method']
        max_violation_group = row['max_violation']

        if row['trust_region']:
            clustering_model = row['clustering_model']
            constraints_tr(model, o, data, var_features, contex_features, clustering_model, row['enlargement'])

        model_list = model_master.loc[o,'model']
        outcomes_sub = []

        ## Iterate over all learned models for each outcome
        for i, mfile_name in enumerate(model_list.keys()):
            # For each outcome, call the related embedding function.
            # Pass in the master model (to attach the constraints to) and the outcome-specific items from model-master
            # RF has one additional argument, the max_violation proportion
            mtype = model_list[mfile_name]
            mfile = pd.read_csv(mfile_name)

            #### 'outcome' tracks the specific outcome (or sub-) that we are embedding. 'o' tracks the master outcome that we are linking across
            if grouped:
                ## Change outcome to have subscript with index of submodel
                outcome = '%s_%d' % (o, i)
                outcomes_sub.append(outcome)
            else:
                outcome = o


            if row['objective']!=0:
                print(f"Embedding objective function for {outcome}")
            else:
                print(f"Embedding constraints for {outcome}")
            
            ## calculate y prediction for row + convert lb/ub for logistic case where needed
            if mtype in ['cart', 'iai', 'iai-single']:
                constraints_tree(model, outcome, mfile, var_features, contex_features,
                    lb=lb, ub=ub,
                    weight_objective=weight_objective, SCM=SCM, features=features)
            elif mtype == 'rf':
                constraints_rf(model, outcome, mfile, var_features, contex_features,
                    max_violation=None, ## additional argument required for RF
                    lb=lb, ub=ub,
                    weight_objective=weight_objective, SCM=SCM, features=features)
            elif mtype == 'gbm':
                constraints_gbm(model, outcome, task, mfile, var_features, contex_features,
                    lb=lb, ub=ub, 
                    weight_objective=weight_objective, SCM=SCM, features=features)
            elif mtype == 'mlp':
                constraints_mlp(model, data, outcome, task, mfile, var_features, contex_features,
                    lb=lb, ub=ub,
                    weight_objective=weight_objective, SCM=SCM, features=features)
                if row['task'] == 'binary':
                    lb = logistic_x(proba=lb) if lb != None else None
                    ub = logistic_x(proba=ub) if ub != None else None
            elif mtype in ['linear', 'svm']:
                constraints_linear(model, outcome, task, mfile, var_features, contex_features,
                    lb=lb, ub=ub,
                    weight_objective=weight_objective, SCM=SCM, features=features)
                if row['task'] == 'binary':
                    lb = logistic_x(proba=lb) if lb != None else None
                    ub = logistic_x(proba=ub) if ub != None else None

        ## Incorporate y into objective or constraint
        if not grouped:
            print("Adding single model.")
            if weight_objective != 0:
                model.OBJ.set_value(expr=model.OBJ.expr + weight_objective * model.y[outcome])
            elif not pd.isna(SCM):
                if outcome in var_features:
                    model.add_component('scm_' + outcome, Constraint(expr=model.y[outcome] == SCM + model.x[outcome]))
                else:
                    if outcome in var_features:
                        model.add_component('scm_' + outcome, Constraint(expr=model.y[outcome] == SCM + contex_features[outcome]))
            else:
                if not pd.isna(ub):
                    model.add_component('ub_' + outcome, Constraint(expr=model.y[outcome] <= ub))
                if not pd.isna(lb):
                    model.add_component('lb_' + outcome, Constraint(expr=model.y[outcome] >= lb))
        else: 
            model.add_component('GroupAvg'+o, Constraint(rule=model.y[o] == 1 / len(outcomes_sub) * quicksum(model.y[j] for j in outcomes_sub)))
                     ## aggregate over all individual preds, enumerated in outcomes_sub
            ## Compute average (as y[outcome]), either for avg. constraint or objective
            print(o)
            if group_method == 'average':
                print("Adding ensemble constraint with %d models and limit on average." % (len(outcomes_sub)))
                # Constrain average values
                if not pd.isna(ub):
                    model.add_component('ub_' + o, Constraint(expr=model.y[o] <= ub))
                if not pd.isna(lb):
                    model.add_component('lb_' + o, Constraint(expr=model.y[o] >= lb))
            else:
                print("Adding ensemble constraint with %d models and violation limit = %.2f" 
                    % (len(outcomes_sub), max_violation_group))
                # Constrain proportion of trees (1 - max_violation_group)
                if not pd.isna(ub):
                    def constraint_upperBoundViol(model, j):
                        return 1 / 1000 * (model.y[j] - ub) <= model.y_viol[(o, str(j))]
                    model.add_component('upperBoundViol'+o, Constraint(outcomes_sub, rule=constraint_upperBoundViol))
                if not pd.isna(lb):
                    def constraint_lowerBoundViol(model, j):
                        return 1 / 1000 * (lb - model.y[j]) <= model.y_viol[(o, str(j))]
                    model.add_component('lowerBoundViol' + o, Constraint(outcomes_sub, rule=constraint_lowerBoundViol))
                model.add_component('constraintViol'+o, Constraint(rule=1 / len(outcomes_sub) * sum(model.y_viol[(o, str(j))] for j in outcomes_sub) <= max_violation_group))

    return model


def initialize_model_master(outcome_list):
    model_master = pd.DataFrame(columns = ['model', 'task', 
        'objective', 'lb', 'ub', 'features', 'var_features','contex_features',
       'group_models','group_method','ensemble_weights','max_violation',
       'trust_region', 'dataset_path', 'clustering_model',  'enlargement',
        'SCM_counterfactuals',],
        index = list(outcome_list.keys()))
    for outcome in model_master.index:
        outcome_specs = outcome_list[outcome]
        ## Universal parameters - must be specified for any outcome
        model_master.loc[outcome,'task'] = outcome_specs['task_type']
        model_master.loc[outcome,'lb'] = outcome_specs['lb']
        model_master.loc[outcome,'ub'] = outcome_specs['ub']
        model_master.loc[outcome,'objective'] = outcome_specs['objective_weight']
        model_master.loc[outcome,'features'] = outcome_specs['X_train'].columns
        model_master.loc[outcome,'group_models'] = outcome_specs['group_models']
        ## Set to defaults - user must change
        model_master.loc[outcome,'var_features'] = outcome_specs['X_train'].columns
        model_master.at[outcome,'contex_features'] = {}
        model_master.loc[outcome,'group_method'] = 'average' if outcome_specs['group_models'] else None
        model_master.loc[outcome,'ensemble_weights'] = None
        model_master.loc[outcome,'max_violation'] = 0.25
        model_master.loc[outcome,'trust_region'] = True
        model_master.loc[outcome,'dataset_path'] = outcome_specs['dataset_path']
        model_master.loc[outcome,'clustering_model'] = None
        # enlargement option: 0-No enlargement, 1-CH enlargement; 
        # enlargement constraint:  0-constraint, 1-objective penalty; 
        # constraint ub/penalty multiplier
        model_master.at[outcome,'enlargement'] = [0]
        model_master.loc[outcome,'SCM_counterfactuals'] = None
    return model_master

def model_selection(model_master, performance):
    '''
    Select models using aggregated 'performance' table. The models are selected based on the highest valid_score, assuming higher scores are better (true in sklearn).
    Objectives are passed through a dictionary with an assigned weight (value) per outcome name (key).
    Constraints are passed as a list. The upper and lower bounds must be set after generating the master model file.
    '''
    performance_embed = performance.loc[performance.loc[:, 'outcome'].isin(model_master.index), :]
    performance_embed.loc[:, 'score'] = performance_embed.loc[:, 'valid_score']
    performance_embed.sort_values(['outcome', 'score'], ascending=[True, False], axis=0, inplace=True)
    for outcome in model_master.index:
        if model_master.loc[outcome,'group_models']:
            performance_embed.sort_values(['alg','outcome_label'], ascending=[True, True], axis=0, inplace=True)
            performance_top = performance_embed.loc[performance_embed['outcome']==outcome,:]
        else: ## if not bootstrap, take first row
            performance_embed.sort_values(['outcome', 'score'], ascending=[True, False], axis=0, inplace=True)
            performance_top = performance_embed.loc[performance_embed['outcome']==outcome,:].head(1)
        model_master.at[outcome, 'model'] = dict(zip(performance_top['save_path'],performance_top['alg']))
    return model_master


def check_model_master(model_master, print_model = True):
    '''
    Check that the model_master file is valid: all outcomes must be unique, the model must be from a valid list, and a model cannot appear as both a constraint and objective.
    Optionally, print out the learned objectives and constraints in a readable format.
    '''
    print("Checking model")

    ## Objective/constraint conistency
    obj_cnt = sum(model_master['objective'] != 0)
    if obj_cnt == 0:
        print("No learned objective")
    else: 
        print("%d learned objective terms" % obj_cnt)
    constraint_cnt = sum((model_master['lb'] != None) | (model_master['ub'] != None))
    if constraint_cnt == 0:
        print("No learned constraints")
    else: 
        print("%d learned constrained outcomes" % constraint_cnt)

    ## Cannot have repeat outcome names
    assert len(model_master.index.unique()) == len(model_master.index), "Cannot have duplicate outcome names"

    for i, row in model_master.iterrows():
        print("\nChecking outcome %s" % i)
        ## ASSERT: must select from valid model list
        models = set(row['model'].values())
        assert np.all([x in ['iai','iai-single','rf','cart','linear','svm','mlp', 'gbm'] for x in models]),\
            "Invalid model type selected."
        ## ASSERT: valid lb/ub/objective arguments - otherwise will get unexpected behavior
        assert isinstance(row['objective'], (int, float)) & (row['objective'] != np.nan), "'objective' parameter must be numeric. Set to 0 if constraint"
        assert isinstance(row['lb'], (int, float)) | (row['lb'] == None), "'lb' parameter must be numeric if used, or None if objective."
        assert isinstance(row['ub'], (int, float)) | (row['ub'] == None), "'ub' parameter must be numeric if used, or None if objective."

        ## Check group behavior
        if row['group_models']:
            if len(row['model'])<=1:
                print("Warning: cannot group models for {i}; only one model specified.")
            print("Embedding outcome with ensemble of %d models, aggregation = %s" % 
                (len(row['model']), row['group_method']))
            ### ASSERT: Must use average if ensemble for objective
            assert (row['objective'] == 0) | (row['group_method'] == 'average'),\
                "If grouping models for objective, must use group_method = 'average'"
            assert row['group_method'] in ['average','violation'],\
                "Unrecognized group_method. Must be in ['average','violation']"
            if row['ensemble_weights'] != None:
                print("Warning: code does not yet support weighted average - raw average will be used")
        else:
            assert len(row['model'])==1, "Cannot pass multiple models witih group_models = False"
            print("Embedding outcome with single %s model" % list(models)[0])

        ## Check trust region
        if row['trust_region']:
            assert row['dataset_path'] != None, "Must specify dataset to use trust region."
        if row['enlargement'][0] != 0:
            assert len(row['enlargement']) > 1, "Illegal enlargement argument - see documentation"

        if row['objective'] != 0:
            ## ASSERT: Model cannot be constrained and optimized
            assert (pd.isna(row['lb']) & pd.isna(row['ub'])), \
                "Outcome cannot appear as both objective term and constraint."
            ## Print output for objective
            if print_model:
                print(f"\nEmbedding objective term for {i}, with outcome weight = {row['objective']}")
        else:
            constraint_lb = f"{round(row['lb'], 3)} <= " if not pd.isna(row['lb']) else ""
            constraint_ub = f" <= {round(row['ub'], 3)}" if not pd.isna(row['ub']) else ""
            if (constraint_lb + constraint_ub) != "":
                print(f"\nEmbedding constraint for {i}.")
                print(constraint_lb + i + constraint_ub)
                if row['task'] == 'binary':
                    print(f"The outcome '{i}' is a probability")
            else: 
                print(f"\Warning: {i} does not appear as constraint or objective term.")

