Py.Cafe

faria.lm/

hofstede-cultural-visualization

Hofstede Cultural Dimensions Interactive Visualization

DocsPricing
  • app.py
  • hofstede_data_final.csv
  • requirements.txt
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
"""
Hofstede Cultural Dimensions - Agent-Based Model with Cultural Distance Calculator
Using Mesa 3.0 and Solara for interactive world map visualization
"""
import pandas as pd
import mesa
import solara
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
import numpy as np
from matplotlib.colors import LinearSegmentedColormap

# Try to import geopandas
try:
    import geopandas as gpd
    GEOPANDAS_AVAILABLE = True
except ImportError:
    GEOPANDAS_AVAILABLE = False


class CountryAgent(mesa.Agent):
    """An agent representing a country with Hofstede cultural dimensions."""
    def __init__(self, model, country_name, country_code, pdi, idv, mas, uai, ltowvs, ivr):
        super().__init__(model)
        self.country_name = country_name
        self.country_code = country_code  # Standard ISO 3166-1 alpha-3
        self.pdi = pdi
        self.idv = idv
        self.mas = mas
        self.uai = uai
        self.ltowvs = ltowvs
        self.ivr = ivr

    def step(self):
        pass

    def get_dimension_value(self, dimension):
        return {
            'pdi': self.pdi,
            'idv': self.idv,
            'mas': self.mas,
            'uai': self.uai,
            'ltowvs': self.ltowvs,
            'ivr': self.ivr
        }.get(dimension)


class HofstedeModel(mesa.Model):
    """Model containing country agents with cultural dimensions."""
    def __init__(self, data_path):
        super().__init__()  # Initializes self.agents as AgentSet automatically
        # Load data as strings first to handle blanks/spaces
        df = pd.read_csv(data_path, dtype=str)
        
        # Convert numeric columns safely: blanks become NaN
        numeric_cols = ['pdi', 'idv', 'mas', 'uai', 'ltowvs', 'ivr']
        for col in numeric_cols:
            df[col] = pd.to_numeric(df[col], errors='coerce')
        
        # Create agents β€” Mesa auto-adds them to self.agents
        for _, row in df.iterrows():
            if pd.isna(row['ctr']) or pd.isna(row['country']):
                continue
            # Use ISO code directly from 'ctr' column
            CountryAgent(
                self,
                country_name=row['country'],
                country_code=row['ctr'],
                pdi=row['pdi'],
                idv=row['idv'],
                mas=row['mas'],
                uai=row['uai'],
                ltowvs=row['ltowvs'],
                ivr=row['ivr']
            )

    def step(self):
        self.agents.do("step")

    def get_dimension_data(self, dimension):
        data = {}
        for agent in self.agents:
            value = agent.get_dimension_value(dimension)
            if value is not None and not np.isnan(value):
                data[agent.country_code] = value
        return data

    def calculate_cultural_distances(self, ref_country_code, weights):
        """Calculate weighted Euclidean cultural distances."""
        ref_agent = next((a for a in self.agents if a.country_code == ref_country_code), None)
        if ref_agent is None:
            return {}

        dimensions = ['pdi', 'idv', 'mas', 'uai', 'ltowvs', 'ivr']
        distances = {}

        for agent in self.agents:
            if agent.country_code == ref_country_code:
                continue

            valid = True
            squared_diff = 0.0
            for dim in dimensions:
                ref_val = ref_agent.get_dimension_value(dim)
                comp_val = agent.get_dimension_value(dim)
                if ref_val is None or comp_val is None or np.isnan(ref_val) or np.isnan(comp_val):
                    valid = False
                    break
                diff = ref_val - comp_val
                squared_diff += weights[dim] * (diff ** 2)

            if valid:
                distances[agent.country_code] = np.sqrt(squared_diff)

        return distances


