CoolFace
Apppublic

BlendMMM/Simulator-UOPX

sourceHugging Faceupdated 2y agoView on Hugging Face
0likes
classes.py699 linesDownload Raw Back to root
1import numpy as np
2from scipy.optimize import minimize, LinearConstraint, NonlinearConstraint
3from collections import OrderedDict
4import pandas as pd
5from numerize.numerize import numerize
6# from gekko import GEKKO
7def class_to_dict(class_instance):
8    attr_dict = {}
9    if isinstance(class_instance, Channel):
10        attr_dict["type"] = "Channel"
11        attr_dict["name"] = class_instance.name
12        attr_dict["dates"] = class_instance.dates
13        attr_dict["spends"] = class_instance.actual_spends
14        attr_dict["conversion_rate"] = class_instance.conversion_rate
15        attr_dict["modified_spends"] = class_instance.modified_spends
16        attr_dict["modified_sales"] = class_instance.modified_sales
17        attr_dict["response_curve_type"] = class_instance.response_curve_type
18        attr_dict["response_curve_params"] = class_instance.response_curve_params
19        attr_dict["penalty"] = class_instance.penalty
20        attr_dict["bounds"] = class_instance.bounds
21        attr_dict["actual_total_spends"] = class_instance.actual_total_spends
22        attr_dict["actual_total_sales"] = class_instance.actual_total_sales
23        attr_dict["modified_total_spends"] = class_instance.modified_total_spends
24        attr_dict["modified_total_sales"] = class_instance.modified_total_sales
25        # attr_dict["actual_mroi"] = class_instance.get_marginal_roi("actual")
26        # attr_dict["modified_mroi"] = class_instance.get_marginal_roi("modified")
27
28    elif isinstance(class_instance, Scenario):
29        attr_dict["type"] = "Scenario"
30        attr_dict["name"] = class_instance.name
31        channels = []
32        for channel in class_instance.channels.values():
33            channels.append(class_to_dict(channel))
34        attr_dict["channels"] = channels
35        attr_dict["constant"] = class_instance.constant
36        attr_dict["correction"] = class_instance.correction
37        attr_dict["actual_total_spends"] = class_instance.actual_total_spends
38        attr_dict["actual_total_sales"] = class_instance.actual_total_sales
39        attr_dict["modified_total_spends"] = class_instance.modified_total_spends
40        attr_dict["modified_total_sales"] = class_instance.modified_total_sales
41
42    return attr_dict
43
44
45def class_from_dict(attr_dict):
46    if attr_dict["type"] == "Channel":
47        return Channel.from_dict(attr_dict)
48    elif attr_dict["type"] == "Scenario":
49        return Scenario.from_dict(attr_dict)
50
51
52class Channel:
53    def __init__(
54        self,
55        name,
56        dates,
57        spends,
58        sales,
59        response_curve_type,
60        response_curve_params,
61        bounds,channel_bounds_min,channel_bounds_max,
62        conversion_rate=1,
63        modified_spends=None,
64        modified_sales=None,
65        penalty=True,
66    ):
67        self.name = name
68        self.dates = dates
69        self.conversion_rate = conversion_rate
70        self.actual_spends = spends.copy()
71        self.actual_sales = sales.copy()
72
73        if modified_spends is None:
74            self.modified_spends = self.actual_spends.copy()
75        else:
76            self.modified_spends = modified_spends
77
78        if modified_sales is None:
79            # self.modified_sales = self.calculate_sales()
80            self.modified_sales = self.actual_sales.copy()
81        else:
82            self.modified_sales = self.calculate_sales()
83            # self.modified_spends = modified_spends
84
85        self.response_curve_type = response_curve_type
86        self.response_curve_params = response_curve_params
87        self.bounds = bounds
88        self.channel_bounds_min = channel_bounds_min
89        self.channel_bounds_max = channel_bounds_max
90        self.penalty = penalty
91
92        self.upper_limit = self.actual_spends.max() + self.actual_spends.std()
93        self.power = np.ceil(np.log(self.actual_spends.max()) / np.log(10)) - 3
94        # self.actual_sales = None
95        # self.actual_sales = self.response_curve(self.actual_spends)#sales.copy()#
96        self.actual_total_spends = self.actual_spends.sum()
97        self.actual_total_sales = self.actual_sales.sum()
98        
99        self.modified_total_spends = self.modified_spends.sum()
100        self.modified_total_sales = self.modified_sales.sum()
101        self.delta_spends = self.modified_total_spends - self.actual_total_spends
102        self.delta_sales = self.modified_total_sales - self.actual_total_sales
103        # # # # print(self.actual_total_spends)
104    def update_penalty(self, penalty):
105        self.penalty = penalty
106
107    def _modify_spends(self, spends_array, total_spends):
108        return spends_array * total_spends / spends_array.sum()
109
110    def modify_spends(self, total_spends):
111        # # # # print(total_spends)
112        self.modified_spends[0] = total_spends 
113        # (
114        #     self.modified_spends * total_spends / self.modified_spends.sum()
115        # )
116        # # # # print("in spends")
117        # # # # print(self.modified_spends,self.modified_spends.sum())
118
119    def calculate_sales(self):
120        # # # # print("in calc_sales")
121        # # # # print(self.modified_spends)
122        return self.response_curve(self.modified_spends)
123
124    def hill_equation(x, Kd, n):
125        return x**n / (Kd**n + x**n)
126    def response_curve(self, x):
127        # # # # print(x)
128        # if self.penalty:
129        #     # # # print("in penalty")
130        #     x = np.where(
131        #         x < self.upper_limit,
132        #         x,
133        #         self.upper_limit + (x - self.upper_limit) * self.upper_limit / x,
134        #     )
135        if self.response_curve_type == "hill-eq":
136            # dividing_parameter = check_dividing_parameter()
137            # # # # print("lalala")
138            # # # # # print(self.name)\
139            # # # # print(len(x))
140            # # # # print("in response curve function")
141            # # # # print(x)
142            if len(x) == 1:
143                dividing_rate = self.response_curve_params["num_pos_obsv"]
144                # # # # print(dividing_rate)
145                # x = np.sum(x)
146            else:
147                dividing_rate = 1
148            # dividing_rate = self.response_curve_params["num_pos_obsv"]
149                # x = np.sum(x)
150            # dividing_rate = 104
151            dividing_rate = self.response_curve_params["num_pos_obsv"]
152            Kd= self.response_curve_params["Kd"]
153            n= self.response_curve_params["n"]
154            x_min= self.response_curve_params["x_min"]
155            x_max= self.response_curve_params["x_max"]
156            y_min= self.response_curve_params["y_min"]
157            y_max= self.response_curve_params['y_max']  
158            # # # # # print(x_min)
159            # # # # # print(Kd,n,x_min,x_max,y_min,y_max)
160            # # # # # print(np.sum(x)/104)
161            x_inp = ( x/dividing_rate- x_min) / (x_max - x_min)
162            # # # # # print("x",x)
163            # # # # # print("x_inp",x_inp)
164            x_out = x_inp**n / (Kd**n + x_inp**n) #self.hill_equation(x_inp,Kd, n)
165            # # # # # print("x_out",x_out)
166
167
168            x_val_inv = (x_out*x_max + (1 - x_out) * x_min)
169            sales = (x_val_inv*y_min/y_max)*dividing_rate
170            # sales = ((x_max - x_min)*x_out + x_min)*dividing_rate
171
172            sales[np.isnan(sales)] = 0
173            # # # # # print(sales) 
174            # # # # # print(np.sum(sales))
175            # # # # # print("sales",sales)
176            # # # # print("aa")
177            # # # # print(sales)
178            # # # # print("aa1")
179        if self.response_curve_type == "s-curve":
180            if self.power >= 0:
181                x = x / 10**self.power
182            x = x.astype("float64")
183            K = self.response_curve_params["Kd"]
184            b = self.response_curve_params["b"]
185            a = self.response_curve_params["a"]
186            x0 = self.response_curve_params["x0"]
187            sales = K / (1 + b * np.exp(-a * (x - x0)))
188        if self.response_curve_type == "linear":
189            beta = self.response_curve_params["beta"]
190            sales = beta * x
191
192        return sales
193
194    def get_marginal_roi(self, flag):
195        K = self.response_curve_params["K"]
196        a = self.response_curve_params["a"]
197        # x = self.modified_total_spends
198        # if self.power >= 0 :
199        #     x = x / 10**self.power
200        # x = x.astype('float64')
201        # return K*b*a*np.exp(-a*(x-x0)) / (1 + b * np.exp(-a*(x - x0)))**2
202        if flag == "actual":
203            y = self.response_curve(self.actual_spends)
204            # spends_array = self.actual_spends
205            # total_spends = self.actual_total_spends
206            # total_sales = self.actual_total_sales
207
208        else:
209            y = self.response_curve(self.modified_spends)
210            # spends_array = self.modified_spends
211            # total_spends = self.modified_total_spends
212            # total_sales = self.modified_total_sales
213
214        # spends_inc_1 = self._modify_spends(spends_array, total_spends+1)
215        mroi = a * (y) * (1 - y / K)
216        return mroi.sum() / len(self.modified_spends)
217        # spends_inc_1 = self.spends_array + 1
218        # new_total_sales = self.response_curve(spends_inc_1).sum()
219        # return (new_total_sales - total_sales) / len(self.modified_spends)
220
221    def update(self, total_spends):
222        self.modify_spends(total_spends)
223        self.modified_sales = self.calculate_sales()
224        self.modified_total_spends = self.modified_spends.sum()
225        self.modified_total_sales = self.modified_sales.sum()
226        self.delta_spends = self.modified_total_spends - self.actual_total_spends
227        self.delta_sales = self.modified_total_sales - self.actual_total_sales
228    
229    def update_bounds_min(self, modified_bound):
230        self.channel_bounds_min = modified_bound
231    
232    def update_bounds_max(self, modified_bound):
233        self.channel_bounds_max = modified_bound
234
235    def intialize(self):
236        self.new_spends = self.old_spends
237
238    def __str__(self):
239        return f"{self.name},{self.actual_total_sales}, {self.modified_total_spends}"
240
241    @classmethod
242    def from_dict(cls, attr_dict):
243        return Channel(
244            name=attr_dict["name"],
245            dates=attr_dict["dates"],
246            spends=attr_dict["spends"],
247            bounds=attr_dict["bounds"],
248            modified_spends=attr_dict["modified_spends"],
249            response_curve_type=attr_dict["response_curve_type"],
250            response_curve_params=attr_dict["response_curve_params"],
251            penalty=attr_dict["penalty"],
252        )
253
254    def update_response_curves(self, response_curve_params):
255        self.response_curve_params = response_curve_params
256
257
258class Scenario:
259    def __init__(self, name, channels, constant, correction):
260        self.name = name
261        self.channels = channels
262        self.constant = constant
263        self.correction = correction
264
265        self.actual_total_spends = self.calculate_modified_total_spends()
266        self.actual_total_sales = self.calculate_actual_total_sales()
267        self.modified_total_sales = self.calculate_modified_total_sales()
268        self.modified_total_spends = self.calculate_modified_total_spends()
269        self.delta_spends = self.modified_total_spends - self.actual_total_spends
270        self.delta_sales = self.modified_total_sales - self.actual_total_sales
271
272    def update_penalty(self, value):
273        for channel in self.channels.values():
274            channel.update_penalty(value)
275
276    def calculate_modified_total_spends(self):
277        total_actual_spends = 0.0
278        for channel in self.channels.values():
279            total_actual_spends += channel.actual_total_spends * 1.0
280        return total_actual_spends
281
282    def calculate_modified_total_spends(self):
283        total_modified_spends = 0.0
284        for channel in self.channels.values():
285            # import streamlit as st
286            # st.write(channel.modified_total_spends )
287            total_modified_spends += (
288                channel.modified_total_spends * 1.0
289               
290            )
291        return total_modified_spends
292
293    def calculate_actual_total_sales(self):
294        total_actual_sales = 0#self.constant.sum()  + self.correction.sum()
295        # # # # print("a")
296        for channel in self.channels.values():
297            total_actual_sales += channel.actual_total_sales
298            # # # # # print(channel.actual_total_sales)
299        # # # # # print(total_actual_sales)
300        return total_actual_sales
301
302    def calculate_modified_total_sales(self):
303
304        total_modified_sales = 0 #self.constant.sum() + self.correction.sum()
305        # # # # print(total_modified_sales)
306        for channel in self.channels.values():
307            # # # # print(channel,channel.modified_total_sales)
308            total_modified_sales += channel.modified_total_sales
309        return total_modified_sales
310
311    def update(self, channel_name, modified_spends):
312        # # # # print("in updtw")
313        self.channels[channel_name].update(modified_spends)
314        self.modified_total_sales = self.calculate_modified_total_sales()
315        self.modified_total_spends = self.calculate_modified_total_spends()
316        self.delta_spends = self.modified_total_spends - self.actual_total_spends
317        self.delta_sales = self.modified_total_sales - self.actual_total_sales
318
319    def update_bounds_min(self, channel_name,modified_bound):
320        # self.modify_spends(total_spends)
321        self.channels[channel_name].update_bounds_min(modified_bound)  
322
323    def update_bounds_max(self, channel_name,modified_bound):
324        # self.modify_spends(total_spends)
325        self.channels[channel_name].update_bounds_max(modified_bound)
326
327    # def optimize_spends(self, sales_percent, channels_list, algo="COBYLA"):
328    #     desired_sales = self.actual_total_sales * (1 + sales_percent / 100.0)
329
330    #     def constraint(x):
331    #         for ch, spends in zip(channels_list, x):
332    #             self.update(ch, spends)
333    #         return self.modified_total_sales - desired_sales
334
335    #     bounds = []
336    #     for ch in channels_list:
337    #         bounds.append(
338    #             (1 + np.array([-50.0, 100.0]) / 100.0)
339    #             * self.channels[ch].actual_total_spends
340    #         )
341
342    #     initial_point = []
343    #     for bound in bounds:
344    #         initial_point.append(bound[0])
345
346    #     power = np.ceil(np.log(sum(initial_point)) / np.log(10))
347
348    #     constraints = [NonlinearConstraint(constraint, -1.0, 1.0)]
349
350    #     res = minimize(
351    #         lambda x: sum(x) / 10 ** (power),
352    #         bounds=bounds,
353    #         x0=initial_point,
354    #         constraints=constraints,
355    #         method=algo,
356    #         options={"maxiter": int(2e7), "catol": 1},
357    #     )
358
359    #     for channel_name, modified_spends in zip(channels_list, res.x):
360    #         self.update(channel_name, modified_spends)
361
362    #     return zip(channels_list, res.x)
363
364
365    
366    
367    
368
369    def optimize_spends(self, sales_percent, channels_list, algo="trust-constr"):
370        num_channels = len(channels_list)
371        # # # # # print("%"*100)
372        desired_sales = self.actual_total_sales * (1 + sales_percent / 100.0)
373
374        def constraint(x):
375            for ch, spends in zip(channels_list, x):
376                self.update(ch, spends)
377            return self.modified_total_sales - desired_sales
378        
379        # def calc_overall_bounds(channels_list):
380        #     total_spends=0
381        #     for ch in zip(channels_list):
382        #         # print(ch)
383        #         total_spends= total_spends+self.channels[ch].actual_total_spends
384        #     return total_spends
385
386
387        bounds = []
388        for ch in channels_list:
389            # bounds.append(
390            #     (1+np.array([-50.0, 100.0]) / 100.0)
391            #     * self.channels[ch].actual_total_spends
392            # )
393            lb = (1- int(self.channels[ch].channel_bounds_min) / 100) * self.channels[ch].actual_total_spends
394            ub = (1+  int(self.channels[ch].channel_bounds_max) / 100)  * self.channels[ch].actual_total_spends
395            bounds.append((lb,ub))
396            # # # # # print(self.channels[ch].actual_total_spends)
397        initial_point = []
398        for bound in bounds:
399            initial_point.append(bound[0])
400            # initial_point = np.nan_to_num(initial_point, nan=0.0, posinf=0.0, neginf=0.0)
401
402        power = np.ceil(np.log(sum(initial_point)) / np.log(10))
403
404        constraints = [NonlinearConstraint(constraint, -1.0, 1.0),
405                    #    LinearConstraint(np.ones((num_channels,)), lb = -50*calc_overall_bounds(channels_list), ub = 50*calc_overall_bounds(channels_list))
406                       ]
407
408        res = minimize(
409            lambda x: sum(x) / 10 ** (power),
410            bounds=bounds,
411            x0=initial_point,
412            constraints=constraints,
413            method=algo,
414            options={"maxiter": int(2e7), "xtol": 10},
415        )
416
417        for channel_name, modified_spends in zip(channels_list, res.x):
418            self.update(channel_name, modified_spends)
419            
420        return zip(channels_list, res.x)
421    
422    
423
424    def optimize(self, spends_percent, channels_list):
425        # channels_list = self.channels.keys()
426        num_channels = len(channels_list)
427        spends_constant = []
428        spends_constraint = 0.0
429        for channel_name in channels_list:
430            # spends_constraint += self.channels[channel_name].modified_total_spends
431            spends_constant.append(self.channels[channel_name].conversion_rate)
432            # # # # print(spends_constant)
433            spends_constraint += (
434                self.channels[channel_name].actual_total_spends+ self.channels[channel_name].delta_spends #st.session_state["total_spends_change_abs_slider_options"]
435            )
436            # # # # print("delta spends",self.channels[channel_name].delta_spends)
437        # spends_constraint = spends_constraint * (1 + spends_percent / 100)
438        constraint= LinearConstraint(np.ones((num_channels,)), lb = spends_constraint, ub = spends_constraint)
439        # constraint = LinearConstraint(
440        #     np.array(spends_constant),
441        #     lb=spends_constraint,
442        #     ub=spends_constraint,
443        # )
444        bounds = []
445        old_spends = []
446        for channel_name in channels_list:
447            _channel_class = self.channels[channel_name]
448            channel_bounds = _channel_class.bounds
449            channel_actual_total_spends = _channel_class.actual_total_spends + _channel_class.delta_spends 
450            # * (
451            #     (1 + _channel_class.delta_spends / 100)
452            # )
453            old_spends.append(channel_actual_total_spends)
454            # bounds.append((1+ channel_bounds / 100) * channel_actual_total_spends)
455            lb = (1- int(_channel_class.channel_bounds_min) / 100) * _channel_class.actual_total_spends
456            ub = (1+  int(_channel_class.channel_bounds_max) / 100)  * _channel_class.actual_total_spends
457            bounds.append((lb,ub))
458            # # # # print("aaaaaa")
459            # # # print((_channel_class.channel_bounds_max,_channel_class.channel_bounds_min))
460            # _channel_class.channel_bounds_min
461            # _channel_class.channel_bounds_max
462        def cost_func1(channel,x):
463            response_curve_params = pd.read_excel("response_curves_parameters.xlsx",index_col = "channel")
464            param_dicts = {col: response_curve_params[col].to_dict() for col in response_curve_params.columns}
465
466            Kd= param_dicts["Kd"][channel]
467            n= param_dicts["n"][channel]
468            x_min= param_dicts["x_min"][channel]
469            x_max= param_dicts["x_max"][channel]
470            y_min= param_dicts["y_min"][channel]
471            y_max= param_dicts['y_max'][channel] 
472            division_parameter = param_dicts['num_pos_obsv'][channel] 
473            x_inp = ( x/division_parameter- x_min) / (x_max - x_min)
474        #     # # # print(x_inp)
475            x_out = x_inp**n / (Kd**n + x_inp**n)
476            x_val_inv = (x_out*x_max + (1 - x_out) * x_min)
477            sales = (x_val_inv*y_min/y_max)*division_parameter
478            if np.isnan(sales):
479        #         # # # print(sales,channel)
480                sales = 0
481            # # # # print(sales,channel)
482            return sales
483        def objective_function(x):
484            sales = 0
485            it = 0
486            for channel_name, modified_spends in zip(channels_list, x):
487                # sales = sales + cost_func1(channel_name,modified_spends)
488                # # print(channel_name, modified_spends,cost_func1(channel_name, modified_spends))
489                it+=1
490                self.update(channel_name, modified_spends)
491            # # # # print(self.modified_total_sales)
492            # # # # print(channel_name, modified_spends)
493            return -1 * self.modified_total_sales
494
495        # # # # print(bounds)
496        # # # # # print("$"*100)
497        res = minimize(
498            lambda x: objective_function(x)/1e3,
499            method="trust-constr",
500            x0=old_spends,
501            constraints=constraint,
502            bounds=bounds,
503            options={"maxiter": int(1e7), "xtol": 0.1},
504        )
505
506        for channel_name, modified_spends in zip(channels_list, res.x):
507            # # # # print("aaaaaaaaaaaaaa")
508            self.update(channel_name, modified_spends)
509            # # # # print(channel_name, modified_spends,cost_func1(channel_name, modified_spends))
510            
511            # # print(it)
512
513        return zip(channels_list, res.x)
514    
515
516    def hill_equation(self,x, Kd, n):
517        return x**n / (Kd**n + x**n)
518    
519    
520
521    # def spends_optimisation(self, spends_percent,channels_list):
522    #     m = GEKKO(remote=False)
523    #     # Define variables        
524    #     # Initialize 13 variables with specific bounds
525    #     response_curve_params = pd.read_excel(r"C:\Users\PragyaJatav\Downloads\Untitled Folder 2\simulator uploaded - Copy\Simulator-UOPX\response_curves_parameters.xlsx",index_col = "channel")
526    #     param_dicts = {col: response_curve_params[col].to_dict() for col in response_curve_params.columns}
527        
528    #     initial_values = list(param_dicts["x_min"].values())
529    #     current_spends = list(param_dicts["current_spends"].values()) 
530    #     lower_bounds = list(param_dicts["x_min"].values()) 
531        
532    #     num_channels = len(channels_list)
533
534    #     x_vars=[]
535    #     x_vars = [m.Var(value=param_dicts["current_spends"][_], lb=param_dicts["x_min"][_]*104, ub=5*param_dicts["current_spends"][_]) for _ in channels_list]
536    #     # # # # print(x_vars)
537    # #     x_vars,lower_bounds
538        
539    #     # Define the objective function to minimize
540    #     cost = 0
541    #     spends = 0 
542    #     i = 0
543    #     for i,c in enumerate(channels_list):
544    #         # # # # # print(c)
545    #         # # # # # print(x_vars[i])
546    #         cost = cost + (self.cost_func(c, x_vars[i]))
547    #         spends = spends +x_vars[i]
548
549           
550    #     m.Maximize(cost)
551        
552    #     # Define constraints
553    #     m.Equation(spends == sum(current_spends)*(1 + spends_percent / 100))
554    #     m.Equation(spends <= sum(current_spends)*0.5)
555    #     m.Equation(spends >= sum(current_spends)*1.5)
556
557    #     m.solve(disp=True)
558        
559    #     for i, var in enumerate(x_vars):
560    #         # # # # print(f"x{i+1} = {var.value[0]}")
561        
562    #     for channel_name, modified_spends in zip(channels_list, x_vars):
563    #         self.update(channel_name, modified_spends.value[0])
564
565    #     return zip(channels_list, x_vars)
566
567    def save(self):
568        details = {}
569        actual_list = []
570        modified_list = []
571        data = {}
572        channel_data = []
573
574        summary_rows = []
575        actual_list.append(
576            {
577                "name": "Total",
578                "Spends": self.actual_total_spends,
579                "Sales": self.actual_total_sales,
580            }
581        )
582        modified_list.append(
583            {
584                "name": "Total",
585                "Spends": self.modified_total_spends,
586                "Sales": self.modified_total_sales,
587            }
588        )
589        for channel in self.channels.values():
590            name_mod = channel.name.replace("_", " ")
591            if name_mod.lower().endswith(" imp"):
592                name_mod = name_mod.replace("Imp", " Impressions")
593            summary_rows.append(
594                [
595                    name_mod,
596                    channel.actual_total_spends,
597                    channel.modified_total_spends,
598                    channel.actual_total_sales,
599                    channel.modified_total_sales,
600                    round(channel.actual_total_sales / channel.actual_total_spends, 2),
601                    round(
602                        channel.modified_total_sales / channel.modified_total_spends,
603                        2,
604                    ),
605                    channel.get_marginal_roi("actual"),
606                    channel.get_marginal_roi("modified"),
607                ]
608            )
609            data[channel.name] = channel.modified_spends
610            data["Date"] = channel.dates
611            data["Sales"] = (
612                data.get("Sales", np.zeros((len(channel.dates),)))
613                + channel.modified_sales
614            )
615            actual_list.append(
616                {
617                    "name": channel.name,
618                    "Spends": channel.actual_total_spends,
619                    "Sales": channel.actual_total_sales,
620                    "ROI": round(
621                        channel.actual_total_sales / channel.actual_total_spends, 2
622                    ),
623                }
624            )
625            modified_list.append(
626                {
627                    "name": channel.name,
628                    "Spends": channel.modified_total_spends,
629                    "Sales": channel.modified_total_sales,
630                    "ROI": round(
631                        channel.modified_total_sales / channel.modified_total_spends,
632                        2,
633                    ),
634                    "Marginal ROI": channel.get_marginal_roi("modified"),
635                }
636            )
637
638            channel_data.append(
639                {
640                    "channel": channel.name,
641                    "spends_act": channel.actual_total_spends,
642                    "spends_mod": channel.modified_total_spends,
643                    "sales_act": channel.actual_total_sales,
644                    "sales_mod": channel.modified_total_sales,
645                }
646            )
647        summary_rows.append(
648            [
649                "Total",
650                self.actual_total_spends,
651                self.modified_total_spends,
652                self.actual_total_sales,
653                self.modified_total_sales,
654                round(self.actual_total_sales / self.actual_total_spends, 2),
655                round(self.modified_total_sales / self.modified_total_spends, 2),
656                0.0,
657                0.0,
658            ]
659        )
660        details["Actual"] = actual_list
661        details["Modified"] = modified_list
662        columns_index = pd.MultiIndex.from_product(
663            [[""], ["Channel"]], names=["first", "second"]
664        )
665        columns_index = columns_index.append(
666            pd.MultiIndex.from_product(
667                [["Spends", "NRPU", "ROI", "MROI"], ["Actual", "Simulated"]],
668                names=["first", "second"],
669            )
670        )
671        details["Summary"] = pd.DataFrame(summary_rows, columns=columns_index)
672        data_df = pd.DataFrame(data)
673        channel_list = list(self.channels.keys())
674        data_df = data_df[["Date", *channel_list, "Sales"]]
675
676        details["download"] = {
677            "data_df": data_df,
678            "channels_df": pd.DataFrame(channel_data),
679            "total_spends_act": self.actual_total_spends,
680            "total_sales_act": self.actual_total_sales,
681            "total_spends_mod": self.modified_total_spends,
682            "total_sales_mod": self.modified_total_sales,
683        }
684
685        return details
686
687    @classmethod
688    def from_dict(cls, attr_dict):
689        channels_list = attr_dict["channels"]
690        channels = {
691            channel["name"]: class_from_dict(channel) for channel in channels_list
692        }
693        return Scenario(
694            name=attr_dict["name"],
695            channels=channels,
696            constant=attr_dict["constant"],
697            correction=attr_dict["correction"],
698        )
699