def create_world_map(model, dimension=None, distance_data=None, ref_country_name=None):
    """Create world map for dimension or cultural distances."""
    dimension_info = {
        'pdi': {'name': 'Power Distance Index', 'description': 'Acceptance of unequal power distribution'},
        'idv': {'name': 'Individualism', 'description': 'Independence vs. interdependence'},
        'mas': {'name': 'Masculinity', 'description': 'Achievement vs. cooperation orientation'},
        'uai': {'name': 'Uncertainty Avoidance Index', 'description': 'Tolerance for ambiguity'},
        'ltowvs': {'name': 'Long Term Orientation', 'description': 'Future vs. present/past focus'},
        'ivr': {'name': 'Indulgence vs Restraint', 'description': 'Gratification vs. control of desires'}
    }
    
    fig = Figure(figsize=(16, 8), dpi=100)
    ax = fig.add_subplot(111)

    if GEOPANDAS_AVAILABLE:
        try:
            # Use built-in dataset (no internet required!)
            world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))

            if distance_data is not None:
                world['dimension_value'] = world['iso_a3'].map(distance_data)
                title = f"Cultural Distance from {ref_country_name}"
                legend_label = "Cultural Distance"
            else:
                dimension_data = model.get_dimension_data(dimension)
                world['dimension_value'] = world['iso_a3'].map(dimension_data)
                dim_info = dimension_info.get(dimension, {'name': dimension.upper(), 'description': ''})
                title = f"Hofstede's {dim_info['name']} ({dimension.upper()})\n{dim_info['description']}"
                legend_label = f"{dim_info['name']} Score"

            valid_vals = world['dimension_value'].dropna()
            if len(valid_vals) > 0:
                vmin, vmax = valid_vals.min(), valid_vals.max()
                cmap = LinearSegmentedColormap.from_list(
                    "blue_red", ["#08306b", "#f0f0f0", "#a50f15"], N=256
                )
                world[world['dimension_value'].notna()].plot(
                    column='dimension_value',
                    ax=ax,
                    cmap=cmap,
                    vmin=vmin,
                    vmax=vmax,
                    edgecolor='#555555',
                    linewidth=0.5,
                    legend=True,
                    legend_kwds={
                        'label': legend_label,
                        'orientation': 'horizontal',
                        'shrink': 0.6,
                        'pad': 0.02
                    }
                )

            # Show missing countries as WHITE
            world[world['dimension_value'].isna()].plot(
                ax=ax,
                color='white',
                edgecolor='#555555', 
                linewidth=0.5
            )

            ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
            ax.set_axis_off()
            fig.text(0.5, 0.02, 'Source: Hofstede Insights | Map: Natural Earth',
                     ha='center', fontsize=9, style='italic', color='#666666')

        except Exception as e:
            ax.text(0.5, 0.5,
                   f"Map error:\n{str(e)}",
                   ha='center', va='center', transform=ax.transAxes,
                   fontsize=12, bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.7))
            ax.set_axis_off() 
    else:
        ax.text(0.5, 0.5,
                "Install geopandas for map:\npip install geopandas",
               ha='center', va='center', transform=ax.transAxes,
               fontsize=14, bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
        ax.set_axis_off()

    fig.tight_layout()
    return fig


def create_statistics_table(model, dimension):
    """Create statistics HTML table."""
    dimension_data = model.get_dimension_data(dimension)
    if not dimension_data:
        return "<div style='padding: 10px;'><p>No data available</p></div>"
    
    values = list(dimension_data.values())
    stats = {
        'Countries': len(values),
        'Minimum': f"{min(values):.1f}",
        'Maximum': f"{max(values):.1f}",
        'Average': f"{np.mean(values):.1f}",
        'Median': f"{np.median(values):.1f}",
        'Std Dev': f"{np.std(values):.1f}"
    }

    agents_dict = {agent.country_code: agent.country_name for agent in model.agents}
    sorted_countries = sorted(dimension_data.items(), key=lambda x: x[1], reverse=True)
    top_3 = [(agents_dict.get(code, code), value) for code, value in sorted_countries[:3]]
    bottom_3 = [(agents_dict.get(code, code), value) for code, value in sorted_countries[-3:]]
    bottom_3.reverse()

    html = "<div style='padding: 10px;'>"
    html += "<h3>Statistics</h3>"
    html += "<table style='width: 100%; border-collapse: collapse;'>"
    for key, value in stats.items():
        html += f"<tr><td style='padding: 5px; border-bottom: 1px solid #ddd;'><strong>{key}:</strong></td><td style='padding: 5px; border-bottom: 1px solid #ddd;'>{value}</td></tr>"
    html += "</table>"

    html += "<h3 style='margin-top: 20px;'>Top 3 Countries</h3><ol>"
    for country, value in top_3:
        html += f"<li>{country}: {value:.1f}</li>"
    html += "</ol>"

    html += "<h3>Bottom 3 Countries</h3><ol>"
    for country, value in bottom_3:
        html += f"<li>{country}: {value:.1f}</li>"
    html += "</ol></div>"

    return html


@solara.component
def WorldMapVisualization(model):
    dimension_display = {
        'pdi': 'Power Distance',
        'idv': 'Individualism',
        'mas': 'Masculinity',
        'uai': 'Uncertainty Avoidance',
        'ltowvs': 'Long Term Orientation',
        'ivr': 'Indulgence'
    }
    
    # Get countries with complete data for distance calculation
    complete_countries = []
    dims = ['pdi', 'idv', 'mas', 'uai', 'ltowvs', 'ivr']
    country_code_to_name = {}

    for agent in model.agents:
        country_code_to_name[agent.country_code] = agent.country_name
        has_all_data = True
        for dim in dims:
            val = agent.get_dimension_value(dim)
            if val is None or np.isnan(val):
                has_all_data = False
                break
        if has_all_data:
            complete_countries.append((agent.country_code, agent.country_name))

    complete_countries.sort(key=lambda x: x[1])  # Sort by name

    selected_dimension = solara.use_reactive('pdi')
    show_distance = solara.use_reactive(False)

    # Initialize with first country code (string, not tuple)
    initial_country = complete_countries[0][0] if complete_countries else None
    selected_country = solara.use_reactive(initial_country)

    weights = solara.use_reactive({d: 1/6 for d in dims})

    # Calculate map and stats
    if show_distance.value and selected_country.value:
        # βœ… SAFETY CHECK: Ensure selected_country.value is a string
        ref_country_code = selected_country.value
        if isinstance(ref_country_code, list):
            # If it's a list, take the first element (the code)
            ref_country_code = ref_country_code[0] if ref_country_code else None
        
        if ref_country_code:
            dist_data = model.calculate_cultural_distances(ref_country_code, weights.value)
            ref_name = country_code_to_name.get(ref_country_code, ref_country_code)
            fig = create_world_map(model, distance_data=dist_data, ref_country_name=ref_name)
            stats_html = "<p>Select a dimension to see statistics</p>"
        else:
            fig = create_world_map(model, selected_dimension.value)
            stats_html = create_statistics_table(model, selected_dimension.value)
    else:
        fig = create_world_map(model, selected_dimension.value)
        stats_html = create_statistics_table(model, selected_dimension.value)

    with solara.Column(style={'width': '100%', 'max-width': '1400px', 'margin': '0 auto'}):
        solara.Markdown("# 🌍 Hofstede's Cultural Dimensions")
        
        with solara.Card("Control Panel", style={'margin': '20px 0'}):
            solara.Markdown(f"**Selected: {dimension_display[selected_dimension.value]}**")
            
            with solara.ColumnsResponsive(12, large=[2, 2, 2], style={'gap': '10px'}):
                for code, label in dimension_display.items():
                    solara.Button(
                        label,
                        color='primary' if selected_dimension.value == code else 'default',
                        on_click=lambda c=code: selected_dimension.set(c),
                        style={'width': '100%'}
                    )
            
            solara.Checkbox(label="Show Cultural Distance Map", value=show_distance.value, on_value=show_distance.set)
            
            # Weight inputs (only when showing distance map)
            if show_distance.value:
                if complete_countries:
                    solara.Select(
                        label="Reference Country",
                        values=complete_countries,  # [(code, name), ...]
                        value=selected_country.value,
                        on_value=selected_country.set
                    )
                else:
                    solara.Warning("No countries with complete data available for distance calculation.")
            
                # Weight inputs
                solara.Markdown("### Dimension Weights (sum must equal 1.0)")
                total_weight = sum(weights.value.values())
                if abs(total_weight - 1.0) > 0.01:
                    solara.Error(f"Total weight = {total_weight:.3f} (must equal 1.0)")
            
                new_weights = weights.value.copy()
                for dim in dims:
                    def make_handler(d):
                        def handler(value_str):
                            try:
                                value = float(value_str)
                                if 0 <= value <= 1:
                                    new_weights[d] = value
                                    weights.set(new_weights.copy())
                            except ValueError:
                                pass
                        return handler
            
                    solara.InputFloat(
                        label=dimension_display[dim],
                        value=new_weights[dim],
                        on_value=make_handler(dim)
                    )

        with solara.Card("World Map Visualization", style={'margin': '20px 0'}):
            solara.FigureMatplotlib(fig, dependencies=[
                selected_dimension.value,
                show_distance.value,
                str(selected_country.value),  # Convert to string for dependency tracking
                str(weights.value)
            ])
            plt.close(fig)

        with solara.Columns([2, 1], style={'margin': '20px 0', 'gap': '20px'}):
            with solara.Card("About Hofstede's Cultural Dimensions"):
                solara.Markdown("""
                Geert Hofstede's cultural dimensions theory describes how cultural values influence behavior across societies.
                
                **The Six Dimensions:**
                  
                - **PDI**: Power Distance Index – acceptance of hierarchical power distribution
                - **IDV**: Individualism vs Collectivism – loose vs tight social frameworks
                - **MAS**: Masculinity vs Femininity – competition vs cooperation values
                - **UAI**: Uncertainty Avoidance – tolerance for ambiguity and risk
                - **LTOWVS**: Long-Term Orientation – future rewards vs tradition
                - **IVR**: Indulgence vs Restraint – gratification of desires
                
                ---
                *Data source: Hofstede Insights*
                """)
            
            with solara.Card("Dimension Statistics"):
                solara.HTML(unsafe_innerHTML=stats_html)


@solara.component
def Page():
    # βœ… Use correct filename based on your data
    model = solara.use_memo(lambda: HofstedeModel("hofstede_data_final.csv"), dependencies=[])
    WorldMapVisualization(model)

page = Page