# Ludek's People Analytics Second Brain — complete corpus Source: https://lstehlik2809.github.io/people-analytics-second-brain | Author: Ludek Stehlik (https://www.linkedin.com/in/ludekstehlik/) --- # My second People Analytics brain - both for people and AI agents URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-analytics-second-brain Date: 2026-07-28 Tags: people-analytics, ai, agentic-ai, embeddings, nlp, knowledge-management, data-visualization Original post: https://blog-about-people-analytics.netlify.app/posts/2026-07-28-people-analytics-second-brain/ Specifically, I built a public knowledge base from more than 200 posts on this People Analytics blog. It is browsable as a knowledge graph, searchable by keywords or meaning, and accessible to AI agents through a public MCP server. The complete corpus is also available as `llms-full.txt` - roughly 290k tokens, so it fits in one go only in the largest context windows - plus a compact `llms.txt` index. I used a small pipeline that converts the .Rmd sources of my blog posts into clean Markdown notes, embeds each note, and links it to its closest semantic neighbours. Post categories become topic nodes, so themes like causal inference, psychometrics or employee turnover turn into hubs in the network. The result is published with Quartz on GitHub Pages, and semantic search runs entirely in the visitor's browser (transformers.js + a small MiniLM model - no server, no API keys). The MCP server is a tiny Cloudflare Worker that fetches the published corpus at runtime and indexes it in memory, so new posts show up without redeploying it. Its tools are shaped for how agents actually work: search for candidates, triage them by snippet, then read the one or two notes that matter in full. In this setting, it enables two ways of using it: 🧠 **Directly** - explore the graph to see how ~300 notes and topics connect, jump between them, search by keyword or by meaning, and follow the related-notes trail at the end of each note. Every note links back to the original post with the full outputs. 🤖 **Via AI agents** - add the MCP server as a connector in any MCP-capable AI client (e.g., Claude Code/Cowork, Codex/Work, Cursor, etc.) and just ask questions. The agent can search the knowledge base, retrieve the relevant full notes, and produce an answer grounded in them - with links back to the original sources. If MCP isn’t available in your preferred tool, you can instead point an LLM with a sufficiently large context window at `llms-full.txt`. It’s too early to make any definite judgments, but so far this setup seems pretty useful for ideation, discovering connections across topics, reusing previous work instead of starting from scratch, and grounding AI-agent outputs in domain-specific knowledge that was curated independently of the agent itself 🤔 Everything is publicly available, so feel free to give it a try for yourself 👉 https://lstehlik2809.github.io/people-analytics-second-brain MCP endpoint, streamable HTTP, no authentication required 👉 https://people-analytics-brain-mcp.ludek-stehlik.workers.dev/mcp GitHub repo 👉 https://github.com/lstehlik2809/people-analytics-second-brain

Your browser cannot display this PDF. Download the PDF.

Download the PDF

--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-07-28-people-analytics-second-brain/) on my blog. --- # Evidence-Based Management: Boring basics and better questions URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/beyond-belief Date: 2026-07-24 Tags: evidence-based-management, hr-management Original post: https://blog-about-people-analytics.netlify.app/posts/2026-07-24-beyond-belief/ Just finished Helen Pearson’s [*Beyond Belief*](https://helenpearson.info/book/what-to-believe/) (btw, highly recommended) - a book about the evidence revolution and how we discover what actually works across domains ranging from medicine and education to business, government, conservation, and parenting. While the book contains many fascinating and relatively little-known gems - for example, did you know that [*Scared Straight* programs](https://methods.cochrane.org/equity/scared-straight), intended to deter at-risk young people by exposing them to prison life, were shown across nine randomized trials to increase rather than reduce subsequent offending? - I want to highlight here just the following two points from the chapter on evidence-based management, the area most directly relevant to People Analytics. The first point is a reminder that the latest trends and shiny new ideas in the HRM space are often not worth our attention. Instead, we should focus on basic, simple, and, yes, relatively boring principles that are nevertheless supported by sufficient evidence. Pearson captures this nicely by quoting Eric Barends, a leading advocate of evidence-based management: “*Barends says the best way to improve the performance and wellbeing of employees is usually to do basic things that evidence supports—the management equivalent of washing your hands or getting enough sleep.* *Employees need to know what is expected of them in their role and have clear goals. They should be given meaningful feedback and feel psychologically safe when raising ideas and concerns. Managers should be able to fairly and accurately determine an employee’s performance in appraisals. Staff should be recognised for the contributions they make. [...]* *These are all fairly obvious and, in most cases, says Barends, they are good enough. The biggest gains in management, he argues, are to be found in following basic evidence-backed principles rather than the latest trend.*”
![](./beyond-belief/mng_hygiene_pic.png)
*A graphical illustration of the point described above - not from the book, but AI-generated.* And the second point is about a recommendation to use a simple set of questions to make better-informed decisions, thereby increasing the likelihood of a favorable outcome. Pearson cites in this context Denise Rousseau, an organizational psychologist and pioneer of evidence-based management, who suggests that, “*when facing a decision, [take] a few minutes to ask these questions: What’s the problem I’m trying to solve? What’s the evidence for the problem? What solutions are appropriate? What’s the evidence for the solutions? [...]* *For instance, a CEO might require that everyone attend the office five days a week based on the assumption that face-to-face interactions lead to more innovation. The evidence-based manager first unpacks the assumptions underlying this decision: the company is insufficiently innovative; this is damaging business objectives; face-to-face interactions will boost innovation and are more effective than other solutions, such as virtual meetings. Then they would look, even briefly, for research that backs each one.* *Spending just a few minutes thinking about the problem, generating more than one solution, and seeking a little information on them can help people think more deeply and critically.*” There are plenty more gems and learnings in the book itself - so if you still have a free slot on your summer reading list and want to learn more about the stories behind the evidence revolution that began several decades ago, I can only recommend adding Pearson’s book to it. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-07-24-beyond-belief/) on my blog. --- # ‘You Said, We Did’ matters - maybe just not as distinctly as we assume URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/you-said-we-did Date: 2026-07-07 Tags: employee-survey, employee-engagement, employee-experience, employee-listening, survey-participation, regression-analysis, psychometrics, statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2026-07-07-you-said-we-did/ If there is one piece of widely accepted wisdom in the employee listening space, it is this: *organizations need to respond visibly and credibly to employee input, or employees will eventually stop giving it*. That claim is repeated so often that it almost reads as self-evident. Still, I wanted to see how far it would hold up against real data. One imperfect, but still directionally informative, way to examine this is to ask whether employees’ prior-year survey responses predict participation in the following year’s engagement survey among continuing employees who had responded previously. I approached that question in two steps. In the primary analysis, I used a raw prior-year survey item reflecting employees’ belief that their input would lead to a visible organizational response, together with common demographic and organizational covariates, to predict next-year survey participation. This provides the more substantively direct test of the practical wisdom itself: whether employees who believed their input would go somewhere were more likely to participate again the following year. In the secondary analysis, I took a narrower decomposition approach. For each survey item, I residualized it against a leave-one-item-out one-factor model estimated from the other survey items, using the complete-case subset required for that factor step and an organizational-cluster bootstrap to reflect uncertainty from both parts of the residualization-and-modeling pipeline. In practical terms, the secondary predictor captures the item-specific residual signal: whether an employee scored that item higher or lower than would be expected from their broader response pattern across the rest of the survey. That second specification is not a stronger causal design; it is a more specific shared-versus-residual signal test. The contrast between the two analyses is where things become interesting. In the primary model, the focal responsiveness item is clearly positively associated with next-year participation. Employees who scored higher on that item were more likely to respond again the following year, even after adjustment for standard demographic and organizational covariates. The association was statistically detectable but modest in size, so I would read it as evidence of a repeat-participation pattern rather than a large practical shift (one possible positive interpretation being that lower perceived responsiveness does not appear to translate into broad withdrawal from the survey as a channel for voice). At a broad level, the data are therefore consistent with the conventional wisdom.
![](./you-said-we-did/pic1.png)
*Fig. 1: Average marginal effects from the primary model predicting next-year survey participation from prior-year survey items. Items are anonymized for public sharing; Action Taken Item is the focal perceived-responsiveness item. Horizontal bars show 95% confidence intervals.* At the same time, that item was not the strongest item-level predictor in the raw analysis. Several other item scales measuring specific aspects of the employee experience appeared more strongly linked to repeat participation. That matters, because it suggests that future participation is not most tightly linked to a single responsiveness signal in isolation. It may be more strongly tied to a wider set of experiences that collectively shape whether employees remain willing to re-engage. And the secondary model sharpens that point further. Once the shared general engagement factor is removed, the focal responsiveness item no longer stands out as a distinct residual predictor. Its residual association is essentially null. By contrast, some other item-specific residual signals continued to show clearer associations with repeat participation.
![](./you-said-we-did/pic2.png)
*Fig. 2: Average marginal effects from the secondary residual-signal model, where each item is adjusted for the broader survey-response pattern using a leave-one-item-out factor approach. Items use the same anonymized labels as in the primary chart; Action Taken Item is the focal perceived-responsiveness item. Horizontal bars show 95% cluster-bootstrap confidence intervals.* That difference in findings changes the interpretation. If we stop at the primary model, the story is straightforward: employees who believe in some visible response to their input are more likely to participate again the next year. But once we compare the primary and secondary analyses, a more nuanced picture emerges. Much of the predictive association attached to that item appears to be shared with the broader employee-experience response pattern with which it co-moves, rather than providing clearly distinct incremental predictive information. In other words, perceived responsiveness still matters descriptively, but it appears less distinctive as a standalone predictive signal once its shared variation with the broader engagement-related survey pattern is removed. That, to me, is the deeper insight. The practical takeaway is not that visible response is unimportant. It is that organizations may overstate its uniqueness. If the goal is to sustain future participation, the stronger predictive signals may sit less in any single “you said, we did” item and more in the broader relational and organizational environment - or in other common factors reflected across employees’ survey responses. A final caveat is important. Neither analysis is causal. These are observational, associational models of repeat participation among continuing employees, based on a single year-over-year transition rather than a longer longitudinal window. They are not estimates of whether actual organizational responsiveness causes future survey participation. Still, even with these limitations, I think the comparison is useful. The primary analysis captures the total predictive signal attached to perceived responsiveness. The secondary analysis asks whether the item adds predictive information beyond the dominant common pattern reflected in the rest of the survey. Taken together, they suggest a more disciplined version of the standard wisdom: responsiveness likely matters, but much of its observed predictive association may be shared with the wider organizational context, general engagement, response tendencies, or related features of how employees experience the survey process. For many people working in employee listening, this may not be so surprising. But it’s still useful to see the pattern show up in the data 😉 Curious if anyone has done a similar exercise, and with what results. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-07-07-you-said-we-did/) on my blog. --- # Before you blame the driver, check the denominator URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/car-accidents-near-home Date: 2026-06-29 Tags: fun-with-data, cognitive-biases Original post: https://blog-about-people-analytics.netlify.app/posts/2026-06-29-car-accidents-near-home/ Recently, we had a family discussion about why so many car accidents happen relatively close to home. It nicely illustrated one of the cognitive blind spots all of us run into from time to time. Most explanations offered in the discussion were driver-centered: people are tired after a long trip, they relax too early because they are almost home, familiar roads make them overconfident, or they stop paying close attention because the surroundings feel routine. These explanations may be true in some cases. But they are not the first explanation we should reach for. The simpler baseline is exposure. Many trips start or end at home. Many everyday trips are also short: shopping, school, work, the gym, visiting friends, errands, appointments. So a large amount of ordinary driving happens within a relatively small radius of home. That alone can produce the pattern. Suppose accident risk per kilometre were constant everywhere. That is not realistic, but it is a useful null model. Under that assumption, accidents would still cluster near home simply because driving exposure clusters near home. And this clustering does not actually depend on most trips being short. Every trip that starts from home passes through the roads near home; only some trips ever reach the roads far away. So whatever the mix of trip lengths happens to be, the area near home is the stretch of road that the most journeys have in common, and therefore the stretch that carries the most exposure. The "short trips" observation reinforces this, but it is not what drives it. So the raw fact that many accidents happen close to home does not, by itself, show that people drive worse close to home. To make that claim, we would need a denominator. Not just where accidents happen, but how much driving happens at each distance from home: kilometres driven, time spent driving, number of trips, road types, intersections, speed, traffic density, time of day, and so on. There are also different kinds of accidents. Minor crashes may be more common on local roads because of intersections, parking, pedestrians, cyclists, and frequent turning. Severe or fatal crashes may follow a different pattern because speed matters so much. So even the word "accident" needs some care. This does not rule out psychological factors. Fatigue, familiarity, distraction, and "almost home" effects may all matter. But they should explain whatever remains after exposure and road environment have been accounted for. The mistake is to see a cluster of accidents near home and immediately explain it by driver psychology. The more basic possibility is that people crash near home because that is where they drive a lot. To be clear, none of this is a deep or original result. For anyone comfortable with base rates and denominators, it is almost too obvious to state. What interested me was the reverse: how naturally a group of thoughtful people reached past the plain structural explanation and went straight for a psychological one. Being a psychologist by training, this reminded me of a mix between the fundamental attribution error and base-rate neglect. The attribution-error part is the instinct to explain the pattern through the driver's mental state: fatigue, overconfidence, relaxation, distraction. The base-rate-neglect part is forgetting the denominator: how much driving actually happens at each distance from home. And it gets forgotten for a specific reason - exposure is a "merely statistical" base rate with no story attached, so it quietly loses out to the vivid causal one about the tired, overconfident driver. [Kahneman & Tversky](https://www.cambridge.org/core/books/abs/judgment-under-uncertainty/causal-schemas-in-judgments-under-uncertainty/2AF53FA85B49717E1B4C23598B1EB5BC) 👋 P.S. I made [a simple tool](https://lstehlik2809.github.io/Car-Travel-Simulator/) to play with this idea. It lets you simulate a "null hypothesis world" where accident risk per unit of driving is constant, while trip lengths follow different distributions. A gamma distribution is a reasonable starting point for everyday driving, because most trips are short and a smaller number are much longer, but you can swap in others. The point is not that the null model is true. It obviously leaves out many real-world factors. And the concentration near home is not an artefact of any one distribution: it shows up whatever trip-length distribution you choose. That is the whole point. Even this stripped-down model already produces accident concentration near home. You can try it [here](https://lstehlik2809.github.io/Car-Travel-Simulator/).
![](./car-accidents-near-home/car-travel-sim-cut.gif)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-06-29-car-accidents-near-home/) on my blog. --- # Company culture as a forward signal of financial performance? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/company-culture-and-financial-performance Date: 2026-06-25 Tags: organizational-culture, glassdoor, performance, bayesian-statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2026-06-25-company-culture-and-financial-performance/ Some time ago, in response to my [post about hidden trade-offs in corporate culture](https://blog-about-people-analytics.netlify.app/posts/2025-09-17-corporate-culture-trade-offs/), Nick Hudgell, suggested examining how different aspects of culture are associated with subsequent financial performance. It took me several months to return to the idea - but I finally did 🤓 ### THE DATA One merged company-level dataset, one row per public company. The nine culture dimensions - transparency, leadership, openness to feedback, supportive culture, low toxicity, strategy, innovation, agility, work-life balance - come from CultureX, which scored anonymous Glassdoor reviews posted between January 1, 2023 and April 4, 2025 across companies in different industries. Each company is paired with four forward outcomes: return on assets, operating margin, revenue growth, and excess total shareholder return versus the S&P 500. After keeping public listings and complete cases, each model runs on roughly 95 to 210 firms. ### THE TIMING The timing helps reduce one concern: the culture scores are measured before the outcome window. The review period ends in April 2025, and the forward stock-return window runs through April 2026. The pre-culture financial controls draw on 2020 to 2022 history. That does not establish causality, but it makes the analysis closer to a forward-looking association than a same-period correlation. ### THE METHOD A separate Bayesian hierarchical model for each culture-and-outcome pair, with industry-level pooling and Student-t residuals to reduce sensitivity to outliers. Each one standardizes culture within industry and adjusts for prior performance, firm size, stock volatility, and pre-existing trend. ### THE RESULTS: FORWARD ROA, THE HEADLINE OUTCOME * A “relational” cluster sits on the positive side. Under this model, the posterior probability that the coefficient is positive is 97% for transparency, 97% for leadership, 96% for openness to feedback, 94% for low toxicity, and 91% for supportive culture. * The “operational” dimensions are less informative. Strategy lands at 78%, innovation at 51%, and agility at 31%, all with wide two-sided uncertainty. * Work-life balance breaks from the pack. Under this model, it is only 14% likely to be positive for ROA. I would not read this necessarily as evidence that work-life balance hurts profitability; it may be picking up business model, growth stage, labor intensity, or review-selection differences.
![](./company-culture-and-financial-performance/forest_primary.png)
### ACROSS THE FOUR OUTCOMES Re-running every dimension against all four metrics shows where the signal concentrates. The relational cluster is strongest for the two profitability outcomes, ROA and operating margin. Revenue growth is the weakest outcome: effects sit closer to zero and the relational ordering no longer holds. Excess TSR partly diverges: low toxicity, transparency, and leadership stay positive, while work-life balance flips from negative on profitability to positive on market return.
![](./company-culture-and-financial-performance/robustness_strip.png)
### TWO THINGS I WOULD PUT NEXT TO ANY HEADLINE First, the effects are modest: about 0.1 to 0.2 outcome standard deviations per one within-industry standard deviation of culture. In practical terms that is small, and better communicated as a tendency than as a promised return. Second, the dimensions are heavily correlated. When all nine compete in a single regularized horseshoe model on the 95-firm complete-case sample, transparency remains the most consistently positive dimension in the joint model: 87% probable positive. The rest collapse toward zero, although the evidence is limited here by the small complete-case sample. So I would not read these as nine independent culture effects. The cleaner reading is one correlated relational-culture bundle, with transparency as its most distinct carrier. ### WHERE THE PATTERN HOLDS The ROA ordering is reasonably stable across several robustness checks. It holds when I swap headcount for assets as the size control, when baseline performance is a multi-year average instead of the latest level, and when industry is modeled with fixed effects, which sharpens the leading cluster to between 98% and 100%. The market-return signal also survives no-washout, three-month, and six-month return windows, so the result does not appear to be driven solely by the exact return-window choice.
![](./company-culture-and-financial-performance/washout_comparison.png)
### THE CAVEAT This is observational. These are adjusted associations, not causal effects. The strongest alternative explanation is that well-managed firms both earn better employee language around transparency and leadership and later deliver better profitability. The model adjusts for observable history, but it cannot fully separate culture from broader management quality. The culture measure is also indirect: built from employees who chose to post reviews, it may track morale, employer brand, or recent business momentum as much as culture itself. And the sample is small and public-only: 95 to 210 firms per model, and just 95 across six industries when all nine dimensions compete jointly. That is a thin base from which to generalize to private firms, other regions, or every industry. So I would keep the claim narrow: a relational-culture bundle, transparency above all, is modestly associated with better forward profitability after adjusting for major observable differences. It does not say that raising transparency will raise ROA 😔 The claim is narrow but, imo, still useful: relational culture, especially transparency, is a modest forward signal of profitability. Not a magic lever. Not causal proof. More like a corporate smoke detector - not the fire itself, but a signal worth investigating before ignoring 🤔 P.S. If you would like to replicate or extend the analysis, you can find the dataset on [my GitHub page](https://github.com/lstehlik2809/company-culture-financial-performance-data). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-06-25-company-culture-and-financial-performance/) on my blog. --- # A nerdy way to choose your next holiday destination URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/cultural-traveler Date: 2026-06-21 Tags: cross-cultural-differences, organizational-culture, work-life-balance, generative-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2026-06-21-cultural-traveler/ My recent encounter with [Hofstede’s cultural dimensions model](https://geerthofstede.com/culture-geert-hofstede-gert-jan-hofstede/6d-model-of-national-culture/) in the context of the [work-from-home topic](https://blog-about-people-analytics.netlify.app/posts/2026-06-18-hofstede-wfh/) reminded me of a nerdy and probably not very practical idea I had some time ago. I wanted an app that could find cultural doppelgangers of countries I’d like to visit - but closer to home. The idea was to reduce travel distance, potentially save money, and perhaps make the trip a little friendlier to the environment. And because these days what separates you from materializing even the craziest app idea you have is just your willingness to sacrifice one hour of Netflix, I built it 🤓 Check out the resulting app [here](https://cultural-traveler.streamlit.app/). It answers three questions:️ * **Cultural twin**: Which countries near my home culturally resemble a more distant country I’d like to visit?️ * **Maximum otherness**: Within a chosen radius, which country is culturally least similar to my home? * **Home away from home**: If I want to travel abroad without encountering too many cultural surprises - yes, even such travelers exist 😲 - which nearby countries are the best candidates?
![](./cultural-traveler/cultural-traveler-app.gif)
The search radius is based on distances between capital cities. You can also decide which cultural dimensions matter more to you. The app optionally considers differences in local price levels using World Bank PPP-based household-consumption data, but this is only a rough affordability indicator: it does not include flights, accommodation choices, seasonality, or your talent for finding suspiciously cheap restaurants 😉 Ideally, the app would also consider flight prices, but I’m not willing to sacrifice another one or two hours of Netflix watching 😁 Give it a try and let me know whether it sparks some inspiration for your upcoming summer holiday season. P.S. Hofstede’s country scores are broad national averages. They capture only a small part of what shapes a travel experience and say nothing about every individual, city, or neighborhood, so please treat the results just as a playful source of inspiration. I don’t want to receive complaint letters later saying that I ruined someone’s vacation 😁 ---- **Update**: One of the commenters on the LinkedIn version of this post asked an interesting question relevant to the first and third use cases covered by the app: "*how often are 'similar countries' not neighboring countries?*" That question sent me back to the data to check. What were the results? Across all 69 countries, 78% had a closest cultural twin that wasn’t a neighbor, and even among countries with at least one land neighbor, the figure was 73%. However, that doesn’t mean geography and culture are unrelated, just less strongly related than one might expect - the rank correlation was fairly weak (*ρ* = -0.23), but robustly negative (95% HDI: -0.34 to -0.12), even after accounting for the non-independence of country pairs.
![](./cultural-traveler/distance_similarity_all_pairs.png)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-06-21-cultural-traveler/) on my blog. --- # What does national culture have to do with working from home? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/hofstede-wfh Date: 2026-06-18 Tags: remote-work, cross-cultural-differences, organizational-culture, open-science Original post: https://blog-about-people-analytics.netlify.app/posts/2026-06-18-hofstede-wfh/ Recently, I listened to a podcast episode featuring Nicholas Bloom, where he mentioned an interesting finding from his recent research with colleagues ([Zarate et al., 2025](https://www.pnas.org/doi/10.1073/pnas.2529036122)): Countries scoring higher on individualism - as conceptualized in [Hofstede’s cultural-dimensions model](https://geerthofstede.com/culture-geert-hofstede-gert-jan-hofstede/6d-model-of-national-culture/) - tend to have more work-from-home days per week. This association remains in a model that includes GDP per capita, the share of jobs suitable for WFH, population density, commuting time, and pandemic lockdown stringency. Intuitively, this makes sense: individualistic cultures may place greater value on autonomy and personal control, while their organizations may be more comfortable evaluating employees by outcomes rather than physical presence. Still, this intuitive explanation deserves a generous grain of salt. Even with these controls, many potential confounding paths remain. This sparked my curiosity about Hofstede’s remaining cultural dimensions. Several of them could plausibly matter for WFH decisions. For example:️ * Power distance may shape how comfortable managers are with granting autonomy.️ * Uncertainty avoidance may affect tolerance for the less visible and less standardized nature of remote work.️ * Long-term orientation may influence how organizations balance immediate flexibility against long-term coordination, learning, and social cohesion. However, these other dimensions were not examined in the paper. Fortunately, the authors shared their data. It was therefore fairly straightforward to enrich their dataset with the remaining Hofstede dimensions and run an analogous analysis for each one. The results? The chart below presents posterior estimates from six separate Bayesian Gaussian regressions. Individualism emerged as the clearest positive correlate: 99.3% of posterior draws were above zero. Long-term orientation showed the most suggestive negative association, with 96.4% of draws below zero. Power distance and uncertainty avoidance also leaned negative, but with considerably more uncertainty. The estimates for masculinity and indulgence were weakly positive and highly uncertain.
![](./hofstede-wfh/posterior_cultural_densities.png)
To get a clearer view of whether WFH frequency is related to specific Hofstede dimensions, we need to account for the fact that the six cultural scores are correlated. In separate models, the coefficient for one dimension may partly capture variation that it shares with other dimensions. For example, an individualism coefficient may reflect not only individualism itself, but also the fact that more individualistic countries often differ systematically on power distance, uncertainty avoidance, or long-term orientation. A complementary approach is therefore to include all six Hofstede dimensions in the same regression. This changes the interpretation of the cultural coefficients: each estimate now reflects the association between WFH and the part of that dimension that is not shared with the other five dimensions, while also conditioning on the available country-level covariates. However, with only 37 countries and several correlated cultural predictors, the joint model can become unstable and sensitive to model specification. To make this comparison more conservative, I used a regularized horseshoe prior on the six cultural coefficients. This prior shrinks the cultural block toward zero overall, while still allowing individual dimensions to retain larger estimates if the data contain a sufficiently distinct signal. The chart below compares the estimates from the separate models with those from the joint shrinkage model. Individualism remains the largest cultural estimate in the joint model, but the evidence is much weaker than in the separate model. Long-term orientation retains the second-largest directional signal, although it also shrinks substantially. The remaining dimensions are estimated much closer to zero, suggesting that their apparent associations in the separate models may have partly reflected variation shared with other cultural dimensions.
![](./hofstede-wfh/joint_shrinkage_cultural_estimates.png)
Do any of these findings surprise you - or do they mostly support your existing intuitions? ⚠️ Note: Interesting as these patterns may be, they should be interpreted cautiously. The Hofstede dimensions are intercorrelated, hidden confounders may remain, the sample covers only 37 countries, and WFH was measured specifically among full-time, college-educated workers. P.S. If interested in exploring the data visually, visit [this dashboard](https://lstehlik2809.github.io/hofstede-wfh/).
![](./hofstede-wfh/hofstede-wfh_dashboard.png)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-06-18-hofstede-wfh/) on my blog. --- # Before you believe the £70,000 cat URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/impact-of-pets-on-life-satisfaction Date: 2026-06-14 Tags: causal-inference, research-methods, critical-thinking Original post: https://blog-about-people-analytics.netlify.app/posts/2026-06-14-impact-of-pets-on-life-satisfaction/ We are about to get a cat for our son, so it is no big surprise that the Internet started feeding us more information about pets even without being asked. Normal stuff in the algorithmic age, I suppose. One of these pieces was [a 2025 paper on the benefits of having a pet for life satisfaction](https://link.springer.com/article/10.1007/s11205-025-03574-1). The headline claim was huge: in the main estimates, **having a dog or cat is worth 3 to 4 points on a 1–7 self-reported life satisfaction scale**, and **around £70,000 per year in life-satisfaction-equivalent income**. Of course, this looked like excellent evidence for my son’s lobbying campaign. But not so fast. Here is the thing I actually want to share, and it has very little to do with pets. Most of us will never re-run a study's regressions. But all of us can train the instinct that fires before we believe a number - the same instinct that kicks in when a too-good-to-be-true email lands in your inbox. That instinct is worth more than any single statistical method. So let me walk through it, using one cat as the worked example. **First instinct: is the effect even physically plausible?** A 3-4 point move on a 1-7 scale is not "pets make people a bit happier." It is closer to moving someone from "quite dissatisfied" to "almost completely satisfied." The scale is only six points wide. An effect that large, as an average, would push people off the top of the ruler. That alone should slow you down. **Second instinct: is it bigger than things we already know are big?** In the authors' own IV models, the estimated pet effect is larger, in absolute size, than the effect of their poor-mental-health indicator. That does not make it impossible. But when a cat appears to matter more than mental health, you check the wiring. Now, to be fair, the authors earned a closer look. They did not simply correlate pet ownership with happiness - they knew that was a trap. Happier people might be more likely to get pets. Or lonelier, less satisfied people might get pets to cope. The direction of causality is genuinely not obvious. To deal with this, they used an **instrumental-variable design**. Their instrument was whether people watch over their neighbors' property when the neighbor is away. The rough idea: people who help neighbors may also be exposed to neighbors' pets, and that exposure may nudge them into getting a pet themselves. That is clever. It is also where the problem begins. For an instrument to work, it has to meet a demanding condition: it should affect life satisfaction **only through pet ownership**. In plain English, "watching over neighbors' property" should make you happier only because it makes you more likely to own a pet. That seems hard to believe. People who watch over neighbors' property are probably different in other ways too. They may live in more trusting neighborhoods, have stronger social ties, be more helpful, be more embedded in their community, or simply have better relationships with the people around them. Any of these could lift life satisfaction directly. So the instrument may be picking up **social capital**, not only pets. The authors are aware of this and try to control for neighborhood cohesion. That helps, but it does not close the hole. "Do you live in a close-knit neighborhood?" is a rough proxy for the deeper reality of being trusted, connected, useful, and supported. There is a second issue, and it is the one that turns a small flaw into a big number. The instrument predicts pet ownership, **but not very strongly**. Switching it on raises the likelihood of owning a cat by only about 4 percentage points, and a dog by about 5. Statistically significant, yes. Substantively small. Why does that matter? Because IV estimates work by dividing the outcome shift by the treatment shift. If the treatment shift is tiny, even a small amount of contamination in the instrument gets divided by something close to zero. That is how **a small leak becomes a huge estimated effect**. More precisely: a weak first stage makes the IV estimate fragile; if the instrument also leaks even a little bit of social capital, that leak gets scaled up by the tiny treatment shift. The two flaws do not simply add - they **multiply**. Which brings me to the loudest alarm of all. In the raw data, pet owners are actually slightly less satisfied than non-owners. In the OLS regression, pets have basically no relationship with life satisfaction. Only after the instrument is applied does the estimate **flip sign and explode** into a large positive effect of 3 to 4 points. When a method reverses the direction of the raw result and inflates it this dramatically, the professional default should be "**maybe the instrument is broken**," not "we found a hidden causal truth." And the £70,000 is more fragile still, because it is not observed at all. It is **a ratio**: the estimated pet effect divided by the estimated income effect. If either piece is shaky, the price tag is shaky too. To be clear: **I am not arguing that pets do nothing**. They probably help many people - routine, affection, touch, purpose, social contact, and, for dogs, exercise. I am very open to the idea that pets improve wellbeing. My point is narrower. The paper is interesting and far more thoughtful than a simple correlation study, but **the headline effect is probably too large to take literally** - even within the narrow subgroup the IV is trying to describe: people whose pet ownership is nudged by watching over neighbors’ property. None of this required redoing anyone’s statistics. It only required a straightforward causal sanity check: **Is the effect plausible? Is the comparison clean? What else could be driving it? Is the key assumption believable?** And then, sadly, resisting the temptation to use the £70,000 cat as scientific cover for surrendering to my son’s lobbying campaign 😉 P.S. For our household, the causal model is simpler: *Child wants cat → parents eventually surrender → cat becomes true treatment condition*. Effect size still unknown. Expected purring coefficient positive 😻 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-06-14-impact-of-pets-on-life-satisfaction/) on my blog. --- # The Laws of Thought: The Quest for a Mathematical Theory of the Mind URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/the-laws-of-thought Date: 2026-05-31 Tags: cognitive-science, ai, machine-learning, generative-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2026-05-31-the-laws-of-thought/ I just finished reading Tom Griffiths’ amazing book, [*The Laws of Thought: The Quest for a Mathematical Theory of the Mind*](https://www.amazon.com/Laws-Thought-Quest-Mathematical-Theory/dp/1250358353), over the weekend.
![](./the-laws-of-thought/book_pic.jpg)
In a compact and very accessible way, it covers roughly 300 years of human efforts to understand how and why the mind does what it does, using various mathematical tools and frameworks. Among other things, the book shows how attempts to understand human cognition and attempts to build artificial minds have deeply shaped each other. It also provides deeper context for understanding current LLMs - their strengths as well as their limitations - from the perspective of probability theory at the computational level of analysis. **All in all, highly recommended!** P.S. The chart bellow represents a kind of knowledge graph generated from the book. It shows two layers at once: key figures, such as Chomsky, Boole, Turing, Skinner, Rumelhart, McClelland, Marr, and Tenenbaum; and major topics that organize the book, such as formal logic, symbolic AI, generative grammar, connectionism, and Bayesian cognition. Green edges mark supportive relationships, such as influence or collaboration; red edges mark critique; and gray dashed edges connect people to topics. The graph includes 48 people, 12 topics, 64 aggregated figure-to-figure edges, and 50 person-topic edges. You may need to click on the image and zoom in to see all the details 🕵️‍♂️
![Network: The Laws of Thought](./the-laws-of-thought/network_the_laws_of_thought.png)
Methodological note on the knowledge graph: The relationships were not inferred from simple co-occurrence. Every figure-to-figure edge had to be explicitly asserted in the text and backed by a verbatim quote. The pipeline extracted and cleaned the PDF, split it into chapters, induced a fixed 12-topic taxonomy, built an entity catalog, used an LLM to identify and classify evidence-backed relationships, and then ran code to verify that every evidence quote appeared in the source chapter. Mechanical steps were handled by code, semantic steps by the LLM, and evidence quotes were checked by deterministic code. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-05-31-the-laws-of-thought/) on my blog. --- # Can AI help us fight the bullshit asymmetry? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ai-and-bullshit-asymmetry Date: 2026-05-22 Tags: causal-inference, critical-thinking, employee-experience, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2026-05-22-ai-and-bullshit-asymmetry/ It's often said that critical thinking is one of the human skills that will stay important even in a world increasingly shaped by AI. The catch: the volume of information hitting our senses keeps growing, and a growing share of it is now AI-generated. Applying critical thinking at that scale may realistically require some help from AI itself. Otherwise we lose the fight against [Brandolini's Law](https://en.wikipedia.org/wiki/Brandolini%27s_law), the bullshit asymmetry principle: "*the amount of energy needed to refute bullshit is an order of magnitude bigger than that needed to produce it.*" A small example. I built a [bot around the five-step causal reasoning framework](https://chatgpt.com/g/g-69afe08219508191b0806e31c5f0d0af-causal-mindset-agent) from [Dr. Quentin Gallea's "*The Causal Mindset Handbook*"](https://thecausalmindset.com/book) and ran it against a recent [whitepaper from an HR-tech vendor in the employee-listening space](https://offer.welliba.com/hubfs/Latest%20Files%20for%20Linking/Welliba%20S%26P%20500%20Hidden%20Economics%20Whitepaper%20v_01.0.pdf?hsLang=en).
![](./ai-and-bullshit-asymmetry/agent_pic.png)
The paper presents this as "*the hidden economic value of employee experience*" and pinpoints "*where investment in people delivers the greatest return.*" Specifically: the top 100 S&P 500 companies on employee experience (EX) outperformed the rest by 5% in total shareholder return over five years, based on AI analysis of 25 million data points pulled from more than 150,000 websites. It never uses the word "causes," but the whole document, from the title ("*Hidden Economics*") to the recommendation to invest in the "*low-hanging levers*" for "*the fastest route*" to performance gains, is built to land as a causal claim. That is the claim worth testing. The bot, in my view, surfaced several methodological concerns with real precision:️ * This is a pure cross-sectional comparison. It has none of the elements that move a claim up the causal ladder. The proxy counterfactual is thin: a snapshot of the top 100 EX firms vs. the rest, with no controls for industry, size, or profitability. That is a long way from a parallel-world comparison. The Magnificent 7 and other AI-related stocks were also excluded "to minimise distortion from extreme growth dynamics," which is outcome-correlated: unless the exclusion rule was pre-specified, it could materially affect the reported 5% gap. The paper's own chart shows the 5% gap opening only in 2024-2025, not compounding across the full five-year window. * The confounder picture is one-sided. Reverse causation is the heaviest one: profitable, rising companies pay more, hire selectively, and have employees sitting on appreciating stock comp, all of which produces better public sentiment on the very platforms feeding the EX score. Industry mix (IT, Banking, Life Sciences dominate the Top 25) and S&P 500 survivorship push the same way. The net direction of bias is amplification. The true effect, if any, is likely materially smaller than 5%. * The "drivers" claim has inverted logic. Colleagues and direct managers are flagged as dominant positive drivers because they score high in 66% and 62% of firms. But factors that score universally high have low variance, and therefore low explanatory power for differences in outcomes. The factors that actually vary across companies are where the causal signal would live: communication, rewards, working conditions. * Getting closer to a parallel-world situation would require a lagged design (EX measured in year T, returns tracked in T+1 to T+5), within-firm fixed effects, industry-size-profitability matching, or natural experiments such as CEO transitions or RTO mandates that shift EX exogenously. The paper offers none of these. None of this means employee experience doesn't matter. It probably does. But the specific evidence in this paper is correlational, framed as if it were causal. What I found more useful than the critique itself is the workflow: a structured causal-reasoning framework that a model can apply in a minute or two. That is the kind of capability that will change how many of us read research, marketing claims, and internal dashboards. Happy to be pushed back on any of this - vendors with skin in the game often see things I don't. Curious what other frameworks you’ve made actionable in a similar way and found useful. Feel free to share links, if available. ---- **Update**: One of the commenters on the LinkedIn version of this post pointed me to the “*BS Detector*” created by Paul Sweeney, author of the excellent ["*Magnetic Nonsense: A Short History of Bullshit at Work and How to Make It Go Away*"](https://www.amazon.co.uk/MAGNETIC-NONSENSE-SHORT-HISTORY-BULLSHIT/dp/1068531037). You can access the tool [here](https://senselabs.co/bs-detector) or find it among the publicly available GPTs on OpenAI’s platform under the name "*Sense Lab BS Detector*". Give it a try. I tested it on several papers, and it’s brutally honest and direct, just like its author 🤓
![](./ai-and-bullshit-asymmetry/bs_detector.png)
P.S. If you don’t follow Paul, you should. His LinkedIn posts may challenge some of your favorite and deeply entrenched beliefs about work-related topics. He regularly brings a much-needed dose of skepticism to how we think about what we do in the HRM space. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-05-22-ai-and-bullshit-asymmetry/) on my blog. --- # Agentic workflow for causal inference URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/agentic-workflow-for-causal-inference Date: 2026-05-16 Tags: causal-inference, agentic-ai, ai, generative-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2026-05-16-agentic-workflow-for-causal-inference/ While preparing causal inference workshops for two recent conferences - SIOP 2026 and Machine Learning Prague 2026 - our team started experimenting with adding AI automation to our usual causal analysis workflow. The motivation was practical. Causal analysis involves many decisions before any estimator is run: defining the causal question, specifying the treatment and outcome, understanding the assignment mechanism, choosing adjustment variables, drawing a DAG, checking identification, and deciding which diagnostics and robustness checks are necessary. These steps are easy to compress or skip when working with a general-purpose AI assistant. We wanted a workflow that makes the design stage explicit, forces important decisions to be documented, and prevents the assistant from moving to implementation before the causal setup is clear. The current version is a small, opinionated repo that wires together three layers. ### 1. A reusable causal-inference skill The first layer is a reusable causal-inference skill: a `SKILL.md` file plus reference materials for study design, DAGs and identification, estimator choice, diagnostics, refutation and sensitivity analysis, code recipes, and reporting. The skill encodes a 10-step workflow: *Formulate → Design → DAG → Identify → Choose estimator → Estimate → Diagnose → Refute → Interpret → Report* Steps 1-5 are design-focused. The agent should not write analysis code until the causal question, DAG, identification strategy, and estimator choice have been made explicit. The workflow is adapted from the [causal inference skill by Alexandre Andorra](https://github.com/Learning-Bayesian-Statistics/baygent-skills/tree/main/causal-inference) from Learning Bayesian Statistics. We adjusted it to be more broadly accessible, moving beyond a primarily Bayesian analytical framing while keeping the emphasis on causal design, identification, DAG-based reasoning, estimation strategy, diagnostics, and robustness checks. The skill follows the same `SKILL.md` plus `references/` structure with progressive disclosure, so the agent only loads the reference file relevant to the current step. The repo also includes a document-generation skill, based on Anthropic’s `docx` skill, used near the end of the workflow to produce Word reports. These reports cover the causal question, assumptions, analysis plan, diagnostics, robustness checks, and interpretation. ### 2. Repo-level invariants The second layer is an `AGENTS.md` file with rules that apply across agents. This file contains the main workflow constraints and guardrails. For example: * if there is no DAG, the workflow stops at Step 3 * if the identification status is “not identified,” the workflow stops at Step 4 * if key diagnostics fail, the agent should not produce a final summary * panel data should use appropriate inference defaults, such as cluster-robust standard errors where relevant * staggered adoption designs should not be handled with a naive default specification * causal language should be calibrated to the strength of the design * unresolved design choices should trigger structured clarification questions before the design is locked The goal is to make the workflow more disciplined and easier to audit. Many causal analysis problems originate before estimation: vague treatment definitions, unclear assignment mechanisms, inappropriate controls, post-treatment covariates, positivity problems, or unsupported identification claims. The invariants file is meant to catch some of these issues before they are buried in code. ### 3. Three agent roles The third layer is a set of three agent roles with explicit handoffs. * **Planner** handles the design stage. It covers the causal question, target trial emulation where appropriate, DAG, identification, estimator choice, and design decisions. It writes a versioned plan and a `design.yaml` file containing the key answered design questions. * **Implementer** handles estimation, diagnostics, robustness checks, interpretation, and report preparation. It should only proceed after the design has been approved. * **Reviewer** audits both the design and the implementation. Each finding is classified as either design-level or code-level. Design-level findings are routed back to the Planner. Code-level findings are routed back to the Implementer. The Reviewer continues routing findings until the analysis is ready to finalize. In practice, this creates an iterative workflow rather than a single long prompt. The process usually involves several rounds between the Planner, Implementer, Reviewer, and the human analyst.
![](./agentic-workflow-for-causal-inference/causal_inference_agentic_workflow.png)
### Project structure The repo also enforces a few project-hygiene rules. * Raw data is treated as immutable. * `project.yaml` is the main source of truth for what is already known about the project: treatment, assignment mechanism, outcomes, covariates, measurement timing, and file paths. * Every output artifact records the plan version it was produced against. This makes it easier to see which design decisions were in place when a particular result, diagnostic, or report was generated. ### Platform support This template is implemented for GitHub Copilot custom agents. The workflow itself is platform-neutral: adapt `AGENTS.md`, `.github/agents/*.agent.md`, and `causal-inference` to your preferred agentic coding tool’s instruction format. Most modern coding agents can do this translation in a few minutes. You can find the repo [here](https://github.com/lstehlik2809/causal_inference_agentic_workflow). ### How to use it A typical workflow starts by forking the repo. Then you fill in `project.yaml` with the information you already know about your project: * the intervention or treatment * who was treated * the outcome * outcome measurement timing * pre-treatment covariates * assignment mechanism, if known * paths to the raw data Then you place the raw data files into `raw/`. After that, open your AI assistant of choice and ask the Planner to read `project.yaml` and prepare a causal estimation plan. The Planner performs structural EDA on the raw data, emulates a target trial where appropriate, draws a DAG, asks design-critical clarification questions, and writes the plan to: `docs/plans/.md` Once the design questions are answered and the identification block is approved, the work moves to the Implementer. The Implementer writes and runs the analysis code, produces diagnostics and robustness checks, and prepares outputs. The Reviewer then audits the design and implementation. If the analysis is ready, it returns a terminal approval. If not, it routes findings back to the Planner or Implementer, depending on whether the problem is design-level or code-level. ### Current status So far, we have tried the workflow on two projects. That is still a small sample, but the initial experience has been encouraging. The workflow feels fairly natural because it matches the way causal analysis usually unfolds: not as one clean linear sequence, but as repeated movement between design clarification, implementation, diagnostics, review, and revision. A typical run has taken around 10-15 iterations across the Planner, Implementer, Reviewer, and the human analyst. The most useful aspect so far has been the enforced separation between design and implementation. The agent is less likely to rush into estimation, and the human analyst gets clearer artifacts to review: a design plan, a DAG, an identification block, diagnostics, robustness checks, and a final report. ### Limitations This is not meant to replace domain knowledge or causal judgment. The workflow can help document assumptions, enforce process constraints, and surface missing design decisions. It cannot determine whether the assumptions are substantively correct. That still requires human expertise and, usually, discussion with people who understand the domain, intervention, data-generating process, and measurement context. The workflow is also still early. We need to test it across more designs, data structures, and failure cases. The areas where I expect problems to show up first are: * complex longitudinal treatments * interference and spillovers * weak or ambiguous assignment mechanisms * high-dimensional adjustment problems * missing data and censoring * transportability and external validity * designs where the estimand itself is contested ### Feedback welcome The repo ships with dummy data from a manager leadership development program evaluation - the same dataset we used at the workshops. It's a realistic observational setup (voluntary participation, uneven program uptake across departments, baseline and follow-up survey outcomes, retention flags at 3/6/9/12 months), so you can run the workflow end-to-end without your own project. Give it a try and let me know how it works for you. I would especially appreciate feedback on: * where the workflow is too rigid * where the guardrails are not strict enough * which causal designs it handles poorly * which parts of the handoff between agents are unclear * what additional diagnostics, templates, or reference materials would be useful I would also be very interested to see related efforts. If you are building agentic workflows for causal inference, statistical analysis, scientific workflows, or research automation more broadly, please share what you have tried and what has or has not worked. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-05-16-agentic-workflow-for-causal-inference/) on my blog. --- # Goals saved above expected… for managers? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/value-added-modeling Date: 2026-05-12 Tags: performance-management, people-management, statistics, causal-inference, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2026-05-12-value-added-modeling/ A hockey stat sent me down a rabbit hole this weekend 🤓 It started with goalie trivia and ended with manager evaluation. The trivia: you can't judge a goalie on save percentage alone - a goalie behind a leaky defense faces harder shots and can look worse than he is. So analysts built models that score every shot's observable difficulty - distance, angle, shot type, rebound/previous-event context, manpower, and in richer datasets, pre-shot movement - and predict the [probability it becomes a goal](https://hockey-graphs.com/2019/08/12/expected-goals-model-with-pre-shot-movement-part-1-the-model/). Sum the differences between expected and actual outcomes, and you've partially separated goalie performance from shot volume and shot quality (*[Goals Saved Above Expected](https://moneypuck.com/glossary.htm)*). Then I learned this same basic logic has been [used in education for ~30 years](https://link.springer.com/article/10.1007/BF00973726). It's called *[Value-Added Modeling](https://www.rand.org/education-employment-infrastructure/projects/measuring-teacher-effectiveness/value-added-modeling.html)*. Predict each student's expected test score from prior achievement, demographics, peers, and other available context - then estimate the teacher effect from the remaining classroom-level difference. The People Analytics application is obvious: strip out tenure, role, comp, market, etc. - and the residual may contain a manager effect. Tempting, but also dangerous. What VAM taught education the hard way:️ * Single-year estimates are noisy: year-to-year [stability for individual teachers](https://files.eric.ed.gov/fulltext/ED530401.pdf) is often around 0.2-0.5, sometimes higher depending on grade, subject, and model.️ * Three years of data materially improves [reliability](https://ies.ed.gov/use-work/resource-library/report/technical-methods-report/error-rates-measuring-teacher-and-school-performance-using-value-added-models?utm_source=chatgpt.com) - patience beats better models - but it doesn't eliminate bias.️ * When test-based metrics became high-stakes, they created predictable gaming: more test prep, narrowed instruction, and in the broader test-based-accountability era, outright cheating scandals like [Atlanta](https://kappanonline.org/saultz-murphy-aronson-what-can-we-learn-from-atlanta-cheating-scandal/)`.️ * The American Statistical Association issued a [formal caution in 2014](https://www.amstat.org/asa/files/pdfs/POL-ASAVAM-Statement.pdf) about overinterpreting VAMs for high-stakes individual decisions.️ * [Houston teachers](https://journals.sagepub.com/doi/10.3102/0013189X20923046) later won a favorable due-process ruling and settlement because the proprietary model/data behind their scores could not be meaningfully challenged. Every one of those lessons applies to manager scorecards. The model can be useful, but the governance around it is where things usually break. I can see this working for surfacing patterns, validating training programs, and allocating coaching - but going wrong at the predictable next step: using it to gate promotions or trigger PIPs on thin data, which is where many orgs will be tempted to go first, for understandable reasons. Question for the PA folks here: Are you using "above expected" style models in PA today? For what - retention, hiring, sales, something else? If you tried and walked away, what was the deal-breaker? Sample size? Politics? Legal? The metric got gamed? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-05-12-value-added-modeling/) on my blog. --- # SIOP through the wisdom of crowds: What I may have missed URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/siop-2026-reflection Date: 2026-05-06 Tags: siop, topic-analysis, decision-making Original post: https://blog-about-people-analytics.netlify.app/posts/2026-05-06-siop-2026-reflection/ After my first SIOP conference, I found myself wondering what useful content I might have missed simply because of the sessions I chose to attend. Think of it as a retrospective “lost opportunity cost” analysis 🤓 A sidenote: this could easily turn into an unhealthy FOMO exercise, but I prefer to treat it more positively as a way to spot sessions or themes I may want to catch up on later through other channels 😉 One imperfect way to look at this is to use a kind of “wisdom of crowds”-ish signal: which sessions and topics attracted more agenda interest than we would expect, given their day and time slot? For this, I used the number of times a session was added to personal agendas in Whova as a rough proxy for pre-attendance interest. Importantly, that is not the same as actual attendance, session quality, or scientific importance. So what showed up? At the topic level, the five biggest positive prediction residuals were: * *Job Analysis* * *Talent Management* * *Artificial Intelligence* * *People Analytics* * *Coaching and Mentoring* For me, the most relevant omission was probably Job Analysis, which I mostly missed in my own session choices. Given how much jobs are already changing because of AI, this may be one area where I should have followed the crowd a bit more. At the session level, the five biggest positive residuals were: * *Hot Takes, Hot Wings: A Spicy Conversation With I-O Psychology's Thought Leaders* * *Proactive People Analytics Solutions: Addressing Tomorrow's Problems Today* * *Future Storming Insights: Emerging Trends and the Future of I-O Psychology* * *How to Leverage Employee Listening Data to Effectively Drive Change* * *Binge-Worthy Leadership Development: Enhancing Learning Through Popular TV Series* These seem to combine several strong attention drivers: the promise of learning something new in a fun and original way, the possibility of creating real-world impact, and curiosity about what the future holds - an ideal recipe for sparking people’s interest, right? 🙂 Personally, I attended the two most connected to impact, but apparently I left some fun and future-looking sessions on the table. Maybe next time? Methodological note: Results are based on Whova data from the three main conference days. Topics were identified through an LLM-assisted active-learning loop using only session titles, and topics are not mutually exclusive. Poster sessions were excluded because they likely come from a different data-generating process compared with other session types. P.S. If interested, you can check out [this simple interactive dashboard](https://lstehlik2809.github.io/siop-2026-reflection/), where you can explore the topics from other perspectives and drill down to individual sessions by topic and topic combinations.
![](./siop-2026-reflection/plot.png)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-05-06-siop-2026-reflection/) on my blog. --- # No Experiment, No Problem? Causal Inference in Applied Quasi-Experimental Settings (Session ID 830) URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/siop-2026-causal-inference-workshop Date: 2026-04-18 Tags: causal-inference, siop Original post: https://blog-about-people-analytics.netlify.app/posts/2026-04-18-siop-2026-causal-inference-workshop/ Excited to share that Marissa Post and I will be co-delivering a workshop at the [2026 SIOP Annual Conference in New Orleans](https://www.siop.org/events/the-annual-conference/program/schedule/).
![](./siop-2026-causal-inference-workshop/event_pic.png)
Most causal inference material focuses on clean examples or theory in isolation. In practice, causal analysis is a workflow - and a messy one. Our workshop walks through a realistic end-to-end case study - evaluating a leadership development program with open enrollment, where there's no clean experiment to lean on. We work through the full pipeline: defining the estimand, constructing and reasoning about a causal DAG, diagnosing positivity violations, choosing an adjustment strategy under real data constraints, and estimating treatment effects using IPTW, GEE, survival analysis, and Double Machine Learning, if time allows. We also cover sensitivity analysis (E-values) and how to communicate uncertain findings honestly to stakeholders. The setting is deliberately realistic - voluntary program enrollment, no randomization, self-report bias, and unmeasured confounders you can't wish away. All hands-on in an interactive Jupyter notebook. If you're at SIOP and interested in making causal thinking more practical in applied People Analytics settings - come say hi 🤓 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-04-18-siop-2026-causal-inference-workshop/) on my blog. --- # SIOP 2026 Recommender URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/siop-2026-recommender Date: 2026-04-18 Tags: siop, machine-learning Original post: https://blog-about-people-analytics.netlify.app/posts/2026-04-18-siop-2026-recommender/ Attending SIOP for the first time this year confronted me with something that more seasoned attendees are probably already used to and may even have a few tricks for handling. I’m talking about the need to sift through a huge number of interesting sessions packed into 3–4 days of the conference and choose the ones that best match my interests and needs. I’ll admit it freely: I was somewhat paralyzed by the sheer volume of options. The filtering options on the website did not help much either. Scrolling through everything was still endless and pretty mentally exhausting. I needed help narrowing things down. As you can probably guess, AI came to the rescue 🤖 During our last Friday afternoon dedicated to exploration and experimentation, and over part of the weekend, I managed to build and deploy an app designed to help with exactly that. What does it do?️ * In the Profile section, you describe what you want from the conference: your goals, keywords, what to avoid, and optionally your CV. You can also include skills you want to develop or acquire.️ * You can adjust how strongly each of these inputs influences the search.️ * You get a ranked list of sessions, with schedule conflicts clearly marked.️ * You get an optimized personal agenda that avoids overlapping sessions.️ * As a bonus, you can explore an interactive SNA-style map of session similarity, with your top recommendations highlighted. Give it a try and let me know whether it makes your SIOP planning at least a little bit easier. In my case, it helped a lot 🤓 You can access it [here](https://siop-recommender-48217563872.europe-west1.run.app/), or for now, just check the screenshots below (double-click any slide to zoom in). ⚠️ As is often the case with AI, treat its recommendations more as inspiration than as definitive guidance, and verify them against a reliable source such as the official SIOP schedule. The app works only with session titles, not abstracts; errors may have occurred during information extraction; and the embedding model used does not capture every semantic nuance. So there is plenty of room for mistakes. **Update**: Now that SIOP 2026 has wrapped, the Recommender can officially retire. RIP, SIOP 2026 Recommender. You served us well 🫡 Thanks to everyone who tried it - it was fun building this. ```r library(pdftools) library(swipeR) library(htmltools) pdf_file <- "siop_2026_recommender_screenshots.pdf" img_dir <- "slides_img" if (!dir.exists(img_dir)) dir.create(img_dir) n <- pdf_info(pdf_file)$pages files <- pdf_convert( pdf_file, format = "png", dpi = 150, filenames = sprintf("%s/page-%02d.png", img_dir, seq_len(n)), verbose = FALSE ) wrapper <- do.call( swipeRwrapper, lapply(files, function(x) { htmltools::tags$div( class = "swiper-zoom-container", htmltools::tags$img( src = x, style = "width:100%; height:auto; display:block; margin:0 auto;" ) ) }) ) htmltools::div( style = "max-width: 700px; margin: 0 auto;", swipeR( wrapper = wrapper, width = "100%", height = "800px", zoom = TRUE, navigationColor = "black", paginationColor = "black" ) ) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-04-18-siop-2026-recommender/) on my blog. --- # What a European cigarette tax study taught me about employee listening URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/did-with-repeated-cross-sectional-data Date: 2026-04-15 Tags: causal-inference, panel-data, employee-survey, difference-in-differences, double-machine-learning Original post: https://blog-about-people-analytics.netlify.app/posts/2026-04-15-did-with-repeated-cross-sectional-data/ Recently, a LinkedIn recommendation algo brought a [working paper](https://arxiv.org/abs/2604.05841) by Stoller and Huber (April 2026) to my feed. It's a Difference-in-Differences study estimating how cigarette tax increases affected smoking rates across 27 EU countries between 2012 and 2020. The topic was outside my day job at Sanofi. The methodology wasn't. By the second read, I was redrawing how we should think about impact evaluation in our employee listening architecture. Here's why a tobacco policy paper belongs on the reading list of I/O psychologists working with modern pulse survey data. ## The paper, and the part that matters for us Stoller and Huber use Eurobarometer survey waves (repeated cross-sections, meaning *different* individuals in each wave) and compare smoking rates in countries that experienced a large cigarette tax increase against countries with stable taxes. They find tax increases reduced smoking by roughly 3.4 percentage points, concentrated in the 15–24 age group. The substantive finding isn't the point. The methodological anchor is: they use an estimator proposed by [Zimmert (2020)](https://arxiv.org/abs/1809.01643), called **DiDDML** (Difference-in-Differences with Double Machine Learning), built specifically for repeated cross-sections. It combines: - A DiD identification strategy (treated units vs. control units, pre- vs. post-period) - Machine learning (random forests, lasso, or gradient boosting) to model confounding relationships flexibly - Cross-fitting to avoid overfitting - A doubly robust, Neyman-orthogonal score function that tolerates moderate errors in the ML step In one sentence: it's a causal estimator purpose-built for data where you can't follow the same people over time, but you do have covariates, unit-level group structure, and time. ## Why this speaks to our listening architecture Many organizations, ours included, are shifting to: - **One larger annual census** (broad, deep, benchmark-quality) - **Smaller quarterly pulses** (faster, more focused) - **Random, partially overlapping samples** in the pulses, often anonymous For I/O psychologists, the trade-offs are familiar. We lose individual-level longitudinal tracking. No more cross-lagged panel models on individuals. No more within-person growth trajectories on engagement. What we gain is cadence, lower respondent burden, and honest anonymous responding. What I hadn't appreciated until reading this paper: **our new pulse architecture is structurally identical to the Eurobarometer data.** Repeated cross-sections with stable unit identifiers (teams, business units, countries), a covariate set, and time. That means there's an off-the-shelf estimator designed for exactly our data structure, with a maintained open-source implementation ([`causalweight::didDML`](https://cran.r-project.org/package=causalweight) in R, plus related methods in Python via [`DoubleML`](https://docs.doubleml.org/) and [`EconML`](https://github.com/py-why/EconML)). That reframes what impact evaluation can look like for us. ## What counts as an "event" Anyone in people analytics faces the same question constantly: *did that thing we did work?* With a DiD design, credible answers become possible whenever an event hits some units at a known time and leaves others unaffected: - **Reorganizations** that roll out unevenly across functions or regions - **Return-to-office or hybrid policy changes** applied differently across geographies - **New manager transitions** in some teams but not others - **Compensation or benefits changes** affecting specific job families - **Leadership changes** in certain functions or BUs - **Wellbeing or DEI program rollouts** staggered across the organization - **Layoff announcements** in specific units (the impact we most want to measure and least want to confront) - **Performance management system pilots** introduced in a subset of units before being scaled The common thread: a discrete event, a known date, treated units, and untreated units. That's a natural experiment, and it's exactly the structure DiD (including modern variants that handle staggered rollouts) is built for. ## Three lessons I'm carrying over from the paper ### 1. Binary treatments beat continuous ones for discrete events The paper's most striking internal finding has nothing to do with tobacco. When they compare a binary treatment specification ("did a large tax increase occur, yes or no") against a continuous specification ("what's the absolute tax share"), **the continuous version attenuates the estimated effect by more than half on the same data.** The linearity assumption (that each marginal unit of treatment has the same effect across the entire range) is rarely defensible for real-world events. For us, this is liberating. An RTO mandate is binary for a given unit. A new manager either started or didn't. A reorg either landed or didn't. Stop trying to scale treatments into continuous regressors. Define the event as binary, define pre- and post-periods, define treated and control units, and run the analysis on a restricted sample. ### 2. Let ML handle the confounders In I/O practice, we typically control for confounders by adding them as linear terms to a regression. That works if we're confident the relationships are linear, additive, and correctly specified. For age, tenure, level, span of control, and most categorical HR variables, we're often wrong. We just can't see the misspecification bias in the output. Double machine learning lets flexible learners estimate the nuisance relationships, then uses a doubly robust correction to produce the causal estimate. The Neyman orthogonality property means moderate errors in the ML step don't propagate to the treatment effect estimate. I know "throw random forests at it" triggers reasonable skepticism in I/O audiences, and it should. The discipline here isn't black-box prediction. It's a specific statistical structure (cross-fitting plus orthogonal scores) that delivers valid inference even with the ML step. It doesn't replace theory-driven modeling of the outcome. It replaces hand-coded functional form assumptions we couldn't justify anyway. ### 3. Use placebo tests when parallel trends can't be tested classically Parallel trends (the assumption that treated and control units would have moved in parallel absent the event) is the load-bearing assumption of any DiD. With only one or two pre-periods (true for most quarterly pulses), we can't test it the standard way. The paper adapts a technique from [Callison and Kaestner (2014)](https://doi.org/10.1111/ecin.12027): **assign placebo treatments to random control units, estimate the effect, and check that placebo effects cluster around zero.** This is a discipline worth building into every internal causal claim as a mandatory robustness check. ## What I/O psychology has to add Econometricians don't talk about the things we lose sleep over. If we're going to use DiDDML responsibly in employee listening, four I/O concerns have to be built in up front. **Measurement invariance across waves.** If we change items, response scales, or item ordering between waves, we don't have the same construct over time, and no estimator can rescue us. Configural and metric invariance testing is a prerequisite for longitudinal claims. That said, the temptation to demand full scalar invariance before any analysis is a form of perfectionism that leads to paralysis. Document changes, treat drift as a source of uncertainty, and be explicit about which outcomes are genuinely comparable versus which ones need caveats. **Multilevel structure.** Employees nest in teams in BUs in countries. DiDDML clusters standard errors at the treatment assignment level, but it doesn't otherwise model the hierarchy. If treatment is assigned at the team level, your effective sample size is teams, not respondents. This has brutal implications for power. A pulse with 5,000 respondents across 20 treated teams is not a study with n = 5,000. **Psychological confounds bundled with the event.** A reorg plus poor leader communication looks identical in survey data to a reorg alone. Be specific about what you're claiming the "treatment" is. Most of the time it's a bundle: the event *plus* its communication *plus* its change management. Either own that framing or design the evaluation to separate them (rarely feasible). **Selection into response.** Anonymous pulses protect honesty but create selection. If an event changes who responds (say, engaged employees tune out while disgruntled ones respond more), the pre-to-post shift partly reflects sample composition rather than construct change. Response propensity modeling, non-response weighting, and sensitivity analyses should be standard, not optional. ## What the pipeline needs Three things have to be in place before the first event evaluation can be credible: - **Stable unit identifiers in every wave:** team, BU, country, function. Treatment is defined at the unit, and respondents inherit assignment. This design is ideal for anonymous surveys. - **A standing covariate set:** same demographics, tenure bands, level, function captured every wave. If the questionnaire reshapes each pulse, the confounding controls go with it. - **A structured event log:** what happened, when, to which units. Captured as events occur, not reconstructed from Slack six months later. ## Honest caveats **Endogeneity.** If an event is triggered by the outcome (a reorg in response to low engagement), the design breaks. Many of our most interesting events are endogenous like this. **Spillovers.** In a connected organization, employees in "control" units talk to employees in "treated" units, read the same Slack channels, attend the same town halls. The sharp treatment/control boundary DiD assumes is a convenient fiction. The bias is usually toward zero, but not always. **Power.** Unit-level treatment assignment collapses effective sample size. For anything below country-level treatment, run the power calculation *before* fielding the pulse, not afterward when you're rationalizing a null. **Multiple testing.** Every event × every outcome × every subgroup inflates the false positive rate. Benjamini-Hochberg adjustments, as the paper uses, aren't a nice-to-have. ## The reframe The shift to pulse-based listening can feel like a methodological downgrade because we lose the individual panel. For some questions, it is. But for evaluating the effects of unit-level events, it gives us data whose structure matches a modern causal inference toolkit better than many of us might assume. Most I/O curricula still barely touch it. The paper that pointed me there happened to be about cigarettes 😅 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-04-15-did-with-repeated-cross-sectional-data/) on my blog. --- # Mapping the People Analytics universe URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/david-green-hr-people-sna-network Date: 2026-03-29 Tags: network-analysis, people-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-29-david-green-hr-people-sna-network/ Being featured in [David Green](https://www.linkedin.com/in/davidrgreen)'s "Best HR & People Analytics Articles" newsletter inspired me to put together a quick-and-dirty SNA based on the co-occurrence of people mentioned across all 60 editions, from April 2021 to March 2026. IMO, it nicely illustrates David's ongoing and successful effort to bring together a remarkably wide and diverse pool of People Analytics professionals from around the globe - and yes, with some "heavyweights" sitting right in the middle of the network, but that's how real-world networks work, right? 😉 If you'd like to explore it yourself, [here it is](https://lstehlik2809.github.io/david-green-hr-people-network/).
![](./david-green-hr-people-sna-network/ona_david_green.png)
A few tips for navigating the network: * Nodes = people mentioned in articles; node size & color reflect total appearances across 60 articles * Edges = co-occurrence in the same monthly article; edge thickness = number of months co-occurring * Use the slider to adjust the minimum appearances threshold * Hover over nodes/edges for details; click a node to highlight its connections * Scroll to zoom in/out; use the search box (top right) to find a specific person P.S. You can consider this a prequel to the People Analytics Network Census (PANC), which is about to wrap up its second wave. If you haven't participated yet, sign up [here](https://lnkd.in/dSVT34sf) 🙂 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-29-david-green-hr-people-sna-network/) on my blog. --- # Do we give more "Try This", "Knock It Off", or "Keep It Up" recommendations in 360-degree feedback? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/start-stop-continue-feedback Date: 2026-03-26 Tags: feedback, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-26-start-stop-continue-feedback/ At the end of the year before last, in the context of New Year's resolutions, [I posted about finding](https://blog-about-people-analytics.netlify.app/posts/2024-12-30-new-years-resolution/) that people most often search Google for ways to stop doing things, followed by how to start doing things, and least often, how to continue doing things, and also speculated about the possible reasons behind this pattern. I was curious if we'd find a similar or different pattern in what people write to others in 360-degree feedback contexts. I used a real-world dataset from various 360 feedback projects across several CEE countries, involving ~15K assessors, to compare how frequently they wrote informative comments in the START, STOP, and CONTINUE sections (i.e., excluding non-informative phrases such as “nothing” and “I don’t know”), as well as the length of informative comments in completed sections. Interestingly - but perhaps not so surprisingly - the pattern I found was completely reversed from what we can see on Google Trends: people most often told others what they should keep doing (90.2%), then what they should start doing (65%), and least often what they should stop doing (37.8%).
![](./start-stop-continue-feedback/response_rate_chart.png)
When it comes to comment length, the comments are largely similar on average; only those in the Start section are slightly longer than the other two (median of 14 vs. 11 words).
![](./start-stop-continue-feedback/word_count_raincloud.png)
I can only speculate about the reasons behind this, but my guesses would probably include social politeness and relationship preservation motivation, psychological safety concerns (even in anonymous 360 systems), cultural conditioning around strengths-based development, discomfort with direct criticism, possibly also the order effect of question presentation, and new behaviors requiring more explanation and context. ❓What would be your guesses? And have you run a similar analysis yourself with similar/different results? P.S. A recent critical systematic review of the performance feedback literature by [Heine et al. (2026)](https://www.researchgate.net/publication/396923591_Performance_Feedback_A_Critical_Systematic_Review) suggests that this pattern may be actually quite adaptive: positive feedback appears to be more consistently beneficial overall, whereas negative feedback is more context-dependent and more likely to backfire, especially when trust, credibility, and psychological safety are low. Moreover, telling people what to start doing next may be a safer way of “fixing” behavior, as it is less likely to trigger internal resistance than telling them what not to do. So maybe the assessors know what they’re doing 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-26-start-stop-continue-feedback/) on my blog. --- # Beyond the “flat Earth” URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/mixed-level-ml Date: 2026-03-23 Tags: machine-learning, multilevel-modeling, causal-inference, predictive-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-23-mixed-level-ml/ I have to reluctantly admit that, until recently, my approach to predictive machine learning (ML) tasks relied on one major simplification: I ignored the hierarchical structure in my data and treated observations as [i.i.d.](https://en.wikipedia.org/wiki/Independent_and_identically_distributed_random_variables) Part of that was convenience, and part of it was the practical belief that standard ML often captures most of the predictive signal while being much simpler to implement than a full mixed-effects model. But once I started using ML not just for prediction, but also for causal inference, the limits of that “flat-earth” view became hard to ignore. In People Analytics, data is usually nested: individuals within teams, teams within departments, and sometimes departments within business units. Ignoring that structure means ignoring a big part of the context that shapes human behavior. ## Grouped Cross-Validation The first requirement for working with clustered data in this context is recognizing that standard cross-validation (CV) can be misleading when observations within a cluster are correlated. If members of the same team appear in both the training and test sets, the model can effectively “peek” at the test distribution through shared group structure. That usually leads to performance estimates that are too optimistic. The first practical fix is to move from standard K-fold CV to `GroupKFold`. This keeps entire clusters together, so the model is evaluated on genuinely unseen groups. **Advantage:** Grouped CV does not improve the model itself, but it gives you a much more honest estimate of whether the model will hold up on new teams or departments. In other words, it is less about boosting performance and more about avoiding self-deception. ### Example: GroupKFold + XGBoost ```python # Synthetic data generator for the GroupKFold + XGBoost example import numpy as np import pandas as pd def make_grouped_prediction_data( n_teams=40, min_team_size=15, max_team_size=30, seed=42, ): rng = np.random.default_rng(seed) team_sizes = rng.integers(min_team_size, max_team_size + 1, size=n_teams) team_id = np.repeat(np.arange(n_teams), team_sizes) n = len(team_id) # Create team-level latent effects so observations within the same # team are meaningfully correlated. team_baseline = rng.normal(0.0, 1.5, size=n_teams) team_skill = rng.normal(0.0, 1.0, size=n_teams) # Generate individual-level predictors with partial dependence on team. tenure = rng.gamma(shape=2.5, scale=2.0, size=n) + 0.7 * team_skill[team_id] base_salary = rng.normal( loc=60000 + 5000 * team_skill[team_id], scale=7000, size=n, ) engagement = rng.normal(loc=team_skill[team_id], scale=1.0, size=n) workload = rng.normal(loc=0.0, scale=1.0, size=n) # Build a nonlinear target that combines individual predictors, # team-specific effects, and random noise. y = ( 1.2 * np.sin(engagement) + 0.35 * np.log1p(np.maximum(tenure, 0)) + 0.00003 * base_salary - 0.5 * workload + team_baseline[team_id] + rng.normal(0.0, 1.0, size=n) ) df = pd.DataFrame( { "team_id": team_id, "tenure": tenure, "base_salary": base_salary, "engagement": engagement, "workload": workload, "performance": y, } ) X = df[["tenure", "base_salary", "engagement", "workload"]] y = df["performance"].to_numpy() return df, X, y # Run grouped cross-validation so that entire teams are held out together. # This avoids leakage that would occur if rows from the same team appeared # in both training and validation folds. from xgboost import XGBRegressor from sklearn.model_selection import GroupKFold, cross_val_score df, X, y = make_grouped_prediction_data() groups = df["team_id"] model = XGBRegressor( n_estimators=100, learning_rate=0.05, max_depth=4, subsample=0.9, colsample_bytree=0.9, random_state=42, ) cv = GroupKFold(n_splits=5) scores = cross_val_score(model, X, y, groups=groups, cv=cv) print(f"Grouped-CV R^2: {scores.mean():.3f}") print("Fold scores:", np.round(scores, 3)) ``` One important caveat: this is the right validation strategy if your real deployment target is **new groups**. If the model will be used within already-seen teams, the validation design may need to reflect that instead. ## Mixed-Effects ML Grouped CV fixes the evaluation problem, but it does not change the model itself. For some problems, especially when group-level heterogeneity matters, it can be useful to fit models that explicitly separate two sources of signal: * **Fixed effects**: global patterns learned from predictors * **Random effects**: group-specific deviations, such as team-level intercepts Libraries such as `GPBoost` or `MERF` let you combine flexible ML models with random effects using a structure like: $$ y = F(X) + Zb + \epsilon $$ Here, \(F(X)\) is a nonlinear prediction function, such as a random forest or gradient-boosted tree, and \(Zb\) represents group-level random effects. **Advantage:** Mixed-effects ML can improve performance when groups have persistent baseline differences, because it lets the model learn both global patterns and group-level structure. It also gives you a more interpretable decomposition of what is happening at the individual level versus the team or department level. ### Example: Mixed Effects Random Forest (MERF) ```python # Synthetic data generator for the MERF example import numpy as np import pandas as pd def make_merf_data( n_teams=30, min_team_size=20, max_team_size=35, seed=42, ): rng = np.random.default_rng(seed) team_sizes = rng.integers(min_team_size, max_team_size + 1, size=n_teams) team_id = np.repeat(np.arange(n_teams), team_sizes) n = len(team_id) # Assign a random intercept to each team to introduce cluster-specific # variation that the mixed-effects model should recover. b_team = rng.normal(0.0, 2.0, size=n_teams) # Generate fixed-effect predictors. x1 = rng.normal(0, 1, size=n) x2 = rng.normal(0, 1, size=n) x3 = rng.normal(0, 1, size=n) # Define a nonlinear fixed-effect signal and combine it with the # team-level random intercept and observation noise. f_x = 2.0 * x1 + x2**2 + 4.0 * (x3 > 0).astype(float) + 1.5 * np.log1p(np.abs(x1)) * x3 y = f_x + b_team[team_id] + rng.normal(0.0, 1.0, size=n) df = pd.DataFrame( { "team_id": team_id, "x1": x1, "x2": x2, "x3": x3, "performance": y, } ) X = df[["x1", "x2", "x3"]].to_numpy() Z = np.ones((n, 1)) # Random-intercept design matrix. clusters = df["team_id"] y = df["performance"].to_numpy() return df, X, Z, clusters, y # Fit MERF with a random forest as the fixed-effects learner. # This example shows how to combine nonlinear prediction with # cluster-specific random intercepts. from merf import MERF from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import r2_score df, X, Z, clusters, y = make_merf_data() rf_engine = RandomForestRegressor(n_estimators=100, random_state=42) merf_model = MERF(fixed_effects_model=rf_engine, max_iterations=20) merf_model.fit(X, Z, clusters=clusters, y=y) y_hat = merf_model.predict(X, Z, clusters=clusters) print(f"In-sample R^2: {r2_score(y, y_hat):.3f}") # Inspect the estimated random effects over iterations using a portable, # dataframe-based summary. print(merf_model.get_bhat_history_df().tail()) ``` ## Cluster-Aware Causal ML The hardest problem in People Analytics is usually causal: for example, estimating the effect of a leadership intervention. Standard ML is not enough here. It must be harnessed in a specialized way, as in the Double Machine Learning (DML) framework, for example. DML helps by residualizing both the treatment (\(T\)) and the outcome (\(Y\)) using flexible nuisance models. However, with hierarchical data, those nuisance models and the sample-splitting strategy should respect the cluster structure. The final inference step should also account for within-cluster dependence so that standard errors and p-values are better calibrated. What cluster-aware causal ML gives you is better inference under clustered dependence. What it does not do is magically eliminate bias from unobserved cluster-level confounding. That still depends on whether your identification assumptions are credible. **Advantage:** Cluster-aware causal ML does not make causal identification easy, but it does make ML-based causal estimation more defensible when your data are nested and observations within groups are dependent. ### Example: Cluster-Aware DML ```python # Synthetic data generator for the cluster-aware DoubleML example import numpy as np import pandas as pd def make_clustered_dml_data( n_teams=80, min_team_size=10, max_team_size=20, true_theta=1.5, seed=42, ): rng = np.random.default_rng(seed) team_sizes = rng.integers(min_team_size, max_team_size + 1, size=n_teams) team_id = np.repeat(np.arange(n_teams), team_sizes) n = len(team_id) # Create an observed team-level covariate that influences both # treatment assignment and outcomes. manager_quality = rng.normal(0.0, 1.0, size=n_teams) # Generate individual-level covariates. tenure = rng.gamma(shape=2.5, scale=2.0, size=n) base_salary = rng.normal( loc=65000 + 4000 * manager_quality[team_id], scale=8000, size=n, ) potential = rng.normal(0.0, 1.0, size=n) tenure_z = (tenure - tenure.mean()) / tenure.std() salary_z = (base_salary - base_salary.mean()) / base_salary.std() manager_q = manager_quality[team_id] # Simulate treatment assignment from observed covariates so the setup # resembles a realistic observational treatment mechanism. logits = 0.8 * manager_q + 0.5 * tenure_z - 0.3 * salary_z + 0.6 * potential p = 1.0 / (1.0 + np.exp(-logits)) intervention_treated = rng.binomial(1, p, size=n) # Add team-level shocks to the outcome to create within-cluster # dependence without introducing hidden confounding by design. team_error = rng.normal(0.0, 1.0, size=n_teams) performance = ( true_theta * intervention_treated + 0.6 * np.log1p(tenure) - 0.25 * salary_z + 0.7 * manager_q + 0.5 * potential + team_error[team_id] + rng.normal(0.0, 1.0, size=n) ) df = pd.DataFrame( { "team_id": team_id, "performance": performance, "intervention_treated": intervention_treated, "tenure": tenure, "base_salary": base_salary, "potential": potential, "manager_quality": manager_q, } ) return df, true_theta # Fit a partially linear DoubleML model with cluster-aware inference. # The goal is to recover the treatment effect while respecting the # grouped structure in the data. from doubleml import DoubleMLData, DoubleMLPLR from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier df, true_theta = make_clustered_dml_data() obj_dml_data = DoubleMLData( df, y_col="performance", d_cols="intervention_treated", x_cols=["tenure", "base_salary", "potential", "manager_quality"], cluster_cols="team_id", ) ml_l = RandomForestRegressor(max_depth=5, n_estimators=200, random_state=42) ml_m = RandomForestClassifier(max_depth=5, n_estimators=200, random_state=42) dml_plr = DoubleMLPLR(obj_dml_data, ml_l, ml_m) dml_plr.fit() print(f"True treatment effect: {true_theta:.4f}") print(f"Estimated treatment effect: {dml_plr.coef[0]:.4f}") print(f"SE: {dml_plr.se[0]:.4f}") print(f"t-stat: {dml_plr.t_stat[0]:.4f}") print(f"P-value: {dml_plr.pval[0]:.4f}") print(dml_plr.summary) ``` ## My Key Takeway For me, the progression now looks something like this: * If I just want a more realistic estimate of predictive performance across teams, I start with **grouped cross-validation**. * If I want the model itself to reflect stable group-level differences, I look at **mixed-effects ML**. * If I care about causal effects and the data are nested, I consider **cluster-aware causal ML**. That is not because hierarchy always makes models better. It is because, in many real-world settings, hierarchy is part of the data-generating process, and pretending otherwise usually comes back to bite you. ❓Curious what your usual approach is to ML tasks with hierarchical data. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-23-mixed-level-ml/) on my blog. --- # The statistical "sin" as best / common practice? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ols-vs-logistic-regression Date: 2026-03-19 Tags: regression-analysis, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-19-ols-vs-logistic-regression/ In psychology, few things are drilled into you harder than: "Binary outcome? Use logistic regression. Never OLS. Never ever." I carried this rule for years like a sacred commandment. Then I started working on causal inference - evaluating program effects, matching, treatment estimates - and discovered that the entire applied causal inference field happily runs OLS on binary outcomes under the fancy name: *Linear Probability Model*. At first it felt like watching someone pour red wine into a coffee mug. Technically functional, deeply unsettling. But here's why it actually may make sense in this context:️ * You're not predicting, you're estimating. When you care about one number - "how much did this program change the probability of outcome X" - OLS gives you that directly as a coefficient. Logit gives you a log-odds coefficient that you then need to transform into a probability difference via post-estimation (average marginal effects). In many practical settings, both give you comparable answers - but OLS skips the extra step. *️ It doesn't blow up so easily. With small samples, many categorical controls, and sparse cells, logit loves to produce infinite coefficients, NaNs, and complete separation drama.️ * The coefficient is human-readable. "Participants were 3 percentage points more likely to move internally" vs "the log-odds ratio was 0.47." One of these gets a nod in a stakeholder meeting. The other gets a blank stare.️ * It's not just some hack. Angrist & Pischke basically made it the default in Mostly Harmless Econometrics. Imbens & Rubin cover it. The entire economics causal inference tradition uses it routinely. You add robust standard errors (HC1) for simple designs - with matching or clustering, you may need to adjust the variance estimator further.️ * The limits are real - and worth knowing. LPM can predict probabilities below 0 or above 1. If you're building a risk scoring model, that matters. If you're estimating an average effect from a matched sample, it usually doesn't - but one shouldn't stop there. When baseline rates are very high or very low, when you're estimating interaction effects, or when covariate relationships are strongly nonlinear, LPM and logit can diverge meaningfully. Out-of-bounds predictions are your canary: if you're seeing them, check whether the linear approximation is actually holding up. The point is to know when it breaks.
![](./ols-vs-logistic-regression/lpm_vs_logit.png)
The deeper lesson for me: the real question isn't "OLS vs logit." It's "what estimand do you want?" Risk difference → LPM is often the natural fit. Odds ratio → logistic regression. Risk ratio → log-binomial or modified Poisson. The tool follows the question, not the other way around. Seems that sometimes sin is the way 🙃 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-19-ols-vs-logistic-regression/) on my blog. --- # The hidden skill map - where life meets work URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/daily-activities-among-business-skills Date: 2026-03-18 Tags: skills, time-management, embeddings Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-18-daily-activities-among-business-skills/ While stress-testing a team skills app, just for fun I tried searching in the semantic space of existing business skills for everyday activities like parenting and cooking. Check out where these two landed in the attached pics. Does it resonate? 🙃 For me it’s pretty accurate 😅
![](./daily-activities-among-business-skills/parenting.png)
![](./daily-activities-among-business-skills/cooking.png)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-18-daily-activities-among-business-skills/) on my blog. --- # Encouragement Design using instrumental variables URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/encouragement-design-and-ivs Date: 2026-03-12 Tags: causal-inference, experiments Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-12-encouragement-design-and-ivs/ This post can be seen as a follow-up to the [article](https://blog-about-people-analytics.netlify.app/posts/2025-05-21-causal-inference-in-people-analytics/) Cole Napper and I wrote on the use of causal inference methods in the People Analytics space. One of its sections discusses instrumental variables (IV) as a very useful and powerful - but also often very hard to find - method for estimating causal effects. For those less familiar with IV, here's the super-short version: instead of directly comparing participants with non-participants (which is usually biased), we look for a variable that shifts the probability of participating while being otherwise unrelated to the outcome. That variable then gives us a cleaner estimate of the causal effect - typically for the subgroup of people whose behavior was actually changed by that "push." While reading a paper on using IV to estimate causal effects in situations where not everyone assigned to the treatment group actually complies with the assigned program, it hit me (yes, I know I'm late to the party - nobody's perfect 😊) that treating assignment not as treatment itself, but merely as encouragement, can be relatively easily utilized in the People Analytics world. Rather than randomly granting or denying access to a program - which would often be neither practical nor especially ethical in an HR context - we randomly select a subset of eligible employees to receive a targeted email (or another type of nudge) encouraging them to participate. The remaining eligible employees still have full access to the program; they just do not receive that specific encouragement.
![](./encouragement-design-and-ivs/iv_encouragement_design_diagram.png)
By comparing outcomes between the encouraged and non-encouraged groups, and using random encouragement assignment as an instrumental variable for actual participation, we can estimate the causal effect of the program for those employees whose participation was actually driven by the encouragement. This convenience doesn't come without a price, though. We scale down from estimating the *Average Treatment Effect* (ATE) across the entire population to a *Local Average Treatment Effect* (LATE), also known as the *Complier Average Causal Effect* (CACE) - the effect specifically on those who participated because of the encouragement, not on everyone. Moreover, the validity of the LATE estimate rests on several assumptions that shouldn't be taken for granted. First, the exclusion restriction: the instrument (in our case, the encouraging email) must affect outcomes only through its influence on participation, not through any other channel. In practice, this means the email itself shouldn't, for example, boost employee morale or signal managerial support in a way that independently shifts the outcome we're measuring. Second, monotonicity - or the "no defiers" assumption: the nudge shouldn't cause anyone to do the opposite of what's intended. In an HR context, this is worth thinking about seriously - if some employees see a corporate nudge and reflexively disengage, the estimate breaks down. And third, relevance: the instrument has to actually move the needle on participation. If the nudge barely changes sign-up rates, you're dealing with a weak instrument, and your estimates become extremely noisy and unreliable. Still, even with these caveats, we preserve voluntary participation, avoid withholding opportunities from anyone, and get evidence that is much more credible than simple before–after comparisons or naive participant-versus-non-participant analyses. If you want a good sense of how an encouragement design can be set up in a real-world business setting, check out, for example, the [paper by Castro et al. (2022)](https://www.researchgate.net/publication/361765374_Fostering_Psychological_Safety_in_Teams_Evidence_from_an_RCT). They report a randomized field experiment in a large global healthcare company with 1,000+ teams and 7,000+ employees, where managers were randomly encouraged to hold specific types of 1:1 conversations to improve psychological safety. Note that their primary analysis uses an *intent-to-treat* (ITT) framework rather than IV/LATE, but the experimental structure itself is a great illustration of how randomized encouragement can be deployed at scale in a corporate environment.* ❓ Curious whether anyone in my People Analytics network has been using encouragement designs (or similar IV-based approaches) when tackling causal estimation - I'd love to hear how it went in practice. ---- \* **A note on ITT vs. IV/LATE**: Both approaches answer different causal questions using the same encouragement design setup. ITT asks what the causal effect of being encouraged is, regardless of whether the person actually participated - you simply compare average outcomes between the encouraged and non-encouraged groups. Because assignment is random, this comparison is unbiased with no assumptions beyond randomization needed, but it comes at a cost: if only 30% of the encouraged group actually participates, you're averaging a real treatment effect for those 30% with a zero effect for the 70% who ignored the nudge, yielding a clean but diluted estimate. IV/LATE, by contrast, asks what the causal effect of actually participating is for the people whose participation was changed by the encouragement (the compliers) - it essentially takes the ITT estimate and scales it up by the compliance rate (the Wald estimator: ITT effect divided by the first-stage effect on participation), giving you an undiluted effect, but only for compliers, and only if all three assumptions hold (exclusion restriction, monotonicity, and relevance). The tradeoff in a sentence: ITT is less informative but basically bulletproof; IV/LATE is more informative but fragile. ---- **Update**: A reader of the LinkedIn version of this post asked an interesting question, and I thought the answer might be useful to a wider audience, so I’m adding it here. The question was about the pros and cons of using a nudging/encouragement approach versus difference-in-differences or propensity score matching to address selection bias. And here’s how I responded: The question was about the pros/cons of the nudging approach versus a difference in differences or propensity score matching based approach in addressing the selection bias. And here is my reply: In short, these aren't really competing methods - they address confounding / selection bias under different assumptions, so it's about which assumptions you are most comfortable defending in your specific context. Encouragement design (IV) is the strongest of the three because you create exogenous variation, but it requires upfront experimental design and gives you a LATE, not an ATE. DiD is your best retrospective option when you have panel data and a credible parallel trends story - works well with staggered rollouts, breaks down when early adopters are systematically different in trajectory, which is, imo, pretty common in HR world (e.g., more progressive managers adopt earlier - and their teams were already improving). PSM only adjusts for observed confounders, and in PA, unobserved confounders (motivation, manager quality, career anxiety) are almost always in play - making it the easiest to implement but the weakest for causal claims. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-12-encouragement-design-and-ivs/) on my blog. --- # AI as friction URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ai-as-friction Date: 2026-03-10 Tags: ai, learning-and-development, behavioral-science, time-management Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-10-ai-as-friction/ When we think about using AI, we often think about how to make things easier for us. But maybe we’re missing the use cases where AI can help us introduce some meaningful friction into our lives. We know this well from other domains: * Think about learning, where desirable difficulties lead to deeper thinking, stronger encoding, and ultimately better acquisition of new skills and knowledge. * Think of the classic credit card frozen in a block of ice - helping us not act on hot emotions and preventing impulsive spending. * Or think of constraints forcing us to come up with more creative solutions. This clicked for me recently while participating in design workshops on reducing inefficient meetings. Many of the proposed solutions were tech/AI-oriented, and they fell neatly into two categories: enablers and something we could call 'Cognitive Speedbumps' - AI designed to break our autopilot and “lazy” defaults. Imagine an AI scheduling assistant that actively refuses to book a 60-minute meeting unless you provide a clear purpose and agenda and tag the specific decision-makers. It adds friction to the scheduler, but saves hours for the attendees downstream. Curious whether you’ve come across examples like this - or maybe you’re already using AI as friction in your own life? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-10-ai-as-friction/) on my blog. --- # Pay gap estimation for small worker categories URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/euptd-pay-gap-reporting Date: 2026-03-01 Tags: gender-pay-gap, multilevel-modeling, bayesian-statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2026-03-01-euptd-pay-gap-reporting/ How are you planning to handle the statistical analysis for EUPTD pay gap reporting - especially for small worker categories? The directive requires reporting by category of workers. Some of those groups will inevitably be thin (5–30 people). A standard OLS regression $$log(pay) ~ gender + controls$$ on a group that small gives you a gender coefficient you can barely trust. I've been thinking about partial pooling - hierarchical models that borrow strength across categories. Small groups get pulled toward the overall estimate; large groups keep their own. It's a well-established technique, but I haven't seen much discussion of it in the pay equity space.
![](./euptd-pay-gap-reporting/illustrative_chart.png)
*An illustration of how shrinkage works in hierarchical modeling across three pooling strategies. No-pooling treats each group independently, producing the highest variance in estimates. Partial pooling shrinks group-level estimates toward the global mean—balancing group-specific signal with overall trends. Complete pooling collapses all groups to a single estimate, eliminating between-group variance entirely. The vertical axis shows the estimated μ for each group; the horizontal axis compares the three approaches.* The interesting tension: shrinkage could pull a group's gap below the 5% JPA trigger threshold. Is that a feature (better estimation) or a bug (masking real gaps)? 🤔 Also curious whether anyone is considering Bayesian approaches. Getting a direct probability that the gap exceeds 5% feels much more useful than a binary significant/not-significant answer from frequentist regression. Would love to hear: * What methodology you're planning * How you're handling categories with few employees * Whether you're distinguishing between internal analysis and regulatory reporting methodology June 2027 is closer than it looks 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-03-01-euptd-pay-gap-reporting/) on my blog. --- # The Triple-Filter Test: How to prioritize HR interventions with panel data URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/the-triple-filter-test Date: 2026-02-12 Tags: evidence-based-management, panel-data, employee-survey, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2026-02-12-the-triple-filter-test/ As People Analytics practitioners, we’re often asked: “*Which lever should we pull to drive engagement, retention, productivity… [fill in your favorite outcome]?*” [Recently](https://blog-about-people-analytics.netlify.app/posts/2025-10-21-cross-lagged-panel-modeling/), I shared how RI-CLPM (*Random Intercept Cross-Lagged Panel Models*) can move us beyond simple correlations by using survey data collected over time to separate stable differences from within-person change. As I’ve gone deeper into the model’s mechanics, I’ve realized it can help not only with identifying potential “drivers,” but also with prioritizing where to spend budget - as long as we’re careful about what the model can and can’t claim and we interpret results in the context of business strategy.
![](./the-triple-filter-test/ri_clpm_scheme_full.png)
Here’s the approach I’m experimenting with: applying a triple filter to every relevant survey variable before recommending action. ### 1) The Asymmetry Test (The “Direction” Filter) We should prioritize levers where the effect from the Lever in period 1 to the Outcome in period 2 is meaningfully stronger than the reverse. But I don’t treat this as a simple yes/no gate. The degree of asymmetry can be used as a weighting signal: a lever with a moderate effect but strong directional dominance can outrank one with a larger effect that appears equally bidirectional. The Logic: If “Manager Support” this quarter predicts next quarter’s “Productivity” more than the reverse pathway does, that pattern is more consistent with a directional influence in the within-person dynamics - and it may be a better candidate for intervention testing. Important Caveat: Asymmetry is suggestive, not definitive. Time-varying confounds (reorgs, comp changes, manager changes), measurement issues, and differences in reliability can still produce apparent directionality. So I treat this as “where to place bets and run tests,” not “proof of causality.” The Goal: Stop over-investing in ambiguous feedback loops where the direction is unclear, and rank the candidates you do find by how strongly directional the within-person dynamics appear. ### 2) The Malleability Filter (The “Inertia vs. Flux” Filter) In RI-CLPM, we separate stable differences across people/contexts (the between-person component) from within-person fluctuations over time (the part that moves relative to someone’s own baseline). The ratio of within-person variance to total variance tells us how much of a variable’s movement is actually “in play” for change efforts. The Logic: If most variance in a variable sits in the stable component, we have evidence of Contextual Inertia. This often reflects stable structural factors like personality, role design, team composition, manager assignment, or baseline culture. The key point is: individual training is often the wrong tool when the signal is dominated by Contextual Inertia. The Goal: Avoid spending intervention budget on variables where the "system" creates too much drag for individual-level programs to succeed. This filter answers: Is there enough within-person signal to work with - or are we fighting structural gravity? ### 3) The Autoregressive Check (The “Lift vs. Maintenance” Filter) This filter goes one level deeper: of the within-person fluctuation that exists, how strongly does it carry forward from year to year? The Logic: Autoregression tells you the intervention economics (stickiness). **High Stickiness (High Persistence)**: Changes tend to endure; the decay rate is slow. This is where structural, high-dose upfront interventions can have a durable payoff. **Low Stickiness (Low Persistence)**: Deviations reset toward baseline quickly. These are “leaky bucket” variables - best suited for continuous/pulse-style reinforcement rather than one-off programs. Example: Perceived growth opportunities can fluctuate across people and time (it looks malleable). But if negative deviations strongly persist year over year, a standalone career workshop is unlikely to break the pattern. You may need structural changes: clearer mobility paths, internal marketplace design, manager incentives, or staffing processes. Conversely, a metric with low persistence might respond quickly to a nudge - just don’t expect it to last without ongoing reinforcement. The Goal: Match the intervention design to the dynamic: decide whether you’re signing up for a heavy lift with long persistence or a lighter lift with ongoing maintenance. This filter answers: Is this a “move it once and it sticks” lever, or a “keep watering it” lever? ### The “Structural Pivot” Here’s the counter-intuitive part: what looks like a “failure” for an L&D team can be a gold mine for Talent Acquisition or Org Design. If analysis suggests a factor has high directional association with performance (Filter 1) but is dominated by Contextual Inertia (Filter 2), stop trying to force-fit it into a training program. You are likely hitting a structural constraint, not a skill gap. This means the driver is better addressed via structural levers: selection, placement, role design, manager assignment, or team composition. In some cases, the fix is Who: “Hire for this trait” or “Staff for this mix.” In other cases, the fix is Where: “Redesign the role” or “Build the environment where this behavior naturally emerges.” Either way, you’ve learned something valuable: this lever is not a target for individual interventions, but it is critical for how you staff and structure the work. ❓ I'm curious: Does this approach make sense to you? Do I read too much from the model? Does anyone here systematically use criteria like these when prioritizing actions based on survey panel data? If you do, what’s your rubric - and what pitfalls have you run into (e.g., time-varying confounds, survey changes, cadence issues)? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-02-12-the-triple-filter-test/) on my blog. --- # Is contagious turnover overrated? Probably only if you ignore the managers. URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/contagious-turnover Date: 2026-02-05 Tags: employee-turnover, people-management, panel-data, statistics, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2026-02-05-contagious-turnover/ Recently, I dug into the well-known phenomenon of “*contagious turnover*” and found some compelling - and sobering - results. I was particularly curious about whose resignation is most strongly associated with subsequent departures: team peers or direct managers? And can team size really act as a buffer? To get a handle on this, I analyzed voluntary attrition patterns using a *fixed-effects cloglog discrete-time survival model*\*, crunching 2.7M+ person-month observations. **Short intro methodological note**: I didn’t just look at who *left* today. I adjusted the model to account for a two-month notice period. This allowed me to separate the **Decision Phase** (when the news breaks) from the **Departure Phase** (when the seat actually goes empty). Now, the major findings…
![](./contagious-turnover/resignation_effects.png)
### **Manager Resignation: The “Universal Shock” & “The Long Tail”** Manager departures show a distinctive pattern over time, and team size appears to moderate this relationship in unexpected ways. **The Universal Shock (Month 2)**: When a direct manager announces they are leaving, the estimated risk of their team members quitting is **107% higher exactly two months later**. Crucially, there is no evidence that team size offers protection here. The model shows that the immediate “panic” is universal - a manager leaving a squad of 3 is associated with the same massive spike in attrition risk as a manager leaving a department of 20. **The Paradox of Scale (Months 3–4)**: As the initial shock fades, a strange pattern emerges. While the average risk remains elevated (+56%), there are tentative hints that *large teams may be more vulnerable* during this period. This could be interpreted as a “**structural vacuum**” effect: without a leader to manage the complexity of a large unit, the environment may degrade faster than in a small, self-organizing squad. **The Long Tail (Months 5–6)**: By this point, the acute crisis is over, but the baseline risk remains elevated at +55%, regardless of team size. This persistence suggests that the issue has shifted from an acute reaction to a more chronic structural problem - though we cannot rule out that both the manager’s departure and the team’s attrition share a common cause.
![](./contagious-turnover/peers_manager_resignation_effects_team_size.png)
### **Peer Resignation: The “Flash Flood”** Peer resignations show a very different profile, consistent with the idea that they act more as *social signals* than structural failures. **The Social Panic (Month 2)**: Like manager exits, peer resignations are associated with a sharp spike in risk - roughly **41% higher per departing peer** - around the time the peer actually leaves. **The Buffer Effect**: Unlike the manager scenario, team size does help here - but only in the acute phase. The model shows a clear dilution effect in Month 2: the larger your team, the less each departing peer affects overall risk. This buffering effect fades in later months and is no longer statistically detectable by Months 3–6. This supports the “**social buffering**” hypothesis: in a small team, one peer leaving is a major disruption to the social fabric; in a large team, the effect is diluted. **The Quick Recovery (Months 3–6)**: Unlike the manager effect, the peer contagion signal decays rapidly. The estimated risk drops to just 12% in Months 3–4 and becomes negligible by Months 5–6. This rapid decay is consistent with the notion that peer contagion is an acute, short-lived phenomenon rather than a long-term structural degrader.
![](./contagious-turnover/peers_resignation_effects_team_size.png)
### **The Takeaway** If these patterns reflect genuine causal relationships (see the methodology section below), “*contagious turnover*” may not be a single problem, but two distinct challenges: 1. **The Peer Exodus**: This looks like an acute, social fire. It spreads fast - and burns out fast. The data imply that if you survive the first few months, the danger likely passes.Possible actions: Counter-signal immediately. If this is an information cascade, you need to break the signal chain. Launch targeted *stay conversations* with central influencers (social nodes) within days. The goal is narrative control: stop the “sinking ship” rumor before it solidifies. 2. **The Manager Vacuum**: This appears to be a more chronic, structural failure that may actually worsen with scale in the medium term. The short-term risk is universal, and large teams may struggle disproportionately without clear direction - though this needs further validation. This supports the case for rapid stabilization: uncertainty causes more damage than an imperfect leader. Install a designated interim immediately to hold the structural load. Large teams cannot self-organize through a transition; they require visible continuity to prevent the environment from degrading. Curious if this matches what you’ve seen on the ground. ### **The “Elephant in the Room” (Limitations)** Let’s be honest about the biggest potential hole in this analysis: **team-level time-varying shocks**. While I controlled for organization and time fixed effects, the model cannot definitively distinguish between *contagion* (one person leaving triggers others) and *common shocks* (a bad environment pushes everyone out). * **The Manager Illusion**: If the ship is sinking, the captain often jumps first - because they have better binoculars. The crew jumping later may look like they’re following the leader, but they may simply be reacting to the same sinking ship. * **The Peer Illusion**: Similarly, if two teammates leave in quick succession, it might not be that Peer A influenced Peer B. More often, they share the same toxic boss or failing project - and Peer A just found a new job 30 days faster. To strengthen the conclusions in future iterations, the gold standard would be an exogeneity filter: rerunning the model using only exits driven by plausibly random shocks (retirements, partner relocations, etc.). If the attrition spike persists when a colleague leaves for “neutral” reasons, we’d have stronger evidence of true social contagion. If the spike vanishes, we were likely measuring a *bad culture proxy*. That said, this approach has its own limitations - such exits are rare and may not generalize well. 🤓 Has anyone here run similar analyses - internally or in research? If so, I’m curious what modeling approach you used and whether your results pointed in the same direction. ------ \* **Methods note**: Fixed-effects cloglog discrete-time survival model was used. Resignation predictors are lagged to align with a standard two-month notice period. Peer resignations are modeled as counts. The model controls for tenure, age, gender, management level, and team size (logged and centered), with fixed effects for org unit, region, job family group, and reporting date, and standard errors clustered by team. Involuntary exits are treated as competing events via cause-specific hazards (right-censored). **All effect sizes should be interpreted as associations, not causal estimates**. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-02-05-contagious-turnover/) on my blog. --- # Luck, cutoffs, and the stories we tell about success URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/matthew-effect-and-success-stories Date: 2026-01-23 Tags: meritocracy, cumulative-advantage, education, inequality Original post: https://blog-about-people-analytics.netlify.app/posts/2026-01-23-matthew-effect-and-success-stories/ It’s easy to underestimate how much small, seemingly arbitrary factors shape long-term outcomes - especially when we’re used to telling clean stories about talent, effort, and merit. I was reminded of this recently while reading [*Determined*](https://en.wikipedia.org/wiki/Determined:_A_Science_of_Life_Without_Free_Will) by [Robert Sapolsky](https://en.wikipedia.org/wiki/Robert_Sapolsky). One striking example he discusses is school entry cutoffs. Because these dates are administrative rather than developmental, children in the same classroom can differ in age by up to ~12 months. In early childhood, that gap often translates into real differences in cognitive, emotional, and self-regulatory maturity. Teachers may understandably interpret those differences as indicators of ability or potential. That can lead to more encouragement, attention, and higher expectations for slightly older students - advantages that already start compounding by first grade, and can continue through tracking, confidence, and parental responses. In the UK, where the cutoff date is August 31st, this [“relative age effect”](https://en.wikipedia.org/wiki/Relative_age_effect) shows up clearly in the data. While the effect size decreases over time, it remains detectable on average through secondary school and even into university. It’s a classic example of cumulative advantage (the so-called [*Matthew Effect*](https://en.wikipedia.org/wiki/Matthew_effect)). See the attached [chart](https://en.wikipedia.org/wiki/Relative_age_effect#/media/File:Oxford_10_years.png) for illustration - it shows a near-perfect linear decline in Oxford graduation rates, with the oldest students (September) being 15% overrepresented and the youngest (August) 15% underrepresented relative to the UK norm.
![](./matthew-effect-and-success-stories/plot.png)
And of course, school entry dates are just one example. We could start with being born into a middle-class family, in a WEIRD country, at the end of the 20th century which already places you on a very particular starting point in life. And we could keep going. If you want a better sense of the background conditions we tend to leave out of our success (and failure) narratives, *Determined* is highly recommended - even if you don’t follow Sapolsky all the way on free will. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-01-23-matthew-effect-and-success-stories/) on my blog. --- # Making abstract ideas digestible with knobs and sliders URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/dunning-kruger-effect-simulation Date: 2026-01-20 Tags: learning-and-development, generative-ai, ai, cognitive-biases Original post: https://blog-about-people-analytics.netlify.app/posts/2026-01-20-dunning-kruger-effect-simulation/ As an illustration from my own practice, I wanted to give the audience in a lecture on cognitive biases a tangible experience of the ideas behind the famous [“debunking”](https://www.sciencedirect.com/science/article/pii/S0160289620300271?via%3Dihub) of the even more famous phenomenon of the [Dunning-Kruger Effect](https://en.wikipedia.org/wiki/Dunning%E2%80%93Kruger_effect) (DKE). So I called Claude to the rescue and asked it to put together a simple simulator that would intuitively demonstrate how the typical DKE pattern can arise from measurement noise / regression to the mean, boundary constraints, and universal optimism bias (also known as the Better-Than-Average Effect) - without invoking any “dual burden of incompetence.” Within two or three iterations, in about 5 minutes total, the simulator was good enough to use. You can check it out for yourself [here](https://lstehlik2809.github.io/Dunning-Kruger-Effect-Statistical-Artifact/index.html).
![Claude artifact preview](./dunning-kruger-effect-simulation/app_pic.png)
For anyone who teaches, lectures, or explains abstract ideas for a living - or just for fun - this kind of workflow is becoming hard to ignore. If you’ve come across or built simple simulators, demos, or visualizations that made a hard concept finally click, feel free to share them in the comments 🤓 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-01-20-dunning-kruger-effect-simulation/) on my blog. --- # Can flatter orgs undermine people management? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/span-of-control-and-managerial-behavior Date: 2026-01-08 Tags: organizational-design, leadership, people-management, future-of-work Original post: https://blog-about-people-analytics.netlify.app/posts/2026-01-08-span-of-control-and-managerial-behavior/ Many companies these days are pushing to flatten their org structures, hoping it will bring the well-known benefits of a leaner org profile - e.g., faster decision-making, reduced bureaucratic overhead, and greater employee autonomy. With a flatter org structure, however, the resulting larger spans of control can negatively affect managers’ capacity to handle the people-related parts of their job - clarifying expectations, providing support and consideration, removing obstacles, securing resources, supporting development - you name it. To illustrate this trade-off, see the four plots below showing [G-computation](https://medium.com/@akif.iips/a-short-introduction-to-g-computation-in-causal-inference-9a67bd9e2233) dose-response curves estimated using a Random Forest outcome model with bootstrapped uncertainty intervals that account for both sampling variability and model estimation error. The plots capture the causal effect of span of control (ranging from 1 to 22 direct reports) on four specific managerial behaviors (coaching, setting goals, communicating expectations, and providing feedback), as rated by direct reports, while controlling for common factors such as managers’ tenure, management level, department, performance rating, gender, age, region, job family, etc.
![](./span-of-control-and-managerial-behavior/charts.png)
As you can see, there is a negative - though non-linear and modest in absolute magnitude but very meaningful relative to the variability in the data - relationship between managers’ span of control and these managerial behaviors: the larger the former, the lower the latter. The existence of this trade-off, imo, doesn’t mean we should avoid flatter org designs. Rather, it means we should account for it and adjust other parts of organizational functioning to compensate for managers’ reduced capacity for people management - for example, by decoupling people development from administrative reporting lines, implementing peer-based feedback and coaching loops to distribute the support load, or maybe using AI to offload managers’ analytical, coordination, and administrative work so they can reallocate time and attention to high-quality people management. Curious whether anyone has dealt - successfully or unsuccessfully - with these negative consequences of organizational flattening. What worked, and what didn’t? Did you try AI as part of the solution? P.S. You might notice the distinct “hook” at the start of the dose-response curves - expected management quality actually dips for very small spans (1–3) before peaking around 4–6. While the wider confidence intervals in this region (the light blue bands) reflect the natural variability of smaller sample sizes, the fact that this dip appears consistently across multiple distinct behaviors suggests a structural signal, not just noise. IMO, there are three plausible and likely converging mechanisms: role conflict, dyadic intensity, and selection bias. In smaller teams, managers are more often in “player–coach” roles, which entail significant resource-allocation conflicts: dominant individual-contributor duties crowd out the cognitive bandwidth required for high-quality people management. At the same time, the lack of distributed attention in a 1:1 dynamic can create a “surveillance effect,” where standard oversight is perceived by direct reports as hyper-scrutiny or micromanagement rather than developmental support. Finally, this cohort may reflect a maturity confound, even after accounting for the available control variables—effectively acting as “training wheels” for novice leaders or as containment roles for specific performance contexts—thereby skewing the behavioral signal downward. I’m curious what your hypothesis is, and how you’re thinking about what might be driving this. **Notes**: * If you’re interested in how the dose-response curves are computed using a Random Forest as the outcome model, check the code below. ```python """ G-COMPUTATION FOR DOSE-RESPONSE CURVES WITH RANDOM FOREST =========================================================== This script demonstrates how to implement G-computation (also known as standardization or the g-formula) to estimate causal dose-response curves using Random Forest models with robust bootstrapping for uncertainty estimation. G-computation is a causal inference method that estimates the effect of an intervention by: 1. Fitting a model for the outcome given exposure and confounders 2. Predicting outcomes under counterfactual exposure scenarios for all individuals 3. Averaging these predictions to get population-level causal effects This approach properly adjusts for confounding and can capture non-linear relationships. Crucially, this version bootstraps the *estimator* (refitting the model) to capture both sampling and model uncertainty. """ import pandas as pd import numpy as np from sklearn.ensemble import RandomForestRegressor from joblib import Parallel, delayed import matplotlib.pyplot as plt import seaborn as sns # Set plot style sns.set_style("white") def prepare_data(data, outcome_var, exposure_var, confounders): """ Prepare data for G-computation analysis. """ # Select relevant variables vars_of_interest = [outcome_var, exposure_var] + confounders modeling_data = data[vars_of_interest].dropna() # Prepare features: one-hot encode categorical variables X = pd.get_dummies( modeling_data.drop(columns=[outcome_var]), drop_first=True ) y = modeling_data[outcome_var] print(f"Dataset prepared: {len(modeling_data)} observations") print(f"Features: {X.shape[1]} (after encoding)") return X, y, modeling_data def _bootstrap_iteration(X, y, exposure_var, exposure_range, n_estimators, random_state): """ Helper function to run a single bootstrap iteration: 1. Resample data 2. Refit model 3. Predict counterfactuals """ # 1. Resample (Bootstrap) boot_idx = np.random.choice(len(X), size=len(X), replace=True) X_boot = X.iloc[boot_idx] y_boot = y.iloc[boot_idx] # 2. Fit Model on Bootstrap Sample # Note: n_jobs=1 here because we parallelize the outer loop model = RandomForestRegressor( n_estimators=n_estimators, random_state=random_state, n_jobs=1 ) model.fit(X_boot, y_boot) # 3. Predict Counterfactuals means = [] X_cf = X.copy() # Use original population structure for marginalization for level in exposure_range: X_cf[exposure_var] = level # Average prediction for this exposure level means.append(model.predict(X_cf).mean()) return means def gcomputation_dose_response(X, y, exposure_var, exposure_range, n_bootstrap=100, n_estimators=50, n_jobs=-1, random_state=2026): """ Perform G-computation with robust bootstrapping. Parameters: ----------- X : pd.DataFrame Feature matrix y : pd.Series Outcome variable exposure_var : str Name of the exposure variable exposure_range : array-like Range of exposure values to evaluate n_bootstrap : int Number of bootstrap iterations (refitting the model) n_estimators : int Number of trees for the bootstrap models (usually fewer than main model) n_jobs : int Number of CPU cores to use (-1 for all) Returns: -------- results_df : pd.DataFrame """ print(f"\n{'='*70}") print("PERFORMING ROBUST G-COMPUTATION") print(f"{'='*70}") print(f"Exposure variable: {exposure_var}") print(f"Exposure range: {min(exposure_range)} to {max(exposure_range)}") print(f"Bootstrap iterations: {n_bootstrap} (Refitting model each time)") # Check if exposure variable exists in the data if exposure_var not in X.columns: raise ValueError(f"Exposure variable '{exposure_var}' not found in data") # 1. Calculate Point Estimate (Main Model) print("Fitting main model for point estimates...") main_model = RandomForestRegressor(n_estimators=200, random_state=random_state, n_jobs=n_jobs) main_model.fit(X, y) expected_outcomes = [] X_cf = X.copy() for level in exposure_range: X_cf[exposure_var] = level expected_outcomes.append(main_model.predict(X_cf).mean()) # 2. Bootstrap for Uncertainty (Parallelized) print("Running bootstrap iterations...") # Generate random seeds for each iteration to ensure diversity rng = np.random.RandomState(random_state) seeds = rng.randint(0, 100000, size=n_bootstrap) boot_results = Parallel(n_jobs=n_jobs, verbose=1)( delayed(_bootstrap_iteration)( X, y, exposure_var, exposure_range, n_estimators, seed ) for seed in seeds ) # Convert list of lists to numpy array: (n_bootstrap, n_levels) boot_matrix = np.array(boot_results) # Calculate Confidence Intervals (Percentile Method) ci_lower = np.percentile(boot_matrix, 2.5, axis=0) ci_upper = np.percentile(boot_matrix, 97.5, axis=0) # Create results dataframe results_df = pd.DataFrame({ 'exposure_level': list(exposure_range), 'expected_outcome': expected_outcomes, 'ci_lower': ci_lower, 'ci_upper': ci_upper }) print(f"\nG-computation complete!") return results_df def plot_dose_response(results_df, exposure_var, outcome_var, save_path=None, figsize=(10, 6)): """ Plot the estimated causal dose-response curve. """ fig, ax = plt.subplots(figsize=figsize) # Plot the dose-response curve ax.plot( results_df['exposure_level'], results_df['expected_outcome'], color='darkblue', linewidth=2.5, label='Expected Outcome (ATE)' ) # Add confidence interval band ax.fill_between( results_df['exposure_level'], results_df['ci_lower'], results_df['ci_upper'], alpha=0.3, color='lightblue', label='95% CI (Model + Sampling Uncertainty)' ) # Labels and formatting ax.set_xlabel(exposure_var, fontsize=12) ax.set_ylabel(f'Expected {outcome_var}', fontsize=12, weight='bold') ax.set_title(f'Causal Dose-Response Curve: {outcome_var} vs {exposure_var}', fontsize=14, weight='normal') ax.legend(loc='best', fontsize=10) ax.grid(True, alpha=0.3, linestyle='--') # Remove top and right spines for cleaner look sns.despine() plt.tight_layout() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') print(f"Plot saved to: {save_path}") plt.show() return fig, ax def run_gcomputation_analysis(data, outcome_var, exposure_var, confounders, exposure_range, n_bootstrap=100, save_results_path=None, save_plot_path=None, random_state=2026): """ Complete pipeline for G-computation dose-response analysis. """ print(f"\n{'='*70}") print("G-COMPUTATION DOSE-RESPONSE ANALYSIS") print(f"{'='*70}") # Step 1: Prepare data X, y, _ = prepare_data(data, outcome_var, exposure_var, confounders) # Step 2: G-computation (Fit & Bootstrap included) results_df = gcomputation_dose_response( X, y, exposure_var, exposure_range, n_bootstrap=n_bootstrap, random_state=random_state ) # Step 3: Visualization plot_dose_response( results_df, exposure_var, outcome_var, save_path=save_plot_path ) # Step 4: Save results if save_results_path: results_df.to_csv(save_results_path, index=False) print("\nANALYSIS COMPLETE") print(f"{'='*70}\n") return results_df # ============================================================================ # EXAMPLE USAGE # ============================================================================ if __name__ == "__main__": print("To use this script, load your dataframe and call run_gcomputation_analysis()") ``` * You can also check [one of my previous posts](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-span-of-control/) that examines this topic from the perspective of 1:1 meeting frequency, as measured via collaboration metadata. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2026-01-08-span-of-control-and-managerial-behavior/) on my blog. --- # Refactoring the "Garden of Forking Paths" URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/garden-of-forking-paths-redo Date: 2025-12-17 Tags: career-development, time-series Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-17-garden-of-forking-paths-redo/ Reading the book [*Existential Physics: A Scientist's Guide to Life's Biggest Questions*](https://en.wikipedia.org/wiki/Existential_Physics) (btw, highly recommended) made me rethink the popular image many people (including myself) use to signal their openness to new career and life directions - a [“garden of forking paths”](https://en.wikipedia.org/wiki/The_Garden_of_Forking_Paths) where the future is a blank canvas of almost infinite potential, constrained only by our imagination.
![](./garden-of-forking-paths-redo/forking_paths_original.jfif)
My update tries to make this image more honestly reflect the reality we’re facing in such situations 🤓 Specifically, that some paths are effectively unreachable for us (e.g., in my middle forties, becoming a professional brain surgeon, no matter how much I might wish it), and others are far more probable than others (for example, for me it’s more likely that my next job will again be at the intersection of data and psychology than in psychotherapeutic care).
![](./garden-of-forking-paths-redo/forking_paths_updated.png)
Not sure if you’ll be interested in using this updated version of the image - after all, it removes a large part of the positive vibe of the original 🙃 - but if so, feel free to copy the image above and use it in your announcement 😊 ---- **Update**: After posting this on LinkedIn, Eric-Jan Wagenmakers pointed me to his article *A Deterministic View on Life*, which, imo, provides an even more honest account of the issue. It defends a deterministic perspective while explaining why uncertainty, choice, and responsibility remain meaningful despite the absence of genuine alternative possibilities. The core argument is that while life unfolds along a single, fully determined trajectory, our experience of uncertainty, deliberation, and choice arises from our incomplete knowledge, not from genuinely open alternatives. We reason, decide, and feel responsible because we must infer outcomes under uncertainty - even though, in retrospect, only one outcome was ever possible (see visualization of this deterministic perspective on life by Viktor Beekman below). But best to read it in full [here](https://www.bayesianspectacles.org/a-deterministic-view-on-life/).
![](./garden-of-forking-paths-redo/LifePathLightningLandscape.jpg)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-17-garden-of-forking-paths-redo/) on my blog. --- # Hidden DAG geometry of version control URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/git-and-dags Date: 2025-12-15 Tags: data-science, causal-inference, network-analysis, critical-thinking Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-15-git-and-dags/ Not being a heavy Git user, I was surprised to learn that to gain a deeper understanding and a solid mental model of how Git works, DAGs - well known from the world of causal inference, where I feel a bit more at home - are very handy. They make it much easier to deal with more complex operations like rebasing or fixing merge conflicts - at its core, it’s just graph manipulation. Maybe I’m not the only one who was ignorant of this, so I’m sharing this small but useful learning of mine 🤓 If interested, check out, for example, the [following video](https://www.youtube.com/watch?v=Ala6PHlYjmw&t=8s), which, imo, does a decent job of explaining Git through a DAG lens.
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-15-git-and-dags/) on my blog. --- # Signal vs. Noise: Why we can’t yet identify effective nudges URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/nudge-effectiveness Date: 2025-12-15 Tags: behavioral-science, meta-analysis, research-methods Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-15-nudge-effectiveness/ A recent [second-order meta-analysis](https://onlinelibrary.wiley.com/doi/10.1002/bdm.70053?af=R) synthesizing 14 meta-analyses, 1,600+ primary studies, and ~30 million participants finds that nudges show a small positive average effect (see the corresponding forest plot below).
![](./nudge-effectiveness/metaanalyses.jpg)
*Forest plot of the effects of the nudging intervention.* However, after correcting for publication bias (see the corresponding funnel plot below), the pooled effect is statistically indistinguishable from zero.
![](./nudge-effectiveness/funnel_plot.jpg)
*Funnel plot of nudging intervention.* A common critique is that "nudging" is too broad - like meta-analyzing all medicines at once - and that we should focus on specific interventions that work. This study challenges that optimism. The authors found no robust moderators to identify successful contexts. This is likely because the foundation itself is shaky: the majority of existing meta-analyses were rated as low or critically low in methodological quality. The signal is simply lost in the noise of low-quality methods. The implication is stark: We cannot currently distinguish the "winners" from the statistical artifacts. For policy and practice, we must stop assuming "what works" and demand fundamental rigor. We need valid measurement before we can even begin to identify effective interventions. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-15-nudge-effectiveness/) on my blog. --- # When facial similarity meets Network Science URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ona-and-face-similarity Date: 2025-12-11 Tags: psychology, network-analysis, ai, fun-with-data Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-11-ona-and-face-similarity/ As psychological research suggests, we tend to be drawn to people who are like us - psychologically, sociodemographically, and physically. This so-called [*similarity-attraction principle*](https://www.encyclopedia.com/social-sciences/applied-and-social-sciences-magazines/similarityattraction-theory) makes us more comfortable with, and more likely to interact with those we perceive as similar, which often leads to more frequent and more positive communication. You can think of these similarities as “avenues” between us that make it easier (or harder) to reach out to others. And from that perspective, it’s just one step to operationalize this phenomenon via [*social network analysis*](https://en.wikipedia.org/wiki/Social_network_analysis). For illustration - and to have some fun 🤓 - I focused only on the physical (facial) aspect of similarity and used a group of 16 well-known world politicians as my volunteers (well, not literally). From a technical perspective, I used [`DeepFace`](https://en.wikipedia.org/wiki/DeepFace) with [`Facenet512`](https://github.com/TheSnakeCoder/Face_recognition_facenet512) to extract facial embeddings and computed cosine similarity between all pairs. Then I built a network graph where edges connect politicians with positive facial similarity (thicker lines = stronger resemblance), and a Spring layout algorithm positioned lookalikes closer together. To identify groups of faces with shared features, I applied Greedy Modularity community detection. The result? See the network graph below - I’ll leave it to you to decide how these discovered similarities might translate into real-world relationships between individual politicians 😉
![](./ona-and-face-similarity/group_photos_network.png)
Note: Given the methodology, if you averaged all the faces, you’d get an “ideal” politician who should, on average, be in the best (central) position to align with the group as a whole. To get a better idea of what such a politician might look like, I also generated the morphed “average face” of this specific set of politicians - see the pic below. If you think your face looks similar, maybe you should consider entering politics 🙃 But don’t wait too long - politics isn’t exactly a stable profession, this crew can change fast, and your face might not fit as well later on 😁
![](./ona-and-face-similarity/average_face.png)
P.S. Out of curiosity, I also ran the analysis with my own photo, and it seems I should soon head to Mexico or Germany 🙂 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-11-ona-and-face-similarity/) on my blog. --- # Loftus & Palmer 2.0: Replicating human bias in AI URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ai-suggestibility Date: 2025-12-07 Tags: generative-ai, behavioral-science, data-science, cognitive-science Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-07-ai-suggestibility/ We’ve all seen how GenAI chatbot responses can shift depending on the exact phrasing of our prompts. A recent complaint from a friend about this behavior inspired me to partially replicate a classic psychological experiment - but with GenAI as the subject instead of humans. I’m referring to the [Loftus & Palmer (1974)](https://psycnet.apa.org/record/1975-04498-001) study (*Reconstruction of Automobile Destruction*), which examined how language shapes memory. For those unfamiliar with it, participants watched short films of car accidents and were asked to estimate the cars’ speed. The key manipulation was the intensity of the verb in the question. Those asked how fast the cars were going when they **“smashed”** gave higher estimates (~40.5 mph) than those asked with the verbs **“collided”** (39.3 mph), **“bumped”** (38.1 mph), **“hit”** (~34.0 mph), or **“contacted”** (~31.8 mph).
![](./ai-suggestibility/loftus_palmer_table.png)
*Results from the original Loftus & Palmer (1974) study (Experiment 1).* I tried something similar with a multimodal AI model - GPT-5.1, to be specific - but instead of relying on memory recall from a film, I provided a static image of a car accident (see the picture below) along with the task description (*Based on picture provided, estimate how fast were the cars going when they {word} each other? Don't refuse to make an estimation - make your best guess based on available information. Provide your response as a JSON object with a single key 'speed' and the value should be just the numerical estimation in km/h (without the unit, just the number).*). This made the experiment less about memory distortion/reconstruction and more about contextual drift - essentially testing whether the semantic weight of a single word could override the visual evidence in the pixel data.
![](./ai-suggestibility/car_accident_pic.jpg)
*The car-crash image shown to the AI as part of the prompt during the replication of the original Loftus & Palmer (1974) experiment.* I ran 100 trials for each of the five verb variations to capture the model’s non-deterministic variability. As shown in the chart below, the results only partially replicate the original findings.
![](./ai-suggestibility/speed_estimations_chart_gpt51.png)
On a broad level, the pattern holds: the two most intense verbs produce slightly higher estimates than the two least intense ones. However, the effect, though statistically robust, is less pronounced than in the original study. One specific detail stands out: the middle verb, “bumped,” produced the lowest estimates of all - even lower than "contacted." I’m not a native English speaker, so I might be missing a nuance, but, imo, this may actually track with NLP logic. While "contacted" is a velocity-neutral term often found in formal police reports, "bumped" carries a specific semantic vector in training data that implies low energy (like a parking lot fender-bender). If this interpretation is valid, the model is clearly adhering to the linguistic definition over the visual cues. I also wondered whether the muted effect in the other verbs might arise from GPT-5.1 being a reasoning model that leans more on “deliberate analysis” than “intuition.” So, I repeated the experiment with OpenAI’s strongest non-reasoning model, GPT-4.1. And as shown in the chart below, its results aligned a bit more with the expected pattern - suggesting reasoning models might anchor more firmly to visual ground truth - but the overall picture is pretty similar to the previous results (aside from generally lower speed estimates), including the odd “bumped” dip.
![](./ai-suggestibility/speed_estimations_chart_gpt41.png)
This is just one small experiment with two specific models and one narrow task, so the conclusions definitely cannot be overstated. Still, imo, it demonstrates that the wording of our prompts can, at least in some cases, systematically bias GenAI outputs. So next time you craft a prompt, choose your words wisely - your leading questions can lead your favorite chatbot astray 😉 P.S. By coincidence, while working on this post I stumbled upon a much more rigorous and complex study on prompt framing effect in the context of misinformation correction - you can check it out [here](https://arxiv.org/abs/2511.22746). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-07-ai-suggestibility/) on my blog. --- # How AI is reshaping HR-tech URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/hr-tech-ai-shift Date: 2025-12-02 Tags: ai, generative-ai, agentic-ai, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2025-12-02-hr-tech-ai-shift/ As many of you can probably attest, AI is dominating a lot of today’s debates about the future of work - both near and distant. And, unsurprisingly, this is also showing up in the more or less aggressive pivots of several HR-tech vendors that not so long ago focused on different workplace topics. I noticed this shift in a company I’ve been a big fan of ever since we played/competed on the same playground back when I was at [Time is Ltd.](https://www.timeisltd.com/) (okay, maybe more after I left TiL 😉). I’m talking about [Worklytics](https://www.worklytics.co/) - a company many know for providing insights based on collaboration metadata from tools like chat, meetings, email, Jira, GitHub, and so on. Scrolling through my LinkedIn feed over the past year or so, I got the impression that the Worklytics team pivoted a bit and is now focusing much more on AI in the workplace and less on the traditional collaboration tools mentioned above. To check that hunch, I reviewed nearly 200 posts on [their blog](https://www.worklytics.co/blog) and looked at how the likelihood that a post was primarily about AI in the workplace changed over time. Since not all posts had a release date, I used the page number as a proxy for time (with ~9 posts per page), assuming the higher the page number, the older the posts. And as you can see in the attached plot below, the probability of a post being about AI in the workplace started rising steadily around February 2025 and reached its peak so far in August this year. So I guess my hunch wasn’t totally off, including the timing.
![](./hr-tech-ai-shift/ai_trends_annotated.png)
This isn’t meant to shame a vendor for jumping on a bandwagon - quite the opposite. In this case, the shift is a sensible move, imo, and highlights that the “boring” collaboration metadata we’ve all been analyzing for years can actually be extremely useful during the transition into the AI era. When trying to deploy or measure the impact of AI in the workplace, it’s tough to do it well without a baseline of how work has been done so far. And collaboration metadata can serve as a very handy sensor for that. Curious whether any of you are already using collaboration metadata for this purpose in a systematic way - for example, tracking AI tool adoption alongside meeting patterns, focus time, or cross-team collaboration. And if so, what insights have surprised you the most? Feel free to share your experiences in the comments. Note: This post wasn’t paid or sponsored by Worklytics in any way - it was purely driven by my curiosity 🤓 P.S. This was also a nice test of current agentic tools (Google [Antigravity](https://www.googleantigravity.org/) with Gemini 3 Pro, in this case). And I can confirm what many of you have probably experienced firsthand - it’s a serious efficiency booster. The posts were scraped and analyzed in under 30 minutes, and the real bottleneck was actually the ideation and narrative phase, although AI helped there too 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-12-02-hr-tech-ai-shift/) on my blog. --- # Do Insights Discovery ‘colors’ relate to self-reported skills? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/insights-discovery-and-skills Date: 2025-11-12 Tags: personality, skills, psychometrics, validity Original post: https://blog-about-people-analytics.netlify.app/posts/2025-11-12-insights-discovery-and-skills/ Recently, I had a chance to take a quick look at a small piece of criterion-related validity evidence for the (in)famous psychometric tool known as [Insights Discovery](https://www.insights.com/products/insights-discovery/) (ID). For those unfamiliar with the tool, here’s a quick rundown - ID is a Jungian-based typological model that maps behavior onto four “color energies”: Red (driven), Yellow (enthusiastic), Green (caring), and Blue (analytical). It’s popular in corporate training and team development, but also somewhat controversial - engaging and easy to relate to, yet supported by limited peer-reviewed evidence, much like the MBTI. Having access to data on self-reported skills and ID profiles for a team of about 40 people, I couldn’t resist exploring the relationship between the two 🤓 To make the analysis workable given the limited sample size, I simplified the data a bit (maybe too much 🤔): * I used only each person’s most dominant color. * I reduced roughly 600 unique skills across the team to seven broader skill categories: *Interpersonal* (e.g., Mentorship), *Intrapersonal* (e.g., Multitasking), *Cognitive* (e.g., Critical Thinking), *Functional/Technical* (e.g., Azure DevOps), *Digital & Data Literacy* (e.g., Data Visualization), *Business & Strategy* (e.g., Budgeting), and *Leadership & Management* (e.g., Change Strategy). I didn’t have any strong hypotheses in advance, so at best this is exploratory work, the results of which should be tested on an independent and much larger sample. That said, I still expected to see at least the following patterns: * Red individuals would self-report the greatest number of skills, while Green individuals would report the fewest. * Compared with other colors, Red individuals would show a higher proportion of skills in Leadership & Management and Business & Strategy. * Green and Yellow individuals would report a relatively higher proportion of Interpersonal skills compared with other colors. * Blue individuals would report a relatively higher proportion of Functional/Technical and Cognitive skills compared with other colors. To my surprise, the majority of these expectations were more or less met, as illustrated in the two charts below, although the effects are often not large enough to be reliably detected with such a small number of observations.
![](./insights-discovery-and-skills/skills_totals_by_dominant_color.png)

![](./insights-discovery-and-skills/skill_profiles_by_dominant_color.png)
Besides that, some other interesting patterns emerged. For example: * All colors followed quite similar patterns across skill categories, suggesting that certain types of skills are generally more or less common among people, regardless of their behavioral profile. * Skill profiles were more tightly correlated along the Introversion/Extraversion dimension than along the Thinking/Feeling dimension of Jungian preferences (i.e., Blue + Green vs. Red + Yellow). ⚠️ As already suggested above, the results should by no means be overinterpreted - whether by supporters or critics of this method - as the analysis is far too limited and simplified for that. It’s primarily an exploratory look at an interesting dataset, intended mainly to satisfy my curiosity and to spark further ideas, questions, or potential analyses related to today’s popular topic of employee skills. Finally, because both the predictor and the criterion rely on self-report, some of the observed associations may reflect [common method variance](https://en.wikipedia.org/wiki/Common-method_variance) rather than substantive relationships. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-11-12-insights-discovery-and-skills/) on my blog. --- # Making talk cheap: How GenAI may disrupt labor-market signalling URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/genai-and-disrupt-of-labor-market-signalling Date: 2025-11-06 Tags: ai, generative-ai, labor-economics, future-of-work Original post: https://blog-about-people-analytics.netlify.app/posts/2025-11-06-genai-and-disrupt-of-labor-market-signalling/ Historically, written communication served as a costly signal of competence. Producing a tailored proposal required effort, skill and relevance, and employers used this as a cue of ability. With LLMs, that cost has basically dropped to zero, which might weaken the signal it used to carry. That this may already be happening - at least in some parts of the job market - is supported by the results of a super interesting [study by Silbert & Galdin (2025)](https://jesse-silbert.github.io/website/silbert_jmp.pdf) - “*Making Talk Cheap: Generative AI and Labor Market Signaling*” -, which focuses on coding-related freelance jobs on a major digital labor platform ([*Freelancer.com*](https://www.freelancer.com/)). The authors observe that before widespread LLM adoption, employers paid a premium for more customised applications. After the rollout of AI-writing tools, that premium disappeared - writing quality generally increased and no longer differentiated good and bad candidates, as measured by actual job performance and completion outcomes.
![](./genai-and-disrupt-of-labor-market-signalling/charts.png)
To measure the quality of applications at scale, the authors developed a specialized large-scale scoring method: using Meta’s Llama 4 model, each job proposal was evaluated on nine criteria capturing both customisation (relevance to the specific task) and generic writing quality (clarity, tone). Proposals re-used across jobs were flagged and scored zero for customised relevance. This quantitative measure approximates what employers infer about candidate effort. In their structural model the results are pretty stark: when writing becomes practically free due to GenAI, high-ability workers are hired 19 % less often, and low-ability workers 14 % more often - pointing to a decline in meritocratic sorting. There are obvious limitations - the study focuses on one platform and one type of job signal (short, text-based applications in coding projects); it doesn’t capture richer selection stages or potential productivity gains from AI, etc. Still, it’s a good reminder that organisations should re-examine what candidates’ behaviour actually signals - and look for alternative, new (or perhaps old 😉) signals. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-11-06-genai-and-disrupt-of-labor-market-signalling/) on my blog. --- # A graph approach to reporting structures: stop wrestling recursion URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/org-stats-and-graph-analysis Date: 2025-10-31 Tags: people-analytics, network-analysis, python Original post: https://blog-about-people-analytics.netlify.app/posts/2025-10-31-org-stats-and-graph-analysis/ From time to time, I need to compute organizational stats related to manager-report relationships. I used to do this with nested loops or a recursive walk through the org chart plus memoization (i.e., caching results to avoid recomputation). It works - but it’s verbose and easy to get wrong (recursion limits, cycle guards, cache invalidation). Recently I came upon a much more elegant and clear solution: rely on graph libraries like `NetworkX`, whose built-in functions let me easily traverse manager-report relationships and compute the metrics I need. Some quick wins with this approach:️ * **Span of control**: direct reports (number of outgoing links from a manager) vs. total reports (number of unique people reachable by following those links through all levels).️ * **Org depth / layers**: shortest-path lengths from a chosen root to its descendants.️ * **Dotted-line structures**: multiple managers are fine; compute totals via unique descendants (no double-counting).️ * **Data quality checks**: detect cycles (impossible loops) and find disconnected sub-orgs.️ * **Fewer footguns**: no hand-rolled recursion, cycle guards, or bespoke caches.️ * **Extensibility**: once it’s a graph, adding metrics (e.g., centralities, bridges) is just another function call.️ * **Clarity & auditability**: easier to review than nested recursion with custom memoization. So if you’re computing org stats and your code is full of nested loops, `NetworkX` and other similar libraries can make it shorter, safer, and more expressive. I only wish I’d discovered this earlier - I could have saved myself a lot of headaches 🫣 P.S. Below is a minimal working example of Python code that builds edges from `manager_id` → `employee_id`, followed by a simple loop to compute direct, indirect, and total reports for every manager. ``` import pandas as pd import networkx as nx # mydata: columns ['manager_id', 'employee_id'] df = (mydata.dropna(subset=['manager_id','employee_id']) .drop_duplicates(['manager_id','employee_id'])) df = df[df['manager_id'] != df['employee_id']] # drop self-loops just in case G = nx.DiGraph() G.add_edges_from(df[['manager_id','employee_id']].itertuples(index=False, name=None)) # Managers = anyone who manages at least one person managers = set(df['manager_id']) rows = [] for m in managers: total = len(nx.descendants(G, m)) # all reports at any depth (unique people) direct = G.out_degree(m) # direct reports rows.append((m, direct, total - direct, total)) out = (pd.DataFrame(rows, columns=['manager_id','direct','indirect','total']) .sort_values(['total','direct'], ascending=False)) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-10-31-org-stats-and-graph-analysis/) on my blog. --- # Can a simple coin flip help you decide and avoid overanalyzing? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/coin-flip-as-a-decision-aid Date: 2025-10-29 Tags: decision-making, behavioral-science, cognitive-science Original post: https://blog-about-people-analytics.netlify.app/posts/2025-10-29-coin-flip-as-a-decision-aid/ Research by [Douneva et al. (2019)](https://www.sciencedirect.com/science/article/pii/S0022103118303950) suggests there might be a surprisingly simple decision aid to help us move forward: a coin flip 🪙 Across five experiments with nearly 1,000 participants, people who flipped a coin between preliminary and final decision - whether about renewing a manager’s contract, choosing a product, or donating to a charity - showed less need to keep gathering more information compared to those who didn’t.️ * Effect sizes were small to moderate, but pretty consistent across different decision types and samples.️ * Participants didn’t just blindly follow the coin’s outcome - they usually stuck with their original preference as much as or even more than the control group, suggesting that the decision aid didn't determine the decision outcome.️ * The coin didn’t make decisions more/less accurate, even when there was an objectively correct answer, but it made people feel ready to decide. What might be happening behind the scenes? The authors connect the effect to well-established psychological theories: 1. Choice Closure: Flipping a coin acts like a mental “stop sign” - a psychological closure cue. It signals that the deliberation phase is over, cutting the urge to keep collecting more data. 2. Mindset Transition: The coin flip nudges people from a deliberative mindset (weighing options) to an implemental one (gearing up for action). 3. It might also help people tune into their “gut feeling” by forcing an emotional reaction to a specific outcome. Curious if anyone here has experience with this specific decision aid and how it works for them outside the lab. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-10-29-coin-flip-as-a-decision-aid/) on my blog. --- # Getting (more) causal insights from employee survey data (without an RCT) URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/cross-lagged-panel-modeling Date: 2025-10-21 Tags: employee-experience, employee-survey, causal-inference, panel-data Original post: https://blog-about-people-analytics.netlify.app/posts/2025-10-21-cross-lagged-panel-modeling/ The usual “driver analysis” (regress outcome on multiple drivers + controls, or use relative weights analysis) is useful but purely correlational - it doesn’t support directional claims that inform interventions. A stronger step in this direction would be a *cross-lagged panel modeling (CLPM)* - a method that uses repeated survey data to estimate directional (not definitive causal) effects between drivers and outcomes like engagement or intention to stay. Why CLPM gets you closer than simple regression: * Respects temporal precedence (T1 → T2).️ * Controls autoregressive stability (baseline levels).️ * Allows within-wave residual correlations, so cross-lags aren’t inflated.️ * Can model reciprocal (bidirectional) relations common for constructs like engagement or satisfaction. When to use it in practice: * You have at least two waves of employee survey data (annual or pulse). * Consistent construct measurement across waves. * Reasonably large sample (hundreds+) to support the model. * With 3+ waves, you can move to random intercept models (RI-CLPM) to separate stable between-person differences from actual within-person change. Despite providing a stronger foundation for prioritizing interventions, it still comes with some important caveats:️ * CLPM strengthens causal inference, but it cannot magically turn observational data into a RCT, so unmeasured confounding can remain️. * Attrition isn’t random - leavers are often less engaged, so simply analyzing the "survivors" who remain can create a heavily biased dataset and lead to misleading, overly optimistic conclusions. It's crucial to use proper statistical methods (like FIML when MAR is plausible or sensitivity checks if MNAR is likely) to handle this. Has anyone here experimented with CLPM or similar longitudinal methods (e.g., fixed effects panel models) to better understand engagement drivers? What insights - or pitfalls - did you run into? P.S. If interested, you can check out an example of a 2-wave CLPM in action with dummy data in the R code snippet below. ```r # ============================================ # 1. SETUP: LOAD LIBRARIES # ============================================ # Install packages if you haven't already if (!require("MASS")) install.packages("MASS") if (!require("tidyverse")) install.packages("tidyverse") if (!require("lavaan")) install.packages("lavaan") if (!require("lavaanExtra")) install.packages("lavaanExtra") library(MASS) library(tidyverse) library(lavaan) library(lavaanExtra) # ============================================ # 2. DATA GENERATION PROCESS # ============================================ # 1. EXOGENOUS CONTROLS: Create Tenure (0–14 yrs, standardized) and Region (Asia/Europe/NA) with dummies. # 2. LATENT T1 ATTITUDES: Generate 4 correlated latent factors (Recognition, Growth, Autonomy, Engagement) via MVN. # 3. OBSERVED T1: Map latent factors to observed attitudes with loadings + modest Tenure/Region effects + noise. # 4. T2 DYNAMICS: Build autoregressive & cross-lagged T2 attitudes (strong T1 paths + correlated errors + controls). # 5. ATTRITION: Apply Missing At Random (MAR) dropout (low T1 engagement ↑ dropout prob) → set T2 vars to NA. # Result: Realistic longitudinal attitudinal dataset with structured correlations, dynamics, and missingness. # Measurement note: This demo uses single observed scores per construct at each wave. # A stronger approach is a latent (multi-item) measurement model with at least partial # longitudinal invariance (equal factor loadings across waves). Results here should be viewed # as illustrative, not as a full latent CLPM. # Random seed for reproducibility set.seed(2025) n <- 2000 # --- STEP 1: GENERATE EXOGENOUS CONTROLS FIRST --- df <- data.frame(id = 1:n) # Start with an empty dataframe df$Tenure <- sample(0:14, size = n, replace = TRUE) df$Tenure_std <- as.numeric(scale(df$Tenure)) df$Region <- sample(c('Asia', 'Europe', 'North_America'), size = n, replace = TRUE, prob = c(0.2, 0.4, 0.4)) df <- df %>% dplyr::mutate( Region_Europe = as.numeric(Region == 'Europe'), Region_North_America = as.numeric(Region == 'North_America') ) # --- STEP 2: GENERATE LATENT T1 ATTITUDES --- # These are underlying factors that are correlated. COV_T1_LATENT <- matrix(c( 1.00, 0.50, 0.40, 0.65, 0.50, 1.00, 0.45, 0.68, 0.40, 0.45, 1.00, 0.62, 0.65, 0.68, 0.62, 1.00 ), nrow = 4, byrow = TRUE) latent_t1_data <- mvrnorm(n = n, mu = rep(0, 4), Sigma = COV_T1_LATENT) colnames(latent_t1_data) <- c('rec_lat', 'gro_lat', 'aut_lat', 'eng_lat') # --- STEP 3: CREATE OBSERVED T1 VARIABLES AS A FUNCTION OF CONTROLS & LATENT FACTORS --- # This builds in the realistic correlation between controls and T1 attitudes. noise_std_t1 <- 0.3 df$Recognition_t1 <- 0.8 * latent_t1_data[, 'rec_lat'] + 0.1 * df$Tenure_std + 0.1 * df$Region_Europe + rnorm(n, 0, noise_std_t1) df$Growth_t1 <- 0.8 * latent_t1_data[, 'gro_lat'] - 0.1 * df$Tenure_std - 0.1 * df$Region_North_America + rnorm(n, 0, noise_std_t1) df$Autonomy_t1 <- 0.8 * latent_t1_data[, 'aut_lat'] + 0.2 * df$Tenure_std + rnorm(n, 0, noise_std_t1) df$Engagement_t1 <- 0.8 * latent_t1_data[, 'eng_lat'] - 0.1 * df$Tenure_std + 0.1 * df$Region_North_America + rnorm(n, 0, noise_std_t1) # --- STEP 4: GENERATE T2 VARIABLES BASED ON T1 --- # Within-wave dependence at T2: # We simulate correlated residual shocks across T2 variables to create # realistic same-wave (contemporaneous) covariation. This prevents inflation # of cross-lagged effects that would otherwise have to "explain" all T2-T2 # co-movement. These are *residual* (post-regression) correlations, not # structural paths among T2 variables. COV_T2_ERRORS <- matrix(0.1, nrow = 4, ncol = 4) # Covariance of 0.1 diag(COV_T2_ERRORS) <- 0.25 # Variance of 0.25 t2_errors <- MASS::mvrnorm(n = n, mu = rep(0, 4), Sigma = COV_T2_ERRORS) # Define T2 variables with strong paths df$Recognition_t2 <- (0.6 * df$Recognition_t1 + 0.20 * df$Engagement_t1 + 0.15 * df$Tenure_std + 0.1 * df$Region_Europe + t2_errors[, 1]) df$Growth_t2 <- (0.6 * df$Growth_t1 + 0.15 * df$Engagement_t1 - 0.15 * df$Tenure_std - 0.1 * df$Region_North_America + t2_errors[, 2]) df$Autonomy_t2 <- (0.6 * df$Autonomy_t1 + 0.18 * df$Engagement_t1 + 0.2 * df$Tenure_std + 0.05 * df$Region_Europe + t2_errors[, 3]) df$Engagement_t2 <- (0.5 * df$Engagement_t1 + 0.3 * df$Recognition_t1 + 0.25 * df$Growth_t1 + 0.2 * df$Autonomy_t1 + 0.2 * df$Tenure_std + 0.15 * df$Region_Europe - 0.15 * df$Region_North_America + t2_errors[, 4]) # --- STEP 5: ATTRITION --- # MAR - dropout depends on observed Engagement_t1 logits <- -2.5 - 0.7 * (df$Engagement_t1 - mean(df$Engagement_t1)) prob_dropout <- 1 / (1 + exp(-logits)) dropout_flags <- rbinom(n, 1, prob_dropout) == 1 t2_vars <- c('Recognition_t2', 'Growth_t2', 'Autonomy_t2', 'Engagement_t2') df[dropout_flags, t2_vars] <- NA df_final <- df %>% dplyr::select(-id, -Tenure, -Region) # ============================================ # 3. SPECIFY & FIT THE MODEL # ============================================ model_desc <- ' # ----------------------------------------------------------- # SCOPE & LIMITATION # ----------------------------------------------------------- # 2-wave CLPM (not RI-CLPM). With two waves we cannot disaggregate # stable between-person differences from within-person change. # RI-CLPM requires ≥3 waves plus random intercept factors. # # When you have ≥3 waves: # - Add random intercepts for each construct (RI-CLPM). # - Interpret cross-lags as within-person dynamics net of stable trait differences. # ----------------------------------------------------------- # BASELINE (T1) REGRESSIONS ON EXOGENOUS CONTROLS # ----------------------------------------------------------- # Purpose: partial out stable covariate effects (tenure, region) # so cross-lag paths reflect change over time rather than static # between-person differences. Region dummies => "Asia" is the reference. # Note: These are adjustment paths, not causal claims about controls. # If controls may be missing, prefer fixed.x = FALSE with FIML. Recognition_t1 ~ Tenure_std + Region_Europe Growth_t1 ~ Tenure_std + Region_North_America Autonomy_t1 ~ Tenure_std Engagement_t1 ~ Tenure_std + Region_North_America # ----------------------------------------------------------- # T1 WITHIN-WAVE COVARIANCE STRUCTURE (RESIDUAL CORRELATIONS) # ----------------------------------------------------------- # Purpose: allow contemporaneous (same-wave) covariance among T1 constructs # due to unmeasured common causes/method factors/conceptual overlap. # These are covariances (~~), not directional paths, preventing inflation # of T1→T2 cross-lag effects. This block is saturated; constrain by theory if needed. Engagement_t1 ~~ Recognition_t1 Engagement_t1 ~~ Growth_t1 Engagement_t1 ~~ Autonomy_t1 Recognition_t1 ~~ Growth_t1 Recognition_t1 ~~ Autonomy_t1 Growth_t1 ~~ Autonomy_t1 # ----------------------------------------------------------- # T2 REGRESSIONS (AUTOREGRESSIVE + CROSS-LAG + CONTROLS) # ----------------------------------------------------------- # Purpose: estimate directional effects from T1 drivers to T2 outcomes, # controlling for T1 baselines (AR paths) and exogenous controls. Engagement_t2 ~ Engagement_t1 + Recognition_t1 + Growth_t1 + Autonomy_t1 + Tenure_std + Region_Europe + Region_North_America Recognition_t2 ~ Recognition_t1 + Engagement_t1 + Tenure_std + Region_Europe + Region_North_America Growth_t2 ~ Growth_t1 + Engagement_t1 + Tenure_std + Region_Europe + Region_North_America Autonomy_t2 ~ Autonomy_t1 + Engagement_t1 + Tenure_std + Region_Europe + Region_North_America # ----------------------------------------------------------- # T2 WITHIN-WAVE RESIDUAL COVARIANCES # ----------------------------------------------------------- # Purpose: capture contemporaneous (same-time) dependence among T2 residuals # so cross-lagged paths are not forced to explain within-wave comovement. # These are correlations among disturbances after controlling for T1 + controls. Engagement_t2 ~~ Recognition_t2 Engagement_t2 ~~ Growth_t2 Engagement_t2 ~~ Autonomy_t2 Recognition_t2 ~~ Growth_t2 Recognition_t2 ~~ Autonomy_t2 Growth_t2 ~~ Autonomy_t2 ' fit <- lavaan::sem( model_desc, data = df_final, missing = "fiml", estimator = "MLR", # Use robust ML - same point estimates as ML, but robust standard errors and scaled fit statistics that are less sensitive to non-normality and some model misspecifications fixed.x = TRUE # Choose FALSE if you need FIML for missing exogenous variables (Tenure, etc.) or prefer joint-model fit ) # ============================================ # 4. INSPECT RESULTS # ============================================ print("---------- Model Fit Statistics ----------") summary(fit, fit.measures = TRUE, standardized = TRUE, rsquare = TRUE) # --- Rank driver effects --- est <- parameterEstimates(fit, standardized = TRUE, ci = TRUE) driver_effects <- est %>% dplyr::filter( lhs == "Engagement_t2", op == "~", grepl("_t1$", rhs), rhs != "Engagement_t1" # exclude AR path ) %>% dplyr::transmute( Driver = rhs, Std_Estimate = std.all, CI_L = ci.lower, CI_U = ci.upper, P_Value = pvalue ) %>% dplyr::arrange(desc(abs(Std_Estimate))) print("---------- Ranked Driver Effects on Engagement ----------") print(driver_effects) # ============================================ # SEM VISUALIZATION # ============================================ # Create the layout matrix layout_mat <- matrix("", nrow = 6, ncol = 5, dimnames = list(NULL, paste0("V", 1:5))) layout_mat[1, c(1, 5)] <- c("Engagement_t1", "Engagement_t2") layout_mat[2, c(1, 5)] <- c("Recognition_t1", "Recognition_t2") layout_mat[3, c(1, 5)] <- c("Growth_t1", "Growth_t2") layout_mat[4, c(1, 5)] <- c("Autonomy_t1", "Autonomy_t2") # row 5 stays all blanks layout_mat[6, 2:4] <- c("Tenure_std", "Region_Europe", "Region_North_America") layout_df <- as.data.frame(layout_mat) # Write it to CSV # write.csv(layout_df, "sem_layout.csv", row.names = FALSE) # layout_df <- read.csv("sem_layout.csv") # Generate the plot p <- lavaanExtra::nice_tidySEM( fit, layout = layout_df, hide_nonsig_edges = FALSE, hide_var = TRUE, hide_cov = FALSE, hide_mean = TRUE, est_std = TRUE, label_location = NULL, reduce_items = NULL, plot = TRUE ) + ggplot2::labs( title = "Cross-Lagged Panel Modeling (CLPM) of Employee Survey Data", subtitle = sprintf("2-Wave • MLR + FIML • conditioning on controls • AR • cross-lags • within-wave residual covariances") ) + ggplot2::theme( plot.title = element_text(hjust = 0, size = 17, face = "bold"), plot.subtitle = element_text(hjust = 0, size = 15, face = "italic"), panel.background = element_rect(fill = "white", colour = "white"), plot.background = element_rect(fill = "white", colour = "white"), plot.margin = margin(t = 20, r = 20, b = 20, l = 20, unit = "pt") ) print(p) # Save the plot # ggplot2::ggsave("sem_plot.png", p, width = 12, height = 8, dpi = 300) ```
![](./cross-lagged-panel-modeling/sem_plot.png)
--- **Update** After sharing this post, Joerg Wittkewitz pointed me to [critical work](https://replicationindex.com/2020/08/22/cross-lagged/) highlighting important weaknesses of CLPM. While CLPM can provide stronger directional insights than simple cross-sectional regression, it also comes with structural limitations that can produce misleading causal stories if not handled carefully. Key weaknesses to be aware of: * Underestimation of stability: CLPM often underestimates how stable constructs are over time, especially when one variable (e.g., engagement) is more stable than another (e.g., satisfaction or intention to stay). * Spurious cross-lagged paths: To “compensate” for this, CLPM can produce artificial directional effects, making one variable look like a driver when it isn’t. * No modeling of short-term fluctuations: Temporary ups and downs (e.g., seasonal workload, reorganizations) are treated as noise rather than meaningful variance, distorting directional paths. * Bad model fit often ignored: Many applied studies use CLPM even when model fit is poor - which invalidates the interpretation of the cross-lagged effects. Practical implication: * If you have 3+ waves of data, moving to Random Intercept Cross-Lagged Panel Model (RI-CLPM) or fixed-effects panel approaches can help separate stable between-person differences from actual within-person change - where causal signals are more trustworthy. * With only 2 waves, be extra cautious in interpretation, and consider alternative designs (e.g., latent change models, FE panel models) or sensitivity checks. Bottom line: CLPM can still be useful as a starting point, but directional effects should be interpreted with care. A significant cross-lagged path ≠ proof of causality - and model misfit can make it worse. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-10-21-cross-lagged-panel-modeling/) on my blog. --- # Big consultancies in the skills semantic space URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/big-consultancies-in-the-skills-semantic-space Date: 2025-10-15 Tags: skills, embeddings Original post: https://blog-about-people-analytics.netlify.app/posts/2025-10-15-big-consultancies-in-the-skills-semantic-space/ While working on a RAG-technique-based tool for mapping talent and skills in our company, I got an interesting question from a colleague who wondered whether querying “Deloitte” would return skills commonly associated with that consultancy. At first, I was pretty skeptical, but the more I thought about it, the more it made sense that a good transformer-based embedding model should also capture the relationship between company names and the expertise typically linked to them in the digital space. So I gave it a shot, and to my surprise, the big consultancies landed in the skills semantic space in a way that actually made sense. Check it out yourself - I reran the analysis using the publicly available [*Lightcast* skill taxonomy](https://lightcast.io/products/data/our-taxonomies) (with ~35k skills) and the [*gte-multilingual-base*](https://huggingface.co/Alibaba-NLP/gte-multilingual-base) embedding model to see where the big consultancies ended up in the semantic space of that skill taxonomy.
![](./big-consultancies-in-the-skills-semantic-space/skills_consultancies_map.png)
You can clearly see how the Big Four accounting firms cluster closely together. McKinsey - not part of the Big Four but focused primarily on strategic consulting - occupies a slightly separate position. Accenture stands even further apart, which makes sense given its dominant orientation toward technology consulting. Don’t know about you, but in my head, they’re parked in a very similar way 😉 That said, this mental map may reflect only their core or historical identities. For example, the Big Four have for some time been expanding their consulting and technology capabilities to compete with players traditionally strong in these domains. P.S. The whole exercise reminded me of a [*semantic differential*](https://en.wikipedia.org/wiki/Semantic_differential) - a classic method developed by [*Charles E. Osgood*](https://en.wikipedia.org/wiki/Charles_E._Osgood) and his colleagues in the 1950s to measure connotative meaning of concepts, objects, or events by asking respondents to rate them on a scale defined by pairs of opposing adjectives. It’s fascinating how ideas from different eras can echo each other when you look closely 🤓 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-10-15-big-consultancies-in-the-skills-semantic-space/) on my blog. --- # Testing my GenAI skepticism URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ai-powered-data-exploration-assistant Date: 2025-10-09 Tags: agentic-ai, data-science, generative-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2025-10-09-ai-powered-data-exploration-assistant/ As a data scientist and analyst who spends a lot of time knee-deep in data, trying to figure out how to use it to answer stakeholders’ questions and help them solve the problems they’re facing, I used to be pretty skeptical about the quality and actual value that GenAI could bring to data analysis. But to challenge my assumptions and get a better sense of what today’s GenAIs and agentic frameworks like *LangChain* and *LangGraph* can actually do, I decided to experiment a bit and built a simple AI-driven assistant for data-insight democratization, that lets non-technical users explore data through basic tools for visualization and descriptive and inferential statistics, all using plain language. For that purpose I built a simple app consisting of several agent-like components (powered by the GPT-5-mini model), each responsible for a different aspect of the insight-generation process: * one component plans the analysis steps based on the user’s request, data characteristics, and broader context, * another evaluates the quality of the proposed plan and suggests adjustments if necessary, * another turns this plan into runnable code, * another fixes potential errors in the code based on received error messages, * another creates code for accompanying analyses to support explanation of the dataviz, and * another transforms the results of these analyses into a concise, clear narrative for a non-technical audience.
![](./ai-powered-data-exploration-assistant/data_exploration.gif)
*The attached video above shows the app in action, answering a question about the employee attitudes that are the most predictive of attrition using the [IBM Employee Attrition dataset](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset).* I was surprised by how reasonably well the whole system worked. Sure, sometimes the AI chose - at least in my opinion - not exactly the best way to analyze the data or answer the question, but more often than not, it handled the tasks just fine, at a level I’d expect from a junior or mid-level data analyst. In those less-than-ideal cases, I could always fix things by giving it more context or clearer instructions on what approach to use (though I admit that’s kind of cheating — you can’t really expect non-analysts to know that). Still, I can imagine the system being improved with an extra component whose job would be to ask users for more context before starting the analysis - similar to what some deep research tools do these days. Personally, this experiment made me update my priors, and now I can imagine that with properly prepared data and guardrails preventing certain ways of handling or interpreting results, you could have a tool that’s very useful in low-stakes situations - when stakeholders use plain language to explore data and test their hypotheses, leaving the most promising ones for deeper examination by human analysts who can factor in broader context and domain knowledge. Curious where you currently stand on this - feel free to share in the comments. P.S. If you’re interested, you can grab the code behind the app from my [GitHub repo](https://github.com/lstehlik2809/AI-Powered-Data-Exploration). Just plug in your OpenAI API key, and you can play around with your own data locally. ---- **Update**: After Google's release of Gemini 3 Flash Preview, which demonstrates a superior performance-to-price ratio, I replaced GPT-5-mini with this model. Its higher "intelligence" is readily apparent in the quality of the app's outputs - specifically through better chart selection and more nuanced data interpretations. It's a perfect example of the ongoing commoditization of "intelligence." --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-10-09-ai-powered-data-exploration-assistant/) on my blog. --- # Conspiracy theories as a specific example of overfitting? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/conspiracy-theories-and-overfitting Date: 2025-09-29 Tags: cognitive-science, data-science, critical-thinking, well-being Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-29-conspiracy-theories-and-overfitting/ I came across this idea when reading another great book, [*A Trick of the Mind*](https://www.amazon.com/Trick-Mind-Brain-Invents-Reality/dp/1538725207) by Daniel Yon. One currently popular [conception of mind](https://en.wikipedia.org/wiki/Bayesian_approaches_to_brain_function) treats human experience as a result of (Bayesian) combination of higher-level world models and lower-level incoming information originating from the world around us, the former trying to predict the latter, with errors leading to model adjustments and better future predictions. But when people try to reconcile existing world models with new incoming data, they must decide what they will give bigger weight. Is contradictory new data just a random fluke that doesn’t invalidate existing beliefs or is it something that should lead us to come up with new models that will better fit the world around us? You can imagine that a bad decision in this respect can quickly lead to crazy beliefs trying to explain each random fluke or, on the other hand, to a fixed mind that fails to adapt to new information and circumstances. There is experimental evidence that when making this decision, people take into account (meta)information about how uncertain, volatile, and unpredictable the world around them is. The more predictable it is, the more weight is given to existing beliefs, and the more volatile and unpredictable, the more weight is given to new incoming information, which is a rational way to combine these two, as we should lean more toward updating our world models when the world is in flux and likely to change. However, what if situational circumstances or biological predispositions lead you to believe that the world around you is uncertain and unpredictable? In that case, you can end up with very peculiar theories, as each data point loudly calls for revision of existing beliefs. There is some evidence that this can really happen: 1. [Suthaharan et al. (2021)](https://www.nature.com/articles/s41562-021-01176-8?utm_source=chatgpt.com) found that during the COVID-19 pandemic, paranoia among the study participants increased and was linked to more erratic belief updating in experimental tasks. People with higher paranoia tended to treat the world as unstable — a bias also associated with greater endorsement of conspiracy theories. 2. [Browning et al. (2015)](https://www.nature.com/articles/nn.3961) showed that anxious individuals struggled to adapt their learning to the stability of the environment. Instead of learning slowly in predictable settings and quickly in volatile ones, they showed a more rigid learning style, failing to make this crucial adjustment between different contexts. From this perspective, eccentric conspiracy theories are a specific example of overfitting when we give too much weight to random noise in the world. Thinking now what we could get from this insight. In data science, we fight overfitting usually with regularization, decreasing learning rate, model simplification, cross-validation, ensembling models, feature selection & engineering, getting more and better data, noise injection to inputs or model parameters. Can there be analogs of some of these techniques that could be used in the case of human cognition? Or am I pushing the analogy too far? P.S. This is also a topic close to me personally. In my Ph.D. dissertation, I looked at whether human thinking is truly irrational or whether it just sometimes looks that way. Using Bayesian models, I studied how people interpret coincidences: do they dismiss them as random noise, or take them as evidence for hidden causes? What I found is that people often follow rational principles, but the “dials” of the system can be miscalibrated. I also explored how these miscalibrations are linked to dispositional factors (like stable personality traits or cognitive styles) and situational factors (like how comprehensible or uncertain the context feels). For example, if someone consistently overestimates the prior plausibility of hidden causes, coincidences easily turn into superstitions. Conspiracy theories, in this light, can be seen as the same kind of miscalibration — not irrationality in the sense of broken reasoning, but an overfitting problem, where too much weight is given to noise in the environment. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-29-conspiracy-theories-and-overfitting/) on my blog. --- # Agentic AI for visual data exploration URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/agentic-ai-for-visual-data-exploration Date: 2025-09-24 Tags: agentic-ai, generative-ai, organizational-culture, employee-experience, glassdoor Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-24-agentic-ai-for-visual-data-exploration/ It’s a [simple agentic app](https://ai-dataviz-exploration.streamlit.app/) for plain-language exploration of corporate culture data. Ask a question in everyday language, and a team of AI agents will plan the analysis, generate the visualization, and explain the findings - all in a few seconds. It runs on a public dataset from [*CultureX*](https://www.culturex.com/) with corporate culture scores across companies in different industries, measured through anonymous [*Glassdoor*](https://www.glassdoor.com) reviews between Jan 1, 2023, and Apr 4, 2025. The app uses several AI agents (powered by Google's Gemini 3 Flash Preview), each responsible for a different step: * **Clarifier** - asks 2–3 sharpening questions to understand exactly what you want (which companies, which metrics, what chart type, etc.) * **Planner** - designs the analysis strategy based on your request and the data * **Critic** - reviews the plan and pushes back if something can be improved (up to 3 rounds) * **Executor** - turns the plan into runnable visualization code * **Narrative** - computes statistics and writes a data-driven summary * **Explainer** - synthesizes everything into a concise insight for a non-technical audience After the initial analysis, you can ask up to 5 follow-up questions. The AI keeps full context from previous turns - including your answers to sharpening questions - so it builds on what it already knows rather than starting from scratch. Hit `✨ New Analysis ` to start to a fresh exploration.
![](./agentic-ai-for-visual-data-exploration/app_pic.png)
It usually provides good outputs even with really simple plain language - but based on my experiments, it helps if you know what kind of dataviz and insights you want and spell that out for the agents 😉 [Happy exploring](https://ai-dataviz-exploration.streamlit.app/) 🕵️‍♀️ And as usual, genAI can make mistakes, so don't trust the outputs blindly - always double-check 😉 ---- P.S. Since the app is hosted on Streamlit Community Cloud, it doesn't stay awake continuously. If it hasn't been used recently, you may need to wake it up and wait a few minutes. P.P.S. Click the GitHub icon in the top right corner of the app to get to the code repo, or go directly [here](https://github.com/lstehlik2809/AI-Dataviz-Exploration/tree/main). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-24-agentic-ai-for-visual-data-exploration/) on my blog. --- # Can genAI help people managers lead better? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/genai-and-leadership-judgement Date: 2025-09-22 Tags: leadership, generative-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-22-genai-and-leadership-judgement/ Many of us have probably read about recent [OpenAI research](https://go.sparkpostmail2.com/f/a/2coy4p_7T3W9fFCvydrRxA~~/AAA-fBA~/SrWxfCzqkYIc4YBJbUbAcKC1heZK5iLCyQsE-BKLz5oLGJBugTP8-oWsMWMPo6mGTQFbvhUFGRY3qhVAmCLhEOkN9ijhDjGtO_0yfkjWxeCCZfeAMyUmEuSFgFCNsAcqrU-LvdmeplQkrvKnoJPh3Kc1t4ejHe9lGQUoP_UEdzNlbsl6rMBJlLXlr5SYECoCEUSth9AdamjpMrCzNT5HQHexqveh27VgiSIXdCNi_Vo~) on how customers are using their tool. The study found, among other things, that asking for information or advice to support decision-making is the single biggest use case, making up 49% of all user interactions. That got me wondering: how good could genAI be at helping managers make better decisions about their teams? To get a more tangible sense of this, I put ChatGPT-5 (in thinking mode) through the *Leadership Judgement Indicator* (LJI) - a well-known situational judgment test that presents people-related decision-making scenarios, offers several possible solutions, and asks respondents to rate how adequate those solutions are. (And for the psychometricians out there: I ran the test in temporary chat mode, so nothing was saved or used to train OpenAI’s models - lowering the risk of the test material being exposed 🫡) For those who don’t know the tool, it’s based on the theory that leadership effectiveness depends on the ability to adapt decision-making styles to the situation, the task, and the people involved. Specifically, it assumes that… * no single leadership style is always best, * context drives effectiveness, and * effective leaders must flex across various styles, even when they don’t come naturally to them.
![](./genai-and-leadership-judgement/lji_model.png) *Leadership decision-making model behind the LJI.*

So how did genAI do compared to human managers? * It leaned much more often toward a consultative style, and less often - though still within the broader norm - toward the other three styles: delegative, consensual, and directive. * Its judgment quality was high when choosing directive and especially consultative styles. For consensual and delegative styles, it showed average judgment quality. * Overall leadership adequacy landed in the upper part of the broader norm - so pretty solid results, though not exceptional. Personally, I thought it’d do much better. * Interestingly, it performed about the same even after being given a short description of the LJI model’s principles of effective leadership. ```r library(tidyverse) library(patchwork) library(ggtext) # Data with test results df <- readxl::read_xlsx("leadership_scores.xlsx") # Helper to simplify and uppercase style names clean_metric <- function(x) { x %>% str_replace("Preference of ", "") %>% str_replace("Adequacy for ", "") %>% str_replace(" style", "") %>% str_to_upper() } # Prepare filtered data pref_df <- df %>% filter(section == "Preference Scores") %>% mutate( metric = clean_metric(metric), metric = factor(metric, levels = c("DIRECTIVE", "CONSULTATIVE", "CONSENSUAL", "DELEGATIVE")) ) adequacy_df <- df %>% filter(section == "Appropriateness Scores", metric != "Overall leadership adequacy") %>% mutate( metric = clean_metric(metric), metric = factor(metric, levels = c("DIRECTIVE", "CONSULTATIVE", "CONSENSUAL", "DELEGATIVE")) ) # General plotting function with a custom title and color argument plot_style <- function(data, title, point_color) { ggplot(data, aes(x = normed_value, y = forcats::fct_rev(metric))) + geom_rect(aes(xmin = -1, xmax = 1, ymin = -Inf, ymax = Inf), fill = "lightgrey", alpha = 0.15, inherit.aes = FALSE) + geom_point(size = 3, color = point_color) + scale_x_continuous( limits = c(-3, 3), breaks = seq(-3, 3, 1), minor_breaks = seq(-3, 3, 0.5) ) + labs( x = "Normed Value (z-score)", y = NULL, title = title ) + theme_minimal(base_size = 12) + theme( panel.grid.major.y = element_blank(), panel.grid.minor = element_line(color = "grey80", linewidth = 0.3, linetype = 'dashed'), panel.grid.major.x = element_line(color = "grey80", linewidth = 0.3), plot.title = element_markdown(size = 13, face = "plain", hjust = 0, margin = margin(b = 10)), plot.title.position = "plot", axis.title.x = element_text(margin = margin(t = 5)) ) } # Assign custom colors pref_color <- "#1f77b4" adequacy_color <- "#d62728" # Build plots p1 <- plot_style( pref_df, paste0("Leadership Style Preferences"), pref_color ) p2 <- plot_style( adequacy_df, paste0("Leadership Style Adequacy"), adequacy_color ) # Combine plots side by side (p1 | p2) + plot_annotation( title = "ChatGPT-5's Results on the Leadership Judgement Indicator", theme = theme( plot.title = element_text(hjust = 0, face = "bold", size = 15) ) ) # Save the plots # ggsave("lji_genai.png", width = 8, height = 4, dpi = 700) ``` The possible takeaway? With full awareness of the obvious limitations of this small “experiment” (one model, one benchmark, one run, no special prompt engineering, etc.), it might suggest that current genAI isn’t bad at leadership judgment, but it’s not exceptional either. At the same time, it may be biased toward certain styles (“safe” and socially desirable consultation) and have blind spots (like situations requiring shared ownership and distributed accountability). If that’s true, it could have implications both for how managers should use it and for how the models should be fine-tuned if they’re meant for this specific purpose at scale. ## Figures ![](./genai-and-leadership-judgement/unnamed-chunk-1-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-22-genai-and-leadership-judgement/) on my blog. --- # The hidden trade-offs in corporate culture? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/corporate-culture-trade-offs Date: 2025-09-17 Tags: organizational-culture, network-analysis, employee-experience, glassdoor Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-17-corporate-culture-trade-offs/ Recently, I came across an [article in *The Economist*](https://www.economist.com/business/2025/07/07/does-working-from-home-kill-company-culture) analyzing ~890 firms, which suggested that employee experiences in flexible vs. full-time office setups might involve a trade-off between agility and other elements of corporate culture. Specifically, the authors found that companies mandating five days in the office scored higher on agility, but at the cost of work-life balance, support, candor, leadership trust, and a non-toxic culture (see the plot below).
![](./corporate-culture-trade-offs/economist_plot.png)
That got me wondering how these different dimensions of culture relate to each other more broadly—regardless of work arrangement—and what kinds of trade-offs might exist. To explore this, I used the same dataset referenced in [another related *Economist* piece](https://www.economist.com/interactive/business/2025/06/16/corporate-culture) comparing cultures across companies from different industries, as measured by [*CultureX*](https://www.culturex.com/) through anonymous [Glassdoor](https://www.glassdoor.com) reviews between Jan 1, 2023 and Apr 4, 2025, and applied [psychometric network analysis](https://www.nature.com/articles/s43586-021-00055-w) (EBICglasso) to estimate regularized partial correlations between individual cultural factors in the sample. ```r library(tidyverse) library(bootnet) library(igraph) library(tidygraph) library(ggraph) # uploading the data data <- read.delim("culturex_data.txt", sep = ",", header = TRUE) # changing the data format wide_data <- data %>% pivot_wider( names_from = topic, values_from = post_covid_score ) %>% # indicating that the toxic culture score were (very probably) reversed rename(`Toxic culture (R)` = `Toxic culture`) # Psychometric Network Analysis # estimating the network network_estimate <- estimateNetwork( wide_data[, 4:12], default = "EBICglasso", corMethod = "cor_auto", tuning = 0.5, # the LASSO penalty / regularization parameter threshold = TRUE # removes edges that are not statistically significant, thereby increasing specificity ) # converting the qgraph object to a tidygraph object graph_obj <- as_tbl_graph(network_estimate$graph, directed = FALSE) %>% activate(nodes) # getting the edge weights from the graph object edge_weights <- E(graph_obj)$weight # creating the network plot # setting seed for reproducibility set.seed(2024) ggraph_plot <- ggraph( graph_obj, layout = 'fr', weights = abs(edge_weights) ) + geom_edge_link( aes(color = weight, linewidth = abs(weight)), show.legend = TRUE ) + scale_edge_color_gradient2( low = "#f28e2b", mid = "gray90", high = "#4e79a7", midpoint = 0, name = "Regularized Partial Correlations" ) + scale_edge_width(name = "Abs. Regularized Partial Correlations") + geom_node_point( fill = "black", color = "black", stroke = 1, size = 4 ) + geom_node_text(aes(label = name),, size=5, repel = TRUE) + theme_graph(base_family = 'sans') + labs( title = "Psychometric Network Analysis of Corporate Culture", subtitle = "As measured by CultureX through anonymous reviews submitted by employees to Glassdoor between Jan 1st 2023-Apr 4th 2025", caption = "\nThe toxic culture score is supposed to be reversed." ) # printing the final plot print(ggraph_plot) ``` ![](./corporate-culture-trade-offs/unnamed-chunk-1-1.png) The resulting network chart shows a few interesting patterns: * Leadership sits at the center, with strong positive connections to most other cultural dimensions. * Innovation appears relatively isolated compared to the rest. * Work-life balance shows negative associations with several factors, including innovation, strategy, and agility. * Agility is negatively related to non-toxic culture, supportive culture, work-life balance, and transparency, but positively associated with leadership and strategy. Sure, the correlations are far from perfect, so you can find plenty of exceptions to these patterns in the data. But still, imo, it’s interesting food for thought and potentially useful input into considerations about shaping corporate culture in one direction or another, as prioritizing one dimension may carry opportunity costs and unintended consequences. --- P.S. If you want to replicate the analysis, run your own, or add additional external data, you can use the table below. ⚠️ But please keep in mind that I did not obtain the data officially from *CultureX*—I scraped/downloaded them from [*The Economist's* webpage](https://www.economist.com/interactive/business/2025/06/16/corporate-culture), so I cannot guarantee that the data are 100% accurate. ```r # user-friendly table with the data in wide format library(DT) datatable( wide_data %>% select(name, description, industry, everything()) %>% rename( 'Company name' = name, 'Company description' = description, 'Industry' = industry ) %>% mutate(Industry = as.factor(Industry)), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy', 'csv', 'excel'), scrollX = TRUE, selection="multiple" ) ) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-17-corporate-culture-trade-offs/) on my blog. --- # Dimensional Traits vs. Personality Types URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/dimensional-traits-vs-personality-types Date: 2025-09-09 Tags: personality, big-five, machine-learning, psychometrics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-09-dimensional-traits-vs-personality-types/ For a guest lecture I’m giving on personality in the business world, I built a [simple interactive dataviz](https://sanofi-people-analytics.shinyapps.io/personality-mapping/) that clearly demonstrates just how futile (and a bit naïve) it is to try to squeeze the huge variability of personality characteristics into a handful of types like… [insert your favorite typology 😉]. The dashboard uses a random sample of 10k Big Five profiles from [Johnson’s IPIP-NEO-300 dataset](https://osf.io/wxvth/files/osfstorage). With a 3D scatter plot, profile line chart, UMAP dimensionality reduction, and the HDBSCAN clustering algorithm, it shows that even when groups of people with similar profiles do appear—and are large enough to matter (say, at least 1% of the population)—they still show pretty high within-group variability. On top of that, a big chunk (actually, the majority) of profiles can’t be assigned to any stable cluster at all, ending up as “noise” points in HDBSCAN’s terms.
![](./dimensional-traits-vs-personality-types/interactive_dataviz.gif)
If you find it useful for your teaching or training, check out the dataviz [here](https://sanofi-people-analytics.shinyapps.io/personality-mapping/). If you want to download the data and code to run it locally, take a look at [this GitHub repo](https://github.com/lstehlik2809/personality-mapping.git). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-09-dimensional-traits-vs-personality-types/) on my blog. --- # When to forgive, when to close the book URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/trust-errors-learning-reflection Date: 2025-09-09 Tags: statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-09-trust-errors-learning-reflection/ Supposedly, great minds can entertain contradictory ideas at once with ease. I’d love to think I’m one of them, but the truth is I even struggle to keep reading books on new, complex topics when the author makes basic mistakes in areas I actually do still understand. I know I shouldn’t throw the baby out with the bathwater, but it gives me trust issues: what if the author makes similar mistakes in the parts I don’t understand? 😯 I’m curious — do you have a rule of thumb for this? When do you forgive, and when do you close the book? For example, a friend of mine once said (not a literal quote): “Treat it like a physics lecture — let ‘electrons orbit like planets’ slide, but walk out when someone brings astrology or chakras into quantum mechanics.” 🙃 Currently, I see it similarly - for me, the dividing line is whether the mistake feels like a teaching shortcut or a conceptual blind spot. If it’s a simplification to ease the reader in, I can forgive it. If it betrays a lack of rigor, I usually stop reading. But I must admit that sometimes it’s not easy to tell the difference in the moment — which is why I’m curious how others draw their line. P.S. This rant-ish post was prompted by my recent reading on causal inference, where the author repeatedly described p-values as the probability that the null hypothesis is true. I was really surprised that an expert in econometrics can still get this wrong — funnily enough, even GenAIs these days seem to get it right (as shown by a bit of ChatGPT’s feedback on the problematic passage below).
![](./trust-errors-learning-reflection/chatgpt_check.png)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-09-trust-errors-learning-reflection/) on my blog. --- # Big Five vs. HEXACO URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/big-five-vs-hexaco Date: 2025-09-02 Tags: personality, big-five, validity, performance, i-o-psychology Original post: https://blog-about-people-analytics.netlify.app/posts/2025-09-02-big-five-vs-hexaco/ One of my old work friends asked me recently whether I’d recommend a [Big Five–](https://en.wikipedia.org/wiki/Big_Five_personality_traits) or [HEXACO](https://en.wikipedia.org/wiki/HEXACO_model_of_personality_structure)-based psychometric tool for employee selection, with predictive validity as the main criterion. Not being a super-expert on this topic, I told her that AFAIK they’re more or less equivalent, with HEXACO having that extra Honesty–Humility dimension, so if counterproductive work behavior (CWB) is a big deal for them, HEXACO might have a slight edge over the Big Five. To double-check my reasoning and advice, I dug into the available evidence and luckily came across a solid meta-analytic review by [Pletzer & Abrahams (2025)](https://www.sciencedirect.com/science/article/pii/S2352250X25000673) that compares the predictive validity of these two models for task performance and CWB, among other things. Interestingly, when you look at the overlapping dimensions one by one, the Big Five seems to have a slight edge in predicting both task performance and CWB—see the charts based on the paper’s data below. ```r # uploading library for using Python in RMarkdown library(reticulate) ``` ```python import numpy as np import matplotlib.pyplot as plt def create_dumbbell_chart_combined(ax, data, title): """ Creates a dumbbell chart on the provided Axes object. """ # Extract data traits = list(data.keys()) b5_values = np.array([v[0] for v in data.values()], dtype=float) hexaco_values = np.array([v[1] for v in data.values()], dtype=float) # Sort by mean (ignoring NaNs) mean_values = np.array([np.nanmean(v) for v in data.values()], dtype=float) sorted_idx = np.argsort(mean_values) sorted_traits = [traits[i] for i in sorted_idx] sorted_b5 = b5_values[sorted_idx] sorted_hexaco = hexaco_values[sorted_idx] y = np.arange(len(sorted_traits)) # Masks for finite values finite_b5 = np.isfinite(sorted_b5) finite_hex = np.isfinite(sorted_hexaco) finite_both = finite_b5 & finite_hex # Lines only where both ends exist ax.hlines(y=y[finite_both], xmin=sorted_b5[finite_both], xmax=sorted_hexaco[finite_both], color='grey', alpha=0.6) # Points (each side independently) ax.scatter(sorted_b5[finite_b5], y[finite_b5], color='#3c78d8', s=150, label='Big Five', zorder=3) ax.scatter(sorted_hexaco[finite_hex], y[finite_hex], color='#e06666', s=150, label='HEXACO', zorder=3) # Value labels — only when finite for i in range(len(sorted_traits)): if np.isfinite(sorted_b5[i]): ax.text(sorted_b5[i], y[i] + 0.2, f'{sorted_b5[i]:.2f}', ha='center', va='bottom', fontsize=10) if np.isfinite(sorted_hexaco[i]): ax.text(sorted_hexaco[i], y[i] + 0.2, f'{sorted_hexaco[i]:.2f}', ha='center', va='bottom', fontsize=10) # Formatting ax.set_yticks(y) ax.set_yticklabels(sorted_traits, fontsize=12) ax.set_xlabel('Absolute Value of Meta-Analytic Correlation Coefficient', fontsize=12) ax.set_title(title, fontsize=16, pad=50) ax.legend(loc='lower center', bbox_to_anchor=(0.5, 1.02), ncol=2, frameon=False, fontsize=12) ax.axvline(x=0, color='black', linestyle='--', linewidth=1, alpha=0.7) ax.grid(axis='x', linestyle='--', alpha=0.7) for spine in ('top','right','left','bottom'): ax.spines[spine].set_visible(False) # --- Data from the Study --- task_performance_data = { 'Openness': [abs(0.09), abs(0.11)], 'Conscientiousness': [abs(0.19), abs(0.24)], 'Extraversion': [abs(0.10), abs(0.08)], 'Agreeableness': [abs(0.08), abs(0.01)], 'Neuroticism / Emotionality': [abs(-0.07), abs(0.03)], 'Honesty-Humility': [np.NaN, abs(0.06)] } cwb_data = { 'Openness': [abs(-0.07), abs(-0.05)], 'Conscientiousness': [abs(-0.31), abs(-0.33)], 'Extraversion': [abs(-0.04), abs(-0.08)], 'Agreeableness': [abs(-0.29), abs(-0.17)], 'Neuroticism / Emotionality': [abs(0.16), abs(-0.08)], 'Honesty-Humility': [np.NaN, abs(-0.35)] } # --- Generate the Combined Chart --- fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 12)) create_dumbbell_chart_combined( ax1, task_performance_data, 'Predictive Power for Task Performance' ) create_dumbbell_chart_combined( ax2, cwb_data, 'Predictive Power for Counterproductive Work Behavior (CWB)' ) plt.tight_layout(pad=3.0) plt.show() ``` ![](./big-five-vs-hexaco/unnamed-chunk-2-1.png) *Note: The charts aren’t from the paper itself—I made them using the paper’s data, so any discrepancies are on me.* But when you combine inter-correlations from the sample-size–weighted meta-analytic correlation matrix below and estimate overall explained variance, the conclusion flips: HEXACO outperforms the Big Five in predicting both CWB (20.1%* vs. 14.9%) and task performance (6.7% vs. 4.3%). \* *When estimating the overall explained variance of outcome variables by individual personality models, using the formula $R^2 = R_{xy}^T R_{xx}^{-1} R_{xy}$, where $R_{xx}$ is the correlation matrix of the predictor traits (e.g., the 5x5 matrix for the Big Five) and $R_{xy}$ is the vector of correlations between each trait and the outcome (CWB or task performance), I obtained a slightly higher estimate of 20.3%. This could be due to a small bug in the code (see the snippet below), but since the other three estimates are identical, it may instead result from a minor typo in the authors’ paper.* 🤔 ```python import numpy as np # --- Big Five Analysis for CWB --- # Define the predictor inter-correlation matrix (Rxx) for the Big Five traits # Order: Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism R_xx_b5 = np.array([ [1.00, 0.10, 0.25, 0.18, -0.08], [0.10, 1.00, 0.23, 0.27, -0.29], [0.25, 0.23, 1.00, 0.19, -0.34], [0.18, 0.27, 0.19, 1.00, -0.24], [-0.08, -0.29, -0.34, -0.24, 1.00] ]) # Define the vector of correlations between predictors and outcome (Rxy) # CWB correlations for Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism R_xy_b5 = np.array([-0.07, -0.31, -0.04, -0.29, 0.16]) # Calculate R-squared using the formula: R^2 = Rxy^T * Rxx^-1 * Rxy R_xx_b5_inv = np.linalg.inv(R_xx_b5) R_squared_b5 = R_xy_b5.T @ R_xx_b5_inv @ R_xy_b5 # --- HEXACO Analysis for CWB --- # Define the predictor inter-correlation matrix (Rxx) for the HEXACO traits # Order: Honesty-Humility, Emotionality, eXtraversion, Agreeableness, Conscientiousness, Openness R_xx_hexaco = np.array([ [1.00, 0.04, 0.01, 0.30, 0.20, 0.10], [0.04, 1.00, -0.14, -0.12, 0.03, -0.06], [0.01, -0.14, 1.00, 0.16, 0.17, 0.16], [0.30, -0.12, 0.16, 1.00, 0.07, 0.07], [0.20, 0.03, 0.17, 0.07, 1.00, 0.11], [0.10, -0.06, 0.16, 0.07, 0.11, 1.00] ]) # Define the vector of correlations between predictors and outcome (Rxy) # CWB correlations for HH, E, X, A, C, O R_xy_hexaco = np.array([-0.35, -0.08, -0.08, -0.17, -0.33, -0.05]) # Calculate R-squared R_xx_hexaco_inv = np.linalg.inv(R_xx_hexaco) R_squared_hexaco = R_xy_hexaco.T @ R_xx_hexaco_inv @ R_xy_hexaco # --- Print Results --- print("--- Replication of Explained Variance (R^2) for CWB ---") print(f"Big Five Model: {R_squared_b5:.1%}") print(f"HEXACO Model: {R_squared_hexaco:.1%}") # --- Big Five Analysis for Task Performance --- # Define the predictor inter-correlation matrix (Rxx) for the Big Five traits # This matrix remains the same as in the CWB analysis. # Order: Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism R_xx_b5 = np.array([ [1.00, 0.10, 0.25, 0.18, -0.08], [0.10, 1.00, 0.23, 0.27, -0.29], [0.25, 0.23, 1.00, 0.19, -0.34], [0.18, 0.27, 0.19, 1.00, -0.24], [-0.08, -0.29, -0.34, -0.24, 1.00] ]) # Define the vector of correlations between predictors and outcome (Rxy) # Task Performance correlations for Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism R_xy_b5_tp = np.array([0.09, 0.19, 0.10, 0.08, -0.07]) # Calculate R-squared using the formula: R^2 = Rxy^T * Rxx^-1 * Rxy R_xx_b5_inv = np.linalg.inv(R_xx_b5) R_squared_b5_tp = R_xy_b5_tp.T @ R_xx_b5_inv @ R_xy_b5_tp # --- HEXACO Analysis for Task Performance --- # Define the predictor inter-correlation matrix (Rxx) for the HEXACO traits # This matrix also remains the same. # Order: Honesty-Humility, Emotionality, eXtraversion, Agreeableness, Conscientiousness, Openness R_xx_hexaco = np.array([ [1.00, 0.04, 0.01, 0.30, 0.20, 0.10], [0.04, 1.00, -0.14, -0.12, 0.03, -0.06], [0.01, -0.14, 1.00, 0.16, 0.17, 0.16], [0.30, -0.12, 0.16, 1.00, 0.07, 0.07], [0.20, 0.03, 0.17, 0.07, 1.00, 0.11], [0.10, -0.06, 0.16, 0.07, 0.11, 1.00] ]) # Define the vector of correlations between predictors and outcome (Rxy) # Task Performance correlations for HH, E, X, A, C, O R_xy_hexaco_tp = np.array([0.06, 0.03, 0.08, 0.01, 0.24, 0.11]) # Calculate R-squared R_xx_hexaco_inv = np.linalg.inv(R_xx_hexaco) R_squared_hexaco_tp = R_xy_hexaco_tp.T @ R_xx_hexaco_inv @ R_xy_hexaco_tp # --- Print Results --- print("--- Replication of Explained Variance (R^2) for Task Performance ---") print(f"Big Five Model: {R_squared_b5_tp:.1%}") print(f"HEXACO Model: {R_squared_hexaco_tp:.1%}") ```
![](./big-five-vs-hexaco/table.png)
The reason seems to be that HEXACO includes the extra Honesty–Humility trait (which captures ethical variance and is a decent predictor of negative workplace behaviors), shows higher predictive validity for the generally most predictive trait (Conscientiousness), and has less inter-correlation between traits (so their predictive power overlaps less). Curious about others’ experiences with switching from the Big Five to HEXACO. What motivated the change for you, and was it worth it? Asking for a friend (and myself) 🙏🙂 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-09-02-big-five-vs-hexaco/) on my blog. --- # Want to maximize your impact as a leader? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/impact-of-leaders Date: 2025-08-21 Tags: leadership, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2025-08-21-impact-of-leaders/ At least, that’s the somewhat unexpected takeaway from a [2021 study by Berry & Fowler](https://www.science.org/doi/10.1126/sciadv.abe3404) who found that… * sports coaches in football, basketball, baseball, and hockey have the largest measurable effects on their teams’ outcomes (wins, points scored, defensive records, etc.); * political leaders in autocracies and transitional regimes can significantly shape economic growth; at the subnational level, U.S. governors influence state finances and crime rates but not broader economic outcomes like income or employment; U.S. mayors, however, showed no detectable effect on their city's economy, public finances, or crime rates; and * CEOs show surprisingly little systematic influence on their organizations' performance, with the study finding no statistically significant effects for eight out of the nine firm outcomes it tested. The authors suggest these patterns may arise because the clarity of goals and direct impact of decisions in sports make coaching influence highly visible, while in politics the strength of institutions matters: in democracies, good institutions constrain leaders and reduce differences between them, but in autocracies or transitional regimes, individual leaders have more room to shape outcomes. In business, meanwhile, the lack of measurable CEO effects could be explained by institutional constraints, homogeneous selection of highly capable candidates, and strong external factors (like markets and industry cycles) that swamp individual leadership differences. Curious whether the findings for CEOs would hold if the analysis went beyond large, established firms with professional executives to also include start/scale-ups, where leadership might matter more given fewer institutional constraints, less mature organizational routines, and greater dependence on founders’ decisions. Also super interesting is the new method the authors developed, called RIFLE (Randomization Inference for Leader Effects) - designed specifically to test whether leaders truly matter or whether outcomes are just the result of luck and timing. Its logic is relatively straightforward: first, measure how much variation in outcomes (GDP growth, firm profits, team wins) seems linked to leaders in the real data. Then create hundreds of “alternative worlds” by shuffling the order of leaders’ tenures while keeping tenure length, broader trends, environmental difficulty, and performance streaks exactly the same. In these reshuffled worlds, leaders are random - so any differences come only from chance and momentum. If the real-world pattern is stronger than in these simulated worlds, that’s evidence of genuine leadership impact. In this way, RIFLE improves on past methods: it avoids confusing streaks or trends with skill (a common flaw in standard regressions or variance decompositions) and makes use of all available data, unlike rare “natural experiments” such as leaders dying in office. The chart below, from the paper, shows the method in action with famous NFL coach [Bill Belichick](https://en.wikipedia.org/wiki/Bill_Belichick). The histogram represents the distribution of the best records achievable over 17 seasons if outcomes were due to luck alone. The red line is Belichick's actual record - showing his success is an extreme statistical outlier, not just a lucky streak.
![](./impact-of-leaders/plot.png)
The key innovation is that RIFLE keeps the structure of the data intact while only randomizing who was in charge. That makes it possible to separate true leadership effects from the noise of luck and circumstance. While it's not a pure experiment, this method provides evidence that goes beyond simple correlation by ruling out some key alternative explanations, making a more confident, causal-like claim about a leader's actual impact. I wonder if anyone in the people analytics space has tried applying this method to a PA-related use case. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-08-21-impact-of-leaders/) on my blog. --- # Tracking talent moves in the (in)famous 9-box grid over time URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/9-box-grid-dataviz-over-time Date: 2025-08-20 Tags: data-visualization, hr-management Original post: https://blog-about-people-analytics.netlify.app/posts/2025-08-20-9-box-grid-dataviz-over-time/ In one of my projects, I’m really struggling to find an effective and intuitive way to visualize YoY movements of people within the [9-box grid](https://www.aihr.com/blog/9-box-grid/) - a well-known talent management tool combining information about people’s performance and potential. Putting aside all the conceptual and philosophical objections to the 9-box - has anyone come across a good way to visualize this? Or had a good experience with something that worked? I tried a Sankey chart, which usually does a great job at showing transitions, but in this case it didn’t preserve the “2D logic” behind the grid - or at least I couldn’t figure out how to make it work. The best idea I’ve got so far is shown below, including the R code behind it. It sort of does the job, but only if I visualize it cell by cell, which isn’t ideal, especially outside the dashboard environment 🫤 ```r # --- 1. SETUP --- # Load libraries library(tidyverse) # --- SET PARAMETERS FOR STARTING CELL --- # Change these values (from 1 to 3) to filter for a different starting cell. # 1 = Low, 2 = Medium, 3 = High start_perf_filter <- 1 start_pot_filter <- 1 # --- 2. SAMPLE DATA GENERATION --- # Set a seed for reproducibility of the random data set.seed(42) n_employees <- 1000 # Helper function to generate scores generate_scores <- function(n) { score1 <- sample(1:3, n, replace = TRUE) score2 <- sapply(score1, function(s) { change <- runif(1) if (change < 0.6) s # 60% chance to stay else if (change < 0.8) min(3, s + 1) # 20% chance to increase else max(1, s - 1) # 20% chance to decrease }) list(score1 = score1, score2 = score2) } # Create the initial wide-format data frame perf <- generate_scores(n_employees) pot <- generate_scores(n_employees) talent_data_wide <- data.frame( id = 1:n_employees, # Using numeric IDs instead of names year1_perf = perf$score1, year1_pot = pot$score1, year2_perf = perf$score2, year2_pot = pot$score2 ) # --- 3. DATA PROCESSING, SIMULATING LEAVERS & NEW HIRES --- # Identify employees who started in the specified cell source_employee_ids <- talent_data_wide %>% filter(year1_perf == start_perf_filter & year1_pot == start_pot_filter) %>% pull(id) # Simulate leavers: randomly select 15% of source employees to have NA in year 2 leaver_ids <- sample(source_employee_ids, size = floor(0.15 * length(source_employee_ids))) talent_data_wide <- talent_data_wide %>% mutate( year2_perf = ifelse(id %in% leaver_ids, NA, year2_perf), year2_pot = ifelse(id %in% leaver_ids, NA, year2_pot) ) n_leavers <- length(leaver_ids) # Simulate new hires: add 10 new people assigned to the source cell in year 2 n_new_hires <- 10 new_hires_data <- tibble( id = (n_employees + 1):(n_employees + n_new_hires), year1_perf = NA, year1_pot = NA, year2_perf = start_perf_filter, year2_pot = start_pot_filter ) # Combine original data with new hires talent_data_wide <- bind_rows(talent_data_wide, new_hires_data) # Filter for employees who started in the source cell and have data for both years ("Movers") movers_data <- talent_data_wide %>% filter(id %in% source_employee_ids, !is.na(year2_perf)) # Calculate transition percentages for labels based only on "Movers" total_movers <- nrow(movers_data) label_data <- movers_data %>% group_by(year2_perf, year2_pot) %>% summarise(count = n(), .groups = 'drop') %>% mutate( percentage = count / total_movers * 100, label_text = sprintf("%.1f%%", percentage), x_pos = year2_perf - 0.1, y_pos = year2_pot - 0.1 ) # Prepare data for plotting trajectories (only for "Movers") jitter_amount <- 0.3 movers_long <- movers_data %>% pivot_longer(cols = -id, names_to = c("year", ".value"), names_pattern = "year(.)_(.*)") %>% mutate( perf_jitter = perf - 0.5 + runif(n(), -jitter_amount, jitter_amount), pot_jitter = pot - 0.5 + runif(n(), -jitter_amount, jitter_amount), year = factor(year, levels = c("1", "2"), labels = c("2024", "2025")) ) # Prepare data for plotting new hires in 2025 new_hires_long <- new_hires_data %>% pivot_longer(cols = -id, names_to = c("year", ".value"), names_pattern = "year(.)_(.*)") %>% filter(!is.na(perf)) %>% mutate( perf_jitter = perf - 0.5 + runif(n(), -jitter_amount, jitter_amount), pot_jitter = pot - 0.5 + runif(n(), -jitter_amount, jitter_amount), year = "2025" ) # --- 4. CHART GENERATION --- axis_labels <- c("Low", "Medium", "High") axis_breaks <- c(0.5, 1.5, 2.5) ggplot() + geom_vline(xintercept = c(0, 1, 2, 3), color = "grey85", linetype = "dashed") + geom_hline(yintercept = c(0, 1, 2, 3), color = "grey85", linetype = "dashed") + # Add connector lines and points for "Movers" geom_line(data = movers_long, aes(x = perf_jitter, y = pot_jitter, group = id), color = "grey50", alpha = 0.5) + geom_point(data = movers_long, aes(x = perf_jitter, y = pot_jitter, color = year), size = 3, alpha = 0.6) + # Add points for "New Hires" in 2025 (using a different shape) geom_point(data = new_hires_long, aes(x = perf_jitter, y = pot_jitter), color = "#d62728", shape = 17, size = 4, alpha = 0.8) + # Add percentage labels geom_text( data = label_data, aes(x = x_pos, y = y_pos, label = label_text), fontface = "bold", color = "black", size = 4, hjust = 1, vjust = 1 ) + scale_color_manual(values = c("2024" = "#007acc", "2025" = "#d62728")) + scale_x_continuous(name = "Performance", limits = c(0, 3), breaks = axis_breaks, labels = axis_labels) + scale_y_continuous(name = "Potential", limits = c(0, 3), breaks = axis_breaks, labels = axis_labels) + labs( title = "Talent Trajectories", subtitle = sprintf("Visualizing movement from Perf: %s / Pot: %s \n(N=%d Movers, %d Leavers, %d New Hires)", axis_labels[start_perf_filter], axis_labels[start_pot_filter], total_movers, n_leavers, n_new_hires), caption = "Note: Percentages in cells refer to the destination of 'Movers' only.\nNew Hires in 2025 are shown as triangles.", color = "Year" ) + theme_minimal() + theme( plot.title = element_text(size = 18, face = "bold"), plot.subtitle = element_text(size = 12, color = "grey30"), legend.position = "top", panel.grid.major = element_blank(), panel.grid.minor = element_blank(), aspect.ratio = 1, plot.background = element_rect(fill="white", color = "white") ) ``` ![](./9-box-grid-dataviz-over-time/unnamed-chunk-1-1.png) Would be super grateful for any tips or suggestions on dataviz tools or approaches worth exploring 🙏 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-08-20-9-box-grid-dataviz-over-time/) on my blog. --- # Adjusting priors on your “emotional radar” for smoother cross-cultural encounters URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/expressive-differences-around-the-world Date: 2025-08-20 Tags: communication, cross-cultural-differences Original post: https://blog-about-people-analytics.netlify.app/posts/2025-08-20-expressive-differences-around-the-world/ I know the summer holidays - often spent abroad, outside our home countries - are wrapping up for many of us, but some of you might still find it handy to know what research by [Matsumoto et al. (2008)](https://www.researchgate.net/publication/259383504_Mapping_expressive_differences_around_the_world) says about cultural differences in emotional expressivity. They found some universal patterns - for example, pretty much everywhere, people tend to show more emotion with close friends (in-groups) than with acquaintances (out-groups). But there are also clear cultural differences. Folks from more individualistic cultures often express emotions more openly, especially positive ones like happiness and surprise, and may show more warmth toward out-groups than people from more collectivistic cultures (see chart from the paper below).
![](./expressive-differences-around-the-world/chart_scatterplot.png)
Sure, there’s still a lot of variation within each country, and individual differences can be as big as the ones between countries. Still, keeping these broad patterns in mind and adjusting the priors on your “emotional radar” a little when you’re interacting across cultures may help you be less likely to over- or underestimate what someone’s really feeling. \#HappySummer 😎 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-08-20-expressive-differences-around-the-world/) on my blog. --- # Happy today, richer tomorrow? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/happiness-and-income Date: 2025-07-31 Tags: well-being, psychology, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2025-07-31-happiness-and-income/ My personal "edu bot", whose task is to supply me with engaging articles from behavioral science, recently served me - quite fittingly for the summer season (at least here in the Northern Hemisphere) - an interesting study by [De Neve & Oswald (2013)](https://www.pnas.org/doi/10.1073/pnas.1211437109) that flipped the usual question from "*Does money buy happiness?*" to "*Does happiness create money?*". The study measured two key components of well-being - *positive affect* (everyday positive emotions) and *life satisfaction* (overall life evaluation) - to see if they influenced later income. The authors used several methodological tweaks to get as close as possible to making a causal claim: * **Longitudinal panel data:** The study tracked the same people (adolescents and young adults) for years, measuring their happiness multiple times before recording their income, thereby reducing the risk of reverse causality. * **Sibling fixed-effects model:** Instead of comparing unrelated people, the study compared siblings, effectively controlling for family background, parenting style, and even genetic factors. * **Rich controls:** Factors such as IQ, education, physical health, height, gender, ethnicity, and self-esteem were included to isolate happiness from other potential drivers of financial success - with self-esteem, interestingly, helping to disentangle happiness from feelings rooted in anticipated future success. * **Later happiness control:** Happiness was also measured at the same time as income to control for stable, latent "happy personality" trait. * **Mediation analysis:** The authors also examined which mechanisms may mediate the effect of happiness on income. And here are their key findings: * **Happiness provides a direct financial edge:** When comparing siblings, a one-standard-deviation increase in an individual's life satisfaction at age 22 boosted their earnings at age 29 by 6%. * **The monetary value is substantial:** A single-point increase in life satisfaction (on a 5-point scale) translated into nearly $4,000 more in annual income compared to a less happy sibling, even after accounting for other factors. * **Happiness works through multiple pathways:** Significant indirect/mediating pathways include a higher probability of obtaining a college degree, getting hired and promoted, having higher degrees of optimism and extraversion, and less neuroticism. Collectively, these factors explain a massive 68-78% of the total effect.
![](./happiness-and-income/results_table_marked.png)
These results thus suggest that happiness isn’t merely a byproduct of financial success or a simple ‘feel-good’ state - it also appears to create the psychological and social conditions that make higher earnings more likely. ⚠️ Sure, there are limitations - e.g., income was measured only once and early in the career, so we don’t know if the effect lasts, not all unobserved factors could be completely ruled out, even with sibling comparisons, etc. But here’s the good news: even if the causal link is weaker than it seems, boosting our happiness is still worth it for its own sake. Right? 😉 \#HappySummer --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-07-31-happiness-and-income/) on my blog. --- # How personality risks co-occur? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/how-personality-risks-co-occur Date: 2025-07-15 Tags: leadership, personality, psychometrics, network-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-07-15-how-personality-risks-co-occur/ Some of you might be familiar with the psychometric tool [Hogan Development Survey (HDS)](https://www.hogandarkside.com/?lang=en), which measures the so‑called “dark side” of personality—essentially strengths overused under stress or when self-control wears thin, leading to behaviors that can derail performance, relationships, and leadership effectiveness. For example, a skeptical person’s vigilance and critical thinking can morph into distrust, negativity, or expecting betrayal, while a colorful person’s charisma and expressiveness can shift into being dramatic, attention-seeking, and overly bold.
![](./how-personality-risks-co-occur/hds_scales_description.png)
HDS scale descriptions taken from a sample HoganLead (Challenge) report found online. While working on a team report based on HDS results, I got curious about how higher-risk scores (70th percentile and above)—both individually and in combos—tend to show up across HDS profiles in the broader population. Luckily, I had access to a recent large HDS dataset (N = 33,078) from the CEE region, drawn predominantly, though not exclusively, from current or aspiring managers, which allowed me to dig into the data and run some descriptive stats. Here are some quick highlights from the data: * **Top three derailers**: Diligent (50%), Dutiful (43%), Bold (41%). IMO, not a big surprise given that succeeding as a manager often requires delivering high-quality work, playing it nice with people in power, and showing confidence. * **Bottom three derailers**: Excitable (27%), Cautious (28%), Mischievous (29%). Personally, I would’ve expected a higher prevalence of Excitable and Mischievous among managers… though maybe that’s just the availability heuristic at work 😉 * **Top three pairs**: Diligent + Dutiful (26%), Diligent + Bold (24%), Skeptical + Leisurely (23%). Congruent with the first point, but interesting to see a combo of distrust and passive-resistance here. * **Top three triplets**: Diligent + Skeptical + Leisurely (14%), Bold + Skeptical + Leisurely (13%), Bold + Skeptical + Diligent (12%). Interesting patterns reinforcing the central role of distrust and independence, paired with excessive assertiveness and control. * **Top derailers based on the strength centrality measure** (i.e., derailers that co-occur with others most often—you can easily spot them in the center of the network graph below): Diligent, Skeptical, Leisurely, and Bold. Consistent with the pattern above. * Community detection algo partially reconstructed—for those familiar with HDS, well-known—three clusters of behaviors: **Moving Against** (Bold, Mischievous, Colorful, and Imaginative) VS. **Moving Away** (Excitable, Skeptical, Cautious, Reserved, and Leisurely) + **Moving Toward** (Diligent and Dutiful).
![](./how-personality-risks-co-occur/hds_network_plot.png)
Anything that surprised you or caught your attention? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-07-15-how-personality-risks-co-occur/) on my blog. --- # What does a typical people manager value chain look like? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-manager-value-chain Date: 2025-07-09 Tags: people-management, hr-management Original post: https://blog-about-people-analytics.netlify.app/posts/2025-07-09-people-manager-value-chain/ One of the projects I’m currently working on involves generating specific people-related insights to help managers be more successful at what they do—delivering results through others. As you can imagine, it would be extremely useful to have a well-established and thoroughly researched model of a people manager value chain that clearly describes how individual managers contribute to organizational effectiveness. Unfortunately, my search for such a model wasn’t very fruitful—I mostly found isolated pieces of information and evidence. The notable exception was a [2010 article, “The Leadership Value Chain,” by Kaiser & Overfield](https://www.researchgate.net/publication/238318590_The_Leadership_Value_Chain). They provided a comprehensive framework showing how leadership creates value, moving from individual leader characteristics through leadership style, team processes, unit results, and ultimately organizational effectiveness. However, their framework wasn’t detailed enough and lacked specific day-to-day managerial activities, which we needed to inspire practical ideas for our specific use case. So, using the sources I found, I decided to put together my own model—a draft of which is attached. It starts with specific activities or processes that people managers more or less control or influence, driving people-related outcomes, which in turn fuel team-level results and ultimately contribute to firm performance.
![](./people-manager-value-chain/people_manager_value_chain.png)
Re methodology, I started with existing HRM value chain models (e.g., [Paauwe & Richardson, 1997](https://www.tandfonline.com/doi/abs/10.1080/095851997341621?journalCode=rijh20); [Hunt, 2014](https://www.amazon.com/Common-Sense-Talent-Management-Performance/dp/0470442417)) and research on the impact of HRM practices on business outcomes (e.g., [Huselid, 1995](https://www.researchgate.net/publication/228215412_The_Impact_of_Human_Resource_Management_Practices_on_Turnover_and_Productivity); [Combs et al., 2006](https://onlinelibrary.wiley.com/doi/10.1111/j.1744-6570.2006.00045.x?msockid=0007c754846062a23e99d26b850b6305); [Subramony, 2009](https://onlinelibrary.wiley.com/doi/10.1002/hrm.20315?msockid=0007c754846062a23e99d26b850b6305); [Jiang et al., 2012](https://journals.aom.org/doi/abs/10.5465/amj.2011.0088)), then distilled from specific HRM activities and processes the kinds of managerial actions that naturally overlap with or are part of them—things like coaching, mentoring, job design, feedback, goal setting, etc., and added a few extra elements based on other studies (e.g., [Jo & Hong’s 2022](https://www.frontiersin.org/journals/psychology/articles/10.3389/fpsyg.2022.900830/full) study on learning agility and innovative behavior) and plain common sense (e.g., people need functioning tools to do their job). For selected elements, I also checked existing evidence of their relationships—for instance, job crafting as a predictor of employee retention, using [Rubenstein et al.’s (2017)](https://onlinelibrary.wiley.com/doi/full/10.1111/peps.12226) meta-analysis on voluntary turnover. Like any model, it’s a simplification of reality. It undoubtedly misses some key factors due to the chosen level of granularity or simply my ignorance. It’s static and linear, it doesn’t capture external moderators, and the relationships among many elements are in reality multi-directional and reciprocal. Nevertheless, I believe the model can serve as a useful starting point for discussions and decision-making regarding manager selection, development, performance appraisals, or broader organizational interventions. Would love to hear if you’ve come across any solid sources on this topic—or if you’ve tried building something similar in your own organization and are up for sharing. Also, feel free to suggest any key factors you think are missing from this draft—or maybe don’t belong there at all. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-07-09-people-manager-value-chain/) on my blog. --- # When self-selected behavior is a blessing, not a headache URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/self-selection-and-proxy-measures Date: 2025-07-03 Tags: psychometrics, causal-inference, people-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-07-03-self-selection-and-proxy-measures/ Situations where people act on their own can be a nightmare for a researcher trying to tease apart the causal effects of specific factors of interest. At the same time, though, such situations can be a valuable opportunity to indirectly tap into people’s otherwise unobserved characteristics or states that may impact or predict outcomes of interest. Here are just a few examples from my own recent analytical practice: 1. Shown interest in gig participation as a signal of lower satisfaction with career development opportunities (see illustrative PDP plot below). 2. The number of self-reported job skills as a proxy for a mix of traits—probably ambition, conscientiousness, learning agility, and others—that predict future performance evaluations. 3. Having completed an individual development plan as another proxy for internal drivers like ambition and learning orientation. 4. Team non/participation in the employee survey as a proxy for team engagement, which in turn predicts individual-level voluntary attrition.
![](./self-selection-and-proxy-measures/growth_gig_plot.png)
Sure, such proxy measures aren’t perfect or clean representations of the variables of interest and, as such, require careful interpretation—ideally supported by empirical validation. That said, their imperfections usually don’t stop them from being useful, to paraphrase the famous George Box’s aphorism. Curious to hear how you have used indirect or behavioral indicators in your work—what creative proxies have you leveraged? Feel free to share in the comments for inspiration. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-07-03-self-selection-and-proxy-measures/) on my blog. --- # Are you crazy enough and in the right way to fit the craziness required by your job? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-fit Date: 2025-06-27 Tags: career-development, cognitive-biases Original post: https://blog-about-people-analytics.netlify.app/posts/2025-06-27-job-fit/ Just read a funny and sharp [article on job fit by Adam Mastroianni](https://open.substack.com/pub/experimentalhistory/p/face-it-youre-a-crazy-person?utm_source=share&utm_medium=android&r=9ce6l), packed with practical wisdom. Here are some of my key takeaways: 🚁 We usually describe our jobs in a shallow, high-level way—saying things like, “I’m a liaison between development and sales,” or imagining a professor as someone who just strolls around in a tweed jacket. Classic *[cognitive economy](https://listen-hard.com/applied-psychology-and-everyday-life/cognitive-economy-psychology-mental-resources/#What_Is_Cognitive_Economy_in_Psychology)*: our brains flatten all the complex details to keep things simple and manageable, but there’s always the risk of leaving out some key details. 🙈 The catch? Most of us fall for the *[illusion of explanatory depth](https://en.wikipedia.org/wiki/Illusion_of_explanatory_depth#:~:text=The%20illusion%20of%20explanatory%20depth%20%28IOED%29%20is%20cognitive,that%20involves%20complex%20causal%20patterns%22%20%28see%20causal%20reasoning%29.)*—we think we deeply understand what a job is, but we don’t really ‘get it’ until we dig into the specifics. 📦 To really know what a job’s like, you need to unpack it. That means asking about the actual day-to-day tasks. If you want to run a café, do you care about sourcing beans, handling 6am staffing crises, or picking the right point-of-sale software? If you’re not interested in these details, maybe that job isn’t for you. 👥 When at a job interview, ask people, “What did you do this morning?” or “What will you do after this meeting?”—not just what their job sounds like, but what they actually do. Then ask yourself, “Would I enjoy those tasks, or just tolerate them?” 🤪 Once you unpack a role, you see that every job needs its own kind of “crazy.” Surgeons repeat the same procedure for decades. Successful novelists write five books a year. 📊 Truth is, we’re all crazy in some way—usually not clinically, but statistically. We just don’t notice, since, due to the *[false consensus effect](https://simple.wikipedia.org/wiki/False_consensus)*, we assume our preferences are more or less normal, but most people wouldn’t enjoy what we love. 👩‍🔧 The fix: Ask yourself more concrete questions. Not, “Do I want to be a professor?” but, “Do I want to write research papers all day?” Not, “Should I run a café?” but, “Do I want to deal with staffing and supplies every morning?” Don’t ask if you like the idea of a job—ask if you’ll actually like the reality of the daily work. To sum it up: Find out your unique flavor of crazy, and match it to the job. Don’t trust the highlight reel—dig into the reality. Definitely [worth reading](https://open.substack.com/pub/experimentalhistory/p/face-it-youre-a-crazy-person?utm_source=share&utm_medium=android&r=9ce6l)! --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-06-27-job-fit/) on my blog. --- # Using Doppelgänger for career pathing? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/doppelganger-for-career-pathing Date: 2025-06-20 Tags: career-development, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2025-06-20-doppelganger-for-career-pathing/ While working with Cole Napper on an [article about causal inference in People Analytics](https://blog-about-people-analytics.netlify.app/posts/2025-05-21-causal-inference-in-people-analytics/), it came to my mind that the so-called *Doppelgänger* technique could inspire a potentially handy tool for helping employees map career options within their organization. As many of you know, the *Doppelgänger* technique is about finding a [synthetic comparison unit](https://en.wikipedia.org/wiki/Synthetic_control_method) that closely mirrors the treated unit before an intervention, enabling us to estimate what would have happened in its absence. Think of the UK and the Brexit vote, where researchers built a synthetic “Doppelgänger” economy to estimate how the country would have performed had it remained in the EU. That got me thinking about a career pathing analytics approach inspired by (but not identical to!) this causal inference technique. Imagine that, as an employee, you had the option to (in an anonymized way) find colleagues who started around the same time and from a similar starting point—based on criteria like role, age, skills, etc. You could then compare your own career path, shaped (at least partially) by your decisions, to theirs, shaped by theirs. From that comparison, you might uncover alternative paths you haven’t taken—but potentially still could—if you'd like to steer your career in that direction. Another option would be to include in your group of “career twins” also employees who joined the company before you, to expand the comparison set—though with a higher risk of less relevant insights, since the career landscape evolves and doesn't stay the same over time. While I believe this type of insight could be valuable for career decisions, I’m definitely not suggesting it should be the only or even the primary input—everyone’s situation is unique, the past isn’t always the best guide to the future, and human (or perhaps non-human) creativity can always come up with something new and unexpected. I tend to think of it as an analogue to the “outside view”—a useful first step in reference-class forecasting that helps counter our natural planning fallacy before diving into project-specific details. Curious if anyone is using this or similar techniques to systematically support employees in their career pathing. Feel free to share your experiences or ideas in the comments. P.S. The illustrative pic is from the 1998 movie *[Sliding Doors](https://en.wikipedia.org/wiki/Sliding_Doors)*, which follows two parallel timelines depending on whether the protagonist catches a subway train. If you're into “what if” stories and romcoms, you may like it 😍 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-06-20-doppelganger-for-career-pathing/) on my blog. --- # ONA as a tool for exploring the skill space in your company? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ona-skills-exploration Date: 2025-06-20 Tags: skills, network-analysis, career-development, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2025-06-20-ona-skills-exploration/ What if ONA could help us explore the skill space—finding more viable and/or more strategic development paths within it? Just like we use ONA to understand how long or “passable” the connections between people are in social networks, or who might be the informal influencer to involve in strategic transformation efforts, maybe we could apply the same approach to connections between skills—based on how often certain skills show up together across jobs or individual employees’ profiles. Picture visualizing a skill’s ego-network (with radius = 1), centered on something you’ve already mastered, to spark ideas for other skills often developed alongside it and that have high closeness centrality—i.e., skills you might pick up fairly easily and through which you can reach a wide swath of other skills in just a couple of steps—ideal for incremental growth (see the attached dataviz skill ego-network for illustration).
![](./ona-skills-exploration/skills_ona_plot.png)
Or flip it around and imagine exploring weaker ties, "high-betweenness" bridging skills connecting disparate skill domains, or more distant parts of the skill network to uncover less common or totally unexpected skills to go after. Curious if anyone’s already used ONA techniques with skill data like this—especially to uncover patterns of skill adjacency or learning pathways—or in any other creative way. Feel free to share your experiences or ideas in this area in the comments. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-06-20-ona-skills-exploration/) on my blog. --- # Novel way to measure leadership skills via causal inference (and AI)? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/causal-impact-of-leadership-skills Date: 2025-06-12 Tags: leadership, psychometrics, causal-inference, ai, generative-ai, agentic-ai Original post: https://blog-about-people-analytics.netlify.app/posts/2025-06-12-causal-impact-of-leadership-skills/ It never occurred to me that causal inference principles could be used effectively to measure something like leadership skills, but two pre-registered WIP studies by Weidmann et al. ([2024](https://www.nber.org/system/files/working_papers/w32699/w32699.pdf), [2025](https://www.nber.org/system/files/working_papers/w33662/w33662.pdf)) illustrate it actually could make good sense. The authors aimed to identify the causal contribution of managers to team performance by repeatedly randomly assigning managers to multiple teams of human followers and controlling for all individuals’ task-specific skills and fluid intelligence. Each time a leader was assigned to a team, a prediction was made about team performance based on the individual skills of the leader and the followers. Actual team performance was then compared to these predictions—leaders who drove performance above expectations were deemed to have strong leadership skills. In other words: a good manager consistently helps their team outperform the sum of its parts. Those familiar with [causal inference](https://matheusfacure.github.io/python-causality-handbook/01-Introduction-To-Causality.html?highlight=counterfactual) can clearly hear in this an echo of its central logic—constructing a counterfactual scenario and comparing it to what actually happened. Here, the counterfactual imagines team performance based solely on individual skills, without any added effect from the leader’s leadership skills. Given the logistical difficulty of arranging such testing situations with human team members, the authors explored whether large language model (LLM) agents could stand in for real people. So, in a second pre-registered study, they tested this idea and found that leadership skill estimates based on teams consisting of human followers and AI-simulated followers correlated strongly (*ρ* = 0.81), which could make this assessment method much more practical and scalable.
![](./causal-impact-of-leadership-skills/leadership_skill_assessment_scheme.png)
*Illustration of the part of the experiment where half the leaders completed 6 group puzzles with human teammates followed by 6 group puzzles with AI teammates (the other half of the leader sample completed the group puzzles in reversed order - not shown here).* Besides that, the studies also uncovered several other interesting insights underlying the value and importance of objective, skill-based selection of future leaders: * Good managers had roughly twice the impact on team performance compared to good individual contributors. * People who put themselves forward as leaders tended to perform worse than those chosen at random—partly because self-nominated managers (correlated with extraversion and self-reported people skills) were often overconfident, especially about their social skills. * Managerial performance was positively linked to economic decision-making ability, social intelligence, and fluid intelligence—but not to gender, age, ethnicity, or education. * Skills were stronger predictors of managerial performance than personality traits or personal preferences. * Strong leaders tended to ask more questions and engage more actively in conversational turn-taking. * Good managers boosted group performance by monitoring workers to prevent wasted time, assigning tasks based on comparative advantage, and motivating team members to put in their best effort. ⚠️ Caveat: The papers referenced were intended for discussion and comment, and have not undergone peer review. P.S. Thanks to my boss, Nadzeya Laurentsyeva, for pointing me to this interesting research. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-06-12-causal-impact-of-leadership-skills/) on my blog. --- # An interactive demo of Computerized Adaptive Testing URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/cat-and-irt-demo Date: 2025-06-02 Tags: psychometrics, bayesian-statistics, python Original post: https://blog-about-people-analytics.netlify.app/posts/2025-06-02-cat-and-irt-demo/ Specifically, I decided to reimplement in [Streamlit](https://streamlit.io/) a [Computerized Adaptive Testing (CAT)](https://en.wikipedia.org/wiki/Computerized_adaptive_testing) and [Item Response Theory (IRT)](https://en.wikipedia.org/wiki/Item_response_theory) demo I originally built years ago in good old Excel. The app illustrates the estimation of a psychological trait of [learning agility](https://psycnet.apa.org/buy/2022-19273-004) and showcases several key ideas behind CAT and IRT-based testing: * **Item-level modeling** – how individuals respond to questions is modeled based on both the characteristics of each item (e.g. difficulty, discrimination) and the test-taker’s underlying trait or ability. * **Continuous ability estimation** – the app updates the trait estimate in a Bayesian way after each response, rather than only at the end of the test. * **Adaptive item selection** – items are chosen based on how informative they are given the test-taker’s current estimated trait level. * **Efficiency and precision** – fewer items are needed to reach a reliable estimate compared to traditional fixed-form tests.
![](./cat-and-irt-demo/cat_irt_demo_app_screenshot.png)
Here’s the [link](https://cat-irt-demo.streamlit.app/) to the app (given that the app is hosted on Streamlit Community Cloud, it doesn’t stay awake all the time regardless of traffic—so if it hasn’t been used recently, you might need to wake it up and wait a few minutes). If you’re interested, you can copy the full code from [GitHub](https://github.com/lstehlik2809/Computerized-Adaptive-Testing-Demo.git) and customize the app to better suit your needs. ⚠️ Caveat: The app isn't intended as a valid and reliable measure of learning agility - it's purely for illustration purposes! --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-06-02-cat-and-irt-demo/) on my blog. --- # Talk vs. Walk: Predictors of staying intentions vs. actual quitting behavior URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/predictors-of-stay-intentions-vs-actual-resignations Date: 2025-05-28 Tags: employee-turnover, behavioral-science, predictive-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-05-28-predictors-of-stay-intentions-vs-actual-resignations/ At least since [Richard LaPiere’s 1934 study](https://academic.oup.com/sf/article-abstract/13/2/230/1989679?redirectedFrom=fulltext&login=true) comparing what hotel and restaurant staff said about ethnic minorities with how they actually treated them*, we’ve known that people’s stated attitudes and intentions can be worlds apart from their actual behavior. A recent revisit of that study got me thinking about a similar disconnect: the relationship between employees’ declared intention to stay and their actual quitting behavior. But I wasn’t interested in whether intention to stay can predict attrition—that’s already fairly well established, with loyalty-related survey items ranking among the strongest predictors of resignations (though even those, unfortunately, tend to be relatively weak in absolute terms 🫤). Instead, I wanted to see whether the drivers of intention to stay—the strongest predictors among other survey items—are also the top predictors of actual resignations (while controlling for standard demographic and organizational variables in both cases). The idea was to spot potential blind spots—where employees might be over- or underestimating the influence of specific workplace experiences on their actual decision to stay or leave. Kind of like those classic findings on how bad we are at predicting what really makes us happy or unhappy (a.k.a. *[affective forecasting](https://en.wikipedia.org/wiki/Affective_forecasting)*). Preliminary results? The rank-order correlation between the estimated predictive strength of these factors for the two criteria was fairly high—around -0.6—but far from perfect, with a few notable outliers suggesting interesting mismatches between perceived and actual “impact”. The difference seems to relate to a dimension we might call 'closeness to one's day-to-day functioning in their role'. The higher a factor is on this dimension, the more likely it is to be underestimated—and vice versa (for obvious reasons, I cannot be more specific 😉).
![](./predictors-of-stay-intentions-vs-actual-resignations/plot_results_unlabeled_blank.png)
Curious if anyone has done a similar analysis on their data and put the findings to practical use. Feel free to share your experiences in the comments. \* *LaPiere famously traveled across the United States with a young Chinese couple, visiting 251 hotels and restaurants. They were refused service only once. However, six months later, when he sent a questionnaire to these same establishments asking, 'Will you accept members of the Chinese race as guests in your establishment?', over 90% of those who responded said 'No'.* --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-05-28-predictors-of-stay-intentions-vs-actual-resignations/) on my blog. --- # Beyond prediction: Exploiting organizational events for causal inference in people analytics URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/causal-inference-in-people-analytics Date: 2025-05-21 Tags: causal-inference, experiments, data-science, people-analytics, python, r Original post: https://blog-about-people-analytics.netlify.app/posts/2025-05-21-causal-inference-in-people-analytics/ # Introduction When we worked as consultants using the most common people analytics maturity model—you probably know the drill by now: moving from descriptive analytics (*what happened*), to diagnostic (*why it happened*), predictive (*what will happen*), and finally prescriptive analytics (*how to make it happen*)—one thing that always seemed odd to us was how predictive analytics was so heavily promoted as more advanced and desirable than diagnostic analytics. But as any analyst will tell you, figuring out the actual causes behind events is way tougher—and a lot more uncertain—than just making accurate predictions. Also, understanding why something happened is typically more practical and actionable than just having predictions. Knowing the causes not only allows you to forecast events—often in a more explainable and interpretable way—but also enables effective interventions to make those events actually happen. In fact, once you have the root causes nailed down, you're much closer to the “ultimate” prescriptive stage than you'd be by just having predictions. This obsession with prediction might be part of the reason why people analytics still struggles to consistently deliver real business impact. Predictions (let alone descriptives) can only take us so far when it comes to actually changing our organizations in a meaningful way. Take the ever-popular employee attrition prediction use case. Sure, knowing which parts of the organization are at higher risk of voluntary turnover is better than flying blind—but if that’s all you bring to the table, the typical response from HRBPs or business stakeholders is something like, “Okay, that’s interesting. We kind of suspected that already, but what should we actually do about it?” The real impact of your predictive insight hinges on your ability to answer that question clearly and convincingly. And the truth is, in many, if not most, situations, you can only do that through causal inference: a systematic and principled approach to answering “why” questions. ## The Trouble with Experiments The good news is, we do have a method for answering causal, or “why”, questions. The less good news? In practice, causal inference is harder and less straightforward than making predictions. If you have a background in the social sciences, you probably remember from your methods classes that the gold standard for establishing causality is the experiment—specifically, the *randomized controlled trial* (RCT), also known in the business world as *A/B testing*. RCTs are powerful because they can effectively simulate comparison between factual and counterfactual scenarios—imagine being able to see what would happen to your headache both *with* and *without* taking aspirin. They do this by randomly assigning interventions to people (or other units of analysis), which helps break any systematic link between the outcome and all factors other than the intervention itself.
![](./causal-inference-in-people-analytics/rct_illustration.png)
*Fig. 1: A diagram illustrating the design of a Randomized Controlled Trial (RCT). Patients are randomly assigned to either a treatment group receiving an intervention or a control group receiving a comparison, allowing for the assessment of the intervention's causal effect on outcomes. Randomization helps balance both known and unknown confounding variables across groups, reducing bias and making it more likely that any observed differences in outcomes are due to the intervention itself—not other factors. (Source: [SimplyPsychology](https://www.simplypsychology.org/randomized-controlled-trial.html))* But as you can imagine, this kind of setup is often impractical, unethical, or technically or politically unfeasible in real organizational settings—especially when it involves employees. For example, try convincing your L&D colleagues to randomly assign eligible employees to either an expensive training program or a waiting list, just to estimate the program’s effectiveness, and see how that conversation goes. That said, if you’re on the other side of this imaginary barricade, like those hypothetical L&D folks above, it’s worth remembering that rolling out a program without testing it properly is still an experiment, just a sloppy and uncontrolled one. So if it’s feasible, running an RCT is at least worth considering. ## Correlation ≠ Causation (Still True, Still Annoying) So, if we often can’t run experiments, what are we left with? Observational data—a lot of it. Most organizations today are sitting on heaps of information about their employees and their behavior, coming from HRIS, survey platform, LMS, CRM systems, recognition tools, collaboration platforms like calendar, chat, email—you name it. However, at this point, many of you are probably hearing a mental alarm bell and recalling the age-old warning: “*Correlation is not causation.*” And you’d be absolutely right. Sifting through this kind of data is a bit like staring at a clear night sky, looking for familiar shapes—you’ll always find some patterns that resemble real things, but it’d be naive to think they’re actually up there. Same goes for observational data: We can’t just simply assume that any relationship we spot is causal. What could possibly go wrong? There could easily be a third, unmeasured variable, a so-called *confounder*, lurking in the background—driving two variables of interest and creating the illusion of a causal link between them (e.g., at first glance, it looks like remote employees chat with their coworkers less than their in-office peers, but it may be that the kind of work, some jobs needing more back-and-forth, others less, drives both the choice to work remotely and the overall volume of communication). Or the relationship might be causal, but in the opposite direction from what we assume (e.g., are people performing well because they’re engaged, or just more engaged because they keep winning Employee of the Month?). Then there’s the infamous *self-selection* effect: When people opt into a program for reasons tied to the very issue the program is meant to fix. That can make even a solid intervention look like it’s backfiring—not because it doesn’t work, but because those who join are systematically different from those who don’t, especially when it comes to the outcome of interest (e.g., if an optional manager coaching program seems to lower team satisfaction, it’s probably not the coaching that’s the problem—more likely, the only managers signing up are already struggling, and their teams were less satisfied from the start).
![](./causal-inference-in-people-analytics/sketchplanations-sampling-bias.png)
*Fig. 2: A lighthearted illustration of the self-selection effect—where only a specific type of person ends up in the sample. (Source: [sketchplanations](https://sketchplanations.com/sampling-bias))* Even if we try to get clever and use our good old friend, regression analysis, to statistically control for everything we can measure, we might still end up with biased estimates—not despite controlling for variables, but because we’re controlling for variables we shouldn't. For instance, if we control for a *collider*—a variable influenced by both the outcome and the intervention—we can actually introduce bias rather than eliminate it (e.g., when looking at the relationship between technical and teamwork skills in job candidates, both might independently boost their chances of getting selected; but if we only analyze those who got through, we’re effectively controlling for selection, which can create a fake negative link between technical and teamwork skills, even if there’s no such relationship in the broader pool of applicants; see *Fig. 3*).
![](./causal-inference-in-people-analytics/collider_plot.png)
*Fig. 3: Here we’ve plotted normally distributed technical versus teamwork scores for 1,000 simulated candidates—blue dots are all candidates, red dots are the selected candidates (you needed at least a 115 on one skill and no less than 100 on the other). The blue regression line is basically flat, but once we cherry-pick by our “either/or” rule (i.e., condition on selection), the red line tilts downward, showing a fake trade-off between skills that only crops up because of how we picked people, not because the skills are actually at odds.* Another potential trap in this category is controlling for a *mediator*—a variable that legitimately carries part of the causal effect. Doing so blocks a real causal pathway and biases the estimated effect downward (e.g., one way flexible work arrangements may help reduce unplanned absenteeism is by lowering occupational stress for employees who have more control over their time; in this case, stress acts as a causal middleman between the policy and absenteeism—if we control for it, we block this indirect pathway and risk biasing our estimates toward “flex time has little or no effect.”) ## Getting Clever with Observational Data But things aren’t as bleak as they might seem. When we enrich observational data with some well-grounded assumptions—based on how we understand the data-generating process—we can get pretty close to simulating the counterfactual scenarios needed for causal inference. Some of these assumptions come from our general knowledge about how the world works (like the fact that causes precede effects). Others are rooted in our domain-specific knowledge drawn from prior research (for example, goal-setting typically boosts performance). And some come from nitty-gritty, context-specific knowledge about the details of how the data were collected (say, participants only got assigned to a training program if they met a certain cutoff score). By combining these assumptions with the data, we can start to rule out alternative, purely correlational explanations for the patterns we see—and, with some caution, move closer to a causal interpretation. It’s not unlike what the famous fictional detective Sherlock Holmes did to solve his cases: piecing together bits of evidence, applying prior knowledge, and methodically ruling out false leads—until only one plausible explanation remained.
![](./causal-inference-in-people-analytics/dagitty_example.png)
*Fig. 4: Researchers and analysts looking to figure out causal effects often use Directed Acyclic Graphs (DAGs) to lay out their assumptions about how different variables might be causally connected. A DAG is a visual and formal tool that shows the assumed causal relationships—where the nodes stand for variables and the arrows (or edges) indicate which ones are influencing others. By building a DAG, researchers can more clearly think through their causal claims, spot potential confounders, and figure out the right way to adjust their analysis. This approach helps make assumptions more transparent and supports solid causal reasoning from observational data. While most DAGs are manually constructed based on domain expertise, there is a growing body of work around causal discovery—automated methods that attempt to infer plausible causal structures directly from data. These algorithms (e.g., PC, FCI, GES, or more recent neural-based models) can be helpful for generating hypotheses, especially when theory is sparse or data complexity is high. That said, causal discovery comes with its own assumptions and limitations, and a full discussion is beyond the scope of this article. The attached example DAG shows the assumed causal structure for estimating the average treatment effect of training performance on productivity. It was created using [DAGitty](https://www.dagitty.net/dags.html)—a great browser-based tool for building and analyzing causal diagrams.* By the way, this kind of knowledge, rooted in the rich, nuanced world models we carry around in our heads, might be one reason current AI still struggles to fully replace (good) business analysts. Their grasp of the world is in many respects still pretty shallow in comparison with humans—at least for now. But who knows what’ll be true by the time this article comes out… or even a month later. # Spotting Quasi-Experiments in the Wild In the rest of this article, we’ll focus on the third type of assumptions mentioned above. Specifically, we’ll look at several common organizational events and practices—and the analytical techniques that go with them—that often provide a natural foundation for robust causal analysis. This won’t be a comprehensive overview, but it still should give HR professionals a useful lens for spotting and leveraging these naturally occurring quasi-experiments for better, more impactful decision-making. For those more technically inclined, an accompanying public [GitHub repository](https://github.com/lstehlik2809/Beyond-Prediction-Exploiting-Organizational-Events-for-Causal-Inference-in-People-Analytics.git) contains example Python and R code with dummy data illustrating some of these scenarios. Without further ado, let’s dive into the first type of events. ## Selectively Affected Teams (When Change Plays Favorites) Despite ongoing efforts to standardize operations, employees in modern organizations often experience very different levels of exposure to various company policies and processes. A major driver of this variability is simply the nature of the role. A software developer, for example, might fully embrace the perks of a remote-work policy—coding in pajamas from a cabin in the woods—while a facilities manager still commutes daily to keep the lights on, the plumbing working, and the office ready for the inevitable “team-building week” return. Another common source of variability is the widespread practice of piloting new programs on a small scale before rolling them out company-wide, allowing organizations to test the waters, gather feedback, and fine-tune everything from design to implementation. While variability is often seen as something to be minimized—especially in contexts like manufacturing or quality control—it can be a real asset when it comes to causal inference. These natural differences can create for us groups that differ in one key aspect of interest (e.g., exposure to a policy), while remaining comparable in many other important ways, making them valuable for identifying potential causal effects. Take, for example, the much-debated and often controversial return-to-office (RTO) initiatives. Suppose a tech company rolls out an RTO policy and starts noticing that employees in the Digital department, who previously enjoyed full remote flexibility, are now quitting at higher rates. Leadership wants to know: Is this spike in attrition actually caused by the RTO policy, or is it just part of a broader company-wide trend? We dig into the data and see that voluntary attrition in the Digital department is indeed climbing. But that alone doesn't tell us much—maybe attrition is up across the board, driven by other factors. To answer the question properly, we need a fair comparison—an apples-to-apples scenario. That means finding a suitable reference group: ideally, a department less affected by the RTO shift due to the nature of their work (say, Facilities), and one that historically followed a similar attrition pattern before the policy change. And just like that, we land on a well-established method for causal inference: *Difference-in-Differences* (DiD). ### Difference-in-Differences DiD looks at how things change over time for a group exposed to an intervention (the treatment group) compared to a group that isn’t (the control group). The core assumption is that, if the intervention hadn’t happened, both groups would have followed similar trends. We can get a sense of whether this holds by checking if their trends were roughly parallel before the intervention. If they were—and we then see a distinct shift in the treatment group that goes beyond what we’d expect from the usual pattern—it’s reasonable to attribute that change to the intervention. Beyond the “*parallel trends*” assumption, there are a few other key ones to keep in mind—like no spillover effects between groups, no overlapping interventions, shared external conditions (common “shocks”), and stable group composition over time—but these can usually be assessed with solid domain knowledge. Let’s bring this back to our RTO example. The chart below shows the trailing 12-month voluntary attrition rate for our two departments—only one of which was supposed to be affected by a remote-work policy change, based on the nature of their work. Before the policy change, Department A (Facilities) had slightly higher attrition than Department B (Digital), but both were trending downward in parallel. After the change, the trend diverged sharply: attrition in Department B went up, while it continued downward in Department A. If we can reasonably confirm the other DiD assumptions, this gives us pretty solid evidence that the policy change caused the increase in attrition in Department B.
![](./causal-inference-in-people-analytics/did.png)
*Fig. 5: A figure showing the DiD method’s “parallel trends” assumption (red dotted line), which helps us imagine what would have happened in department B if the policy change hadn’t taken place. By comparing that hypothetical, counterfactual scenario to what actually happened (red vertical line), we can estimate the effect of the policy change on voluntary attrition in department B.* ### Synthetic Control If it seems to you that relying on just one comparison group (like department A in our earlier example) is a bit shaky, you can reach for a more robust alternative: *Synthetic control* method. Instead of using a single control group, this method constructs a synthetic version of the control group by combining multiple control units, each weighted based on how closely they mirror the treatment group’s behavior prior to the intervention. The principle is similar to what we already saw with DiD: we still want to estimate what department B’s attrition would have looked like had the policy not changed, and then compare that counterfactual to what actually happened. But rather than relying solely on department A, we bring in several departments whose historical attrition trends most closely align with department B’s. Using a method like linear regression—or another machine learning (ML) algorithm—we assign weights to these control units based on how well they predict department B’s attrition before the policy change. We then use this weighted combination to forecast department B’s attrition after the policy shift, creating a counterfactual baseline to compare against the actual outcomes. Below is an example of how this method was used to estimate the negative impact of Brexit on the UK’s economic performance, using GDP as the outcome measure. To model the UK’s pre-Brexit GDP, we used data from countries unlikely to be strongly affected by Brexit but whose GDP trends closely mirrored the UK’s during the pre-Brexit period (e.g., the US, Costa Rica, Germany, New Zealand, Sweden, Austria, etc.). As shown in the top chart, the model explains 99% of the variability in UK’s GDP from 2008 to 2020. This model was then used to predict what UK’s GDP would have looked like after Brexit (January 31, 2020) if Brexit hadn’t happened, and these predictions were compared to the actual observed values. The second and third charts from the top show how the predicted and actual values diverge—quarter by quarter and cumulatively over time—including uncertainty around the estimates. From these charts, it's clear that the data and the analysis support the hypothesis that Brexit had a negative effect on the UK economy, as measured by GDP.
![](./causal-inference-in-people-analytics/uk_chart.png)
*Fig. 6: A set of charts showing the estimated causal impact of Brexit on the UK’s GDP using a Bayesian synthetic control method implemented in the [CausaPy](https://causalpy.readthedocs.io/en/stable/) package. Top chart: It compares actual UK’s GDP (black dots) with a counterfactual scenario (orange line) — i.e., what GDP would likely have looked like had Brexit not occurred. The vertical red line marks the Brexit date (January 2020). The fit before that is very tight (R² = 0.99), indicating a very good approximation. Middle chart: This shows the instantaneous causal impact — the difference between actual and counterfactual GDP for each quarter. Post-Brexit, GDP is consistently below what it would have been otherwise, shown by the orange shaded area. Bottom chart: This plots the cumulative causal impact — essentially, how much GDP has been “lost” over time due to Brexit. By 2024, the cumulative loss reaches over 2 trillion USD.* ## Staggered Rollouts (When Change Doesn’t Happen All at Once) When HR teams launch new programs—like a transformation training for senior managers designed to boost their change readiness—they rarely implement them company-wide all at once. Organizational and technical constraints typically get in the way: A limited number of trainers, room availability, scarce training materials, and the need to keep business operations running smoothly, just to name a few. Instead, these programs are usually implemented in a staggered fashion, meaning different employee groups participate at different times. This staggered rollout presents us with a valuable opportunity: we can compare more or less similar employees who have participated in the program to those who haven’t—yet. The “more or less similar” part is key. We can only make valid causal claims about a program’s impact if the groups we’re comparing are similar in factors that could influence both their participation and the outcome of interest. And we should never assume that this similarity holds by default. We need to test for it and/or adjust for relevant differences in the analysis. As program managers, we have an advantage. We’re close to the nomination process and usually have a solid understanding of the selection criteria. That puts us in a good position to spot which factors need to be accounted for. However, when self-selection comes into play, things get trickier. There may be hidden factors influencing both participation and outcomes, so we should be cautious in drawing final conclusions. To illustrate this kind of scenario, imagine that—due to the capacity constraints mentioned earlier—a subgroup of managers starts and completes the transformation training before the regular annual employee survey is administered, while another subgroup begins and finishes it only afterward (see *Fig. 7*). In this case, only the first subgroup could potentially show a causal effect of the training in their survey scores for that year. That gives us a good opportunity to compare the change and transformation scores between the two groups.
![](./causal-inference-in-people-analytics/staggered_rollout.png)
*Fig. 7: An illustration of a possible staggered rollout scenario, where one group of training participants started and completed the training before the employee survey was administered, and the second group did so afterward. Only for the first group (A) could a potential causal effect of the training be reflected in the survey results.* However, we need to make sure these groups are comparable in terms of factors that might influence both the timing of their training and their survey outcomes. For example, if all senior managers from Sales—where employee satisfaction is generally lower in our hypothetical organization—were scheduled to take the training later, it could bias the comparison. The same applies if managers who were already less satisfied with transformation efforts were more motivated to start their training earlier. We can address these issues by adjusting for such differences in the analysis. ### Adjusting When we “adjust” for a variable, we’re statistically controlling for it in the analysis—basically holding it constant so it doesn’t muddy the waters when we’re trying to see what really drives the outcome. The way we do this can vary depending on the specific analytical technique, but within causal inference the goal is always the same—to approximate random assignment as closely as possible. For instance, we might include the variable as a covariate in a regression model, match individuals based on that variable (see *Fig. 8*), re-weight the data with *Inverse-Probability-of-Treatment Weighting* (IPTW), where the weights are the inverse of each person’s estimated probability of receiving the treatment they actually got (see *Fig. 9*), split the data into subgroups (stratification), or condition on the variable in a ML model. With such adjustment, we can be more confident that we're comparing apples to apples and make some causal conclusions.
![](./causal-inference-in-people-analytics/matching.png)
*Fig. 8: An illustration of matching based on two covariates (X1 and X2). Each dotted circle represents a matched set containing individuals from the treated (red) and control (blue) populations with similar characteristics (X1 and X2), facilitating a fair, apple-to-apple comparison. (Source: [Applied Causal Inference](https://appliedcausalinference.github.io/aci_book_kr/03-causal-estimation-process.html))*
![](./causal-inference-in-people-analytics/iptw.png)
*Fig. 9: An illustration of the Inverse-Probability-of-Treatment Weighting method (IPTW). Each person is given a weight equal to the inverse of the estimated probability—based on their observed characteristics—of receiving the treatment they actually received. After weighting, the treated and control groups look alike on those characteristics, so we can learn about cause and effect from observational data (assuming two extra assumptions hold: ignorability—i.e. there aren’t hidden factors that influence both treatment and outcome—and positivity—i.e. everyone had some chance to get each option). A chart: Observed data before reweighting. B chart: Observed data after reweighting. (Source: [Applied Causal Inference](https://appliedcausalinference.github.io/aci_book_kr/03-causal-estimation-process.html))* ### Staggered Adoption DiD Model Another technique that can be used for causal analysis of staggered rollout data is a specific type of DiD model tailored to handle the fact that we implemented changes or interventions at different times across various parts of an organization. The classic DiD works best when all members of the treatment group are exposed to the intervention at the same time. But what if, like in many real-world situations, a new policy is rolled out gradually? Imagine that Department A adopts it in January, Department B in April, Department C in July, and Department D either never adopts it—or does so much later (see *Fig. 10*).
![](./causal-inference-in-people-analytics/staggered_rollout2.png)
*Fig. 10: An illustration of a staggered rollout of a new policy across several departments: Department A adopts it in January, Department B in April, Department C in July, and Department D either never adopts it—or does so much later.* If we tried to use the simple DiD method here, we might get confusing or misleading results. Why? Because who are we comparing Department A to in February? Department B hasn't adopted the policy yet, so they seem like a good control. But by May, Department B has also adopted the policy, so they're no longer a clean "untreated" comparison group for Department A. The Staggered Adoption DiD model is specifically built to handle these kinds of messy, real-world rollouts. It introduces several key adjustments to give us a clearer and more accurate picture: 1. *Everyone starts at their own "day zero":* Instead of aligning all departments by calendar date, the analysis aligns them based on when they actually adopted the policy. So, Department A's "Month 1 after adoption" is January, while for Department B it's April. This allows a fair comparison of each department’s progress relative to its own start. 2. *Using the "not yet treated" as a proper control group:* At any given time point, the model compares departments that have already adopted the policy to those that haven’t yet. For example, in February (one month after Department A's adoption), Departments B, C, and D—who are still untreated—can serve as the control group. By May, when Department B is in its first post-policy month, Department A is already treated, but Departments C and D still haven’t adopted the policy and can act as the comparison group. This ensures we're always making the best possible comparison given who’s received the policy and who hasn’t. 3. *Tracking the effect over time for different "waves":* The model doesn’t just provide a single estimate. It tracks the average effect of the policy for each wave of adopters (e.g., January adopters, April adopters), and how that effect evolves over time. This helps us understand whether the policy’s impact is immediate, builds up, or fades as time goes on. By implementing these adjustments, the Staggered Adoption DiD model provides a more accurate and nuanced understanding of the intervention's impact over time and across various units. However, as with traditional DiD, we still rely on the assumption that, in the absence of the intervention, the treated and control groups would have followed similar trends over time. ## Policy Thresholds and Eligibility Cutoffs (When a Small Step Can Mean a Big Shift) Another type of situation that can help us obtain comparable groups—differing only in exposure to the intervention of interest—involves scenarios where employees must surpass a specific threshold or cutoff to qualify. For instance, eligibility for a coaching program might require not only a minimum management level but also a certain tenure. Or consider a case where employees become eligible for a leadership development initiative only if their performance evaluation score exceeds 85 on a scale from 0 to 100. In such situations, individuals just above and just below the threshold are likely similar in most respects, except for their exposure to the intervention. This setup is ideal for applying a *Regression Discontinuity Design* (RDD), which allows for estimating the causal effect of the intervention by comparing outcomes around the cutoff point (see *Fig. 11*). ### Regression Discontinuity Design To implement RDD, we start by identifying a continuous assignment variable (like tenure or performance score) and the specific cutoff that determines who gets the intervention. Then we collect data on individuals just above and just below that threshold (in a region called the "bandwidth")—people who are otherwise very similar except for whether they received the intervention. Next, we visualize the relationship between the assignment variable and the outcome to see if there's a clear jump at the cutoff. If there is, that’s a signal the treatment may have had an effect. To formally estimate this, we fit two separate regression lines—one on each side of the cutoff—using only data near the threshold, and then calculate the size of the jump between them at the cutoff. This estimated difference gives us the treatment effect.
![](./causal-inference-in-people-analytics/rdd.png)
*Fig. 11: An illustration of the Regression Discontinuity Design (RDD) method using a hypothetical leadership development initiative where eligibility is determined by employee performance (the assignment variable, x-axis) crossing a specific threshold. Employees below the threshold (red dots) were ineligible, while those above (green dots) were eligible. RDD isolates the program's impact by comparing the leadership effectiveness (the outcome variable, y-axis) of employees just below the threshold to those just above it; the distinct jump, or discontinuity, observed between the fitted regression lines (red and green) precisely at the threshold represents the estimated treatment effect of the initiative.* Given the somewhat arbitrary nature of the chosen bandwidth, it’s often a good idea to run robustness checks—like varying the bandwidth—to make sure the estimated effect isn’t overly sensitive to those choices. We should also run a few validity checks to make sure the assumptions behind RDD hold up, because even RDD-based causal inference doesn’t come for free—it relies on three key assumptions. First, the relationship between the assignment variable and the outcome should be smooth; any sharp jump at the cutoff should be caused by the treatment itself, not some other factor. Second, people shouldn’t be able to game the system to end up just above or below the cutoff. And third, individuals near the cutoff should be very similar in all other respects, making the comparison almost like random assignment (ironically, this might be one of the few cases where having a somewhat noisy performance evaluation can actually be helpful). Even when these assumptions hold, it’s important to remember that RDD estimates a *Local Average Treatment Effect* (LATE)—the observed causal effect applies specifically to individuals near the cutoff and may not generalize to the broader population. ## Abrupt Organizational Changes (Breaking the Routine with a Bang) A great way to explore causal relationships is by looking at sudden, discrete organizational changes that disrupt the usual way of doing things—especially in systems that have been relatively stable or at least somewhat predictable over time. One timely example is the introduction of advanced, AI-driven automation to help employees get support for administrative or technical issues they face at work. Another example in this category is a company-wide initiative to support employees in using GenAI tools to generate their personal goals in alignment with the organization’s strategic objectives, hoping to improve goal cascading—a known weak spot in many performance management systems. We can make use of these abrupt changes—and the stable/predictable patterns that came before them—to get a rough idea of the counterfactual comparison we need for causal inference. But that only works if it makes sense to think that, without the change, things would’ve just kept going as usual. And if that’s not the case, we at least need a good sense of what else might’ve thrown things off, so we can factor that into the analysis. ### Segmented Regression Analysis of Interrupted Time Series One useful method for analyzing data in this kind of scenario is *segmented regression analysis of interrupted time series*. It lets us model changes in outcomes that happen after a specific intervention at a known point in time, while also accounting for other possible influences—like seasonal patterns that would’ve happened anyway, or external “shocks” that might explain the changes too. In effect, it helps us spot shifts in both level and trend, and see how things played out compared to how they likely would’ve gone if nothing had been introduced at all (see *Fig. 12*).
![](./causal-inference-in-people-analytics/segmented_regression.png)
*Fig. 12: A general structure of the segmented regression model. The β0 coefficient estimates the baseline level of the outcome variable at time zero; β1 coefficient estimates the change in the mean of the outcome variable that occurs with each unit of time before the intervention (i.e. the baseline trend); β2 coefficient estimates the level change in the mean of the outcome variable immediately after the intervention (i.e. from the end of the preceding segment); and β3 estimates the change in the trend in the mean of the outcome variable per unit of time after the intervention, compared with the trend before the intervention (thus, the sum of β1 and β3 equals to the post-intervention slope). Since time series data often exhibit autocorrelation and seasonal patterns, the model should include terms to account for both—such as an autoregressive term to handle autocorrelation, and a seasonal indicator (e.g., month) to capture recurring seasonal effects. If we know that other external 'shocks' may have occurred around the same time as the main intervention, we should also include those as additional covariates to isolate their potential impact. (Source: [Turner et al. (2021)](https://bmcmedresmethodol.biomedcentral.com/articles/10.1186/s12874-021-01306-w))* Let’s illustrate this kind of analysis by looking at the supposed impact of GenAI on how people write emails. Since plenty of [studies](https://seosandwitch.com/microsoft-copilot-statistics/) show that one of the most common business uses of GenAI is helping with email writing, it makes sense to think that people might be turning to the internet less often for advice on the topic. One way to check that is by looking at [Google Trends](https://trends.google.com/trends/) data for searches like “how to write an email” and seeing if there’s a noticeable drop after ChatGPT was officially released. As the output charts from the Bayesian interrupted time series analysis below show, there’s indeed a clear decline in this type of search activity. In fact, the shift is so pronounced that even a quick eyeballing analysis picks it up. Sure, we could imagine alternative explanations—maybe people are just sending fewer emails because they’re relying more on tools like Slack or Teams—but [other available stats](https://financesonline.com/email-statistics/) suggest that’s more wishful thinking than reality.
![](./causal-inference-in-people-analytics/output_plot.png)
*Fig. 13: Charts showing the estimated causal impact of ChatGPT availability on the popularity of Google searches for 'how to write an email.', using a Bayesian interrupted time series analysis implemented in the [CausalPy](https://causalpy.readthedocs.io/en/stable/) package. Top chart: This compares the actual popularity of the search term (black dots) with a counterfactual scenario (orange line)—basically, an estimate of what search interest would have looked like if ChatGPT hadn’t been released. The vertical red line marks the official release of ChatGPT (November 2022). The model fits the pre-release data fairly well (R² = 0.66), suggesting it’s a good approximation. Middle chart: This shows the immediate causal impact—week by week, it displays the difference between actual search interest and the counterfactual. After ChatGPT’s release, the actual search interest stays consistently below the expected level, as shown by the orange shaded area. Note: As far as we know, the InterruptedTimeSeries class from the CausalPy library used here assumes independent observations and does not account for autocorrelation, which may lead to underestimated and time-insensitive uncertainty. The model also does not consider the truncated nature of the data, with possible values limited to the 0–100 range. These specifics could be better addressed in a more flexible modeling environment, such as the brms library in R or the PyMC library in Python.* ## Instrumental Variables (The Hard-to-Find Helpers of Causal Inference) Suppose we're trying to estimate the causal impact of a leadership training program on team productivity. While it may sound straightforward, in practice, people are rarely selected into such programs at random—those who participate may already be more motivated, capable, or open to change. These unobserved traits could independently influence both their likelihood of attending the training and their team’s productivity. That introduces *unmeasured confounding*, which can bias even the most sophisticated statistical models, including many we covered in the previous causal inference examples. Fortunately, all is not lost. There’s a clever workaround that can help us untangle causality even when lurking variables like motivation or inherent ability are hiding in the background: *instrumental variables* (IVs). We can think of them as hidden helpers—factors that push people toward receiving a certain treatment or policy but don’t influence the outcome in any other direct or indirect way, except through the treatment or policy of interest. A valid instrument affects the *likelihood of receiving the treatment* (e.g., joining a program) but doesn’t have a *direct effect* on the outcome (e.g., team productivity)—only an *indirect one*, through the treatment itself. In other words, if we find a factor that moves treatment status without independently moving the outcome, we’ve got a potential instrument. How does this help? By leveraging this instrumental variable, we can estimate what the effect of the treatment would be in a world where it was randomly assigned. And we can do this using a technique called *two-stage least squares* (2SLS). In the first stage, we predict treatment assignments based on the instrument. In the second stage, we use this predicted treatment (now purged of confounding) to estimate the effect on the outcome. But there’s a catch (or two). Finding good instruments is hard. There are two big boxes to tick: 1. *Relevance:* The instrument must strongly influence the treatment. If it doesn’t move the needle, it won’t help us identify anything. 2. *Exclusion restriction:* The instrument must affect the outcome only through the treatment—not directly or via some other variables. Relevance can often be tested statistically, but the exclusion restriction leans heavily on domain expertise and theoretical justification, as it often can’t be empirically verified. As a result, using IVs demands careful scrutiny and well-defended assumptions. In practice, satisfying both conditions is pretty tough. In fact, it’s so tough that IV-based methods are used relatively rarely—not just in organizational research, but across the board. Still, when we get lucky, IVs can be a powerful addition to our causal toolbox. So be on the lookout! As an example of this analytical approach, consider a study by [Chang & Kang (2019)](https://link.springer.com/article/10.1007/s12122-018-9273-z#:~:text=crucial%20factors%20to%20improve%20firm,the%20ordinary%20least%20square%20estimation), which tackled the classic chicken-or-egg problem in organizational research: Do better management and HR practices actually cause higher productivity, or do already successful firms simply tend to adopt them? The researchers suspected *endogeneity*—unobserved factors like culture or leadership quality might drive both. To get around this, they used firms’ historical change experiences as instrumental variables—specifically, whether the company had previously undergone major reorganizations, implemented empowerment programs, invested in IT, or had strong motivation for change. These factors predicted current management practices but were unlikely to directly affect present-day productivity except through those practices (see *Fig. 14*). Using 2SLS, they found that once this endogeneity was accounted for, structured HR and management practices had a clear, positive impact on productivity—an effect that standard regression analysis had missed entirely.
![](./causal-inference-in-people-analytics/dagitty_model_legend.png)
*Fig. 14: A DAG capturing Chang & Kang’s (2019) IV-based approach to answering the causal question of whether better management and HR practices actually cause higher productivity. For more context, see the details in the main text.* A second, more unexpected example comes from the world of CEO succession. [Bennedsen et al. (2007)](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=925650) examined whether appointing a family member as CEO in family-owned firms impacts firm performance. The catch? Succession choices aren’t random—stronger firms might be more (or less) inclined to keep leadership in the family, and hidden factors like founder legacy or firm culture could influence both who gets the top job and how the firm performs. To isolate a source of random variation, the researchers used a surprisingly clever instrument: the gender of the outgoing CEO’s firstborn child. In Denmark, because of the societal preference for male heirs at the time, having a firstborn son significantly boosted the chances of a family CEO being appointed—by around 30%—but was otherwise unlikely to affect firm performance, either directly or through any other indirect channels (see *Fig. 15*). With this instrument, their 2SLS analysis revealed that family CEO appointments caused a substantial drop in firm performance—at least 4 percentage points in operating return on assets, especially in fast-growing or large firms. Once again, the IV approach helped cut through the noise, showing that what might look like a neutral or even positive correlation at first glance actually hides a meaningful causal effect in the opposite direction.
![](./causal-inference-in-people-analytics/dagitty_model_legend2.png)
*Fig. 15: A DAG capturing Bennedsen et al.’s (2007) IV-based approach to answering the causal question of whether appointing a family member as CEO in family-owned firms impacts firm performance. For more context, see the details in the main text.* ## Tailoring Impact (One Size Usually Doesn’t Fit All) While estimating an overall treatment effect can be informative, it misses a key point: HR interventions likely don’t affect all employees in the same way. That’s where *Heterogeneous Treatment Effect* (HTE) or *Conditional Average Treatment Effect* (CATE) come in—they help us understand how treatment effects vary across subgroups or individual characteristics. Instead of just asking “what works?”, this approach shifts the question to “what works for whom?” The key added value is that, by estimating HTE, organizations can target interventions more precisely, reduce unnecessary costs, and boost overall impact. Imagine a situation where a company rolls out a new leadership training program aimed at boosting managerial effectiveness. While the average results might suggest a modest improvement in leadership ratings, a deeper HTE analysis could reveal that the program had a much stronger impact on first-time managers in technical departments than on seasoned leaders in sales. This kind of insight allows HR to refine the rollout strategy—perhaps focusing future training efforts on those likely to benefit most, adapting the content for other groups, or even discontinuing the program where it shows little effect. ### Traditional Econometric Approaches One straightforward way to implement this type of analysis is by running a subgroup analysis—i.e., estimating treatment effects within predefined subgroups (e.g., age bins). Another option is to include interaction terms between the intervention and the characteristics of interest in the adjustment formula of the regression analysis, along with other relevant control and precision variables. The downside of these approaches is that they typically assume linearity and often lack flexibility when dealing with a large number of confounding variables. ### Machine Learning-Based Approaches ML-based approaches—often referred to as *Causal ML*—can help overcome these limitations. For example, methods like *Causal Forests* are specifically designed to estimate heterogeneous treatment effects in a flexible, data-driven way. A Causal Forest is an adaptation of the traditional *Random Forest* algorithm, repurposed for causal inference rather than prediction. Instead of minimizing prediction error, it focuses on partitioning the data to uncover subpopulations where treatment effects vary. At a high level, the algorithm builds many decision trees, each recursively splitting the data based on covariates to maximize differences in estimated treatment effects—not just differences in outcomes. Each tree acts as a “weak learner” that captures local treatment heterogeneity. These are then aggregated to produce stable HTE estimates for each observation—effectively answering the question: given this person’s characteristics, what is the likely effect of the treatment on them? A key strength of Causal Forests is their ability to automatically model complex, nonlinear interactions between variables without the need to predefine subgroups or interaction terms—especially handy when dealing with high-dimensional confounding in the data. A nice example of repurposing ML for causal inference is available on [EconML’s GitHub](https://github.com/py-why/EconML/blob/main/notebooks/Solutions/Causal%20Interpretation%20for%20Employee%20Attrition%20Dataset.ipynb). It showcases, in the context of employee attrition analysis using the popular [IBM Employee Attrition dataset](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset), how to effectively combine classical ML—providing a list of strongest predictors—with their subsequent causal interpretation using *Double ML* (DML) and the HTE estimation. The entire pipeline includes the following steps: 1. Fine-tuning and training a traditional ML model. 2. Using SHAP values for a purely correlational interpretation of the results—identifying the top predictors flagged by the ML model and helping to surface candidates for follow-up causal exploration (see *Fig. 16*).
![](./causal-inference-in-people-analytics/shap.png)
*Fig. 16: A SHAP (SHapley Additive exPlanations) summary plot illustrating feature importance and impact on the ML model's prediction of employee attrition. Features are ranked by decreasing importance vertically, with "OverTime_Yes" appearing as the most impactful and "HourlyRate" as the least impactful. Each point represents an individual observation; its horizontal position indicates the feature's impact on the model's prediction of attrition (positive values increase the prediction, negative values decrease it). The color indicates the feature's value for that observation, with red representing high values and blue representing low values. For example, high values for "OverTime_Yes" (indicating an employee works overtime) and low values for features like "JobSatisfaction," "MonthlyIncome/1K," or "StockOptionLevel" generally show a positive SHAP value, thus contributing to a higher predicted risk of attrition. (Source: [EconML](https://github.com/py-why/EconML/blob/main/notebooks/Solutions/Causal%20Interpretation%20for%20Employee%20Attrition%20Dataset.ipynb))* 3. Employing DML—which uses ML with sample splitting to control for confounders in treatment and outcome models—combined with HTE estimation to test whether these top predictors actually have a direct causal effect on attrition. While these might be strong predictors of employee departure, that doesn’t mean they’re actually driving people to leave. 4. Using the HTE component to segment employees by specific risk factors, enabling individualized plans to reduce attrition. For instance, salary might have a higher causal impact on short-tenured employees, whereas overtime could matter more for long-tenured ones.
![](./causal-inference-in-people-analytics/causal_effect_chart_combo.png)
*Fig. 17: Causal analysis of employee attrition. The left chart displays the estimated ATE of five strong attrition predictors (e.g., "OverTime", "StockOptionLevel"), along with their 95% confidence intervals; from these, "OverTime" demonstrates the most pronounced positive causal impact on attrition. The right chart presents a shallow tree interpreter for the HTE of a salary-related intervention. Despite salary (represented as "MonthlyIncome/1K" on the left, showing a non-significant average effect) not being universally impactful, this HTE analysis identifies subgroups who may respond more favorably to an income raise (i.e., exhibit a greater reduction in attrition risk). The tree illustrates how the CATE mean of this salary intervention on attrition varies across subgroups defined by "YearsAtCompany" and "TotalWorkingYears," suggesting that the impact of salary adjustments differs depending on employee tenure and overall experience. Specifically, the intervention appears most effective in reducing attrition (CATE mean = -0.051) for employees with 1.5 years or less at the company and 1.5 years or less of total working experience. Conversely, its effect is minimal (CATE mean = -0.002) for employees with more than 6.5 years at the company. (Source: [EconML](https://github.com/py-why/EconML/blob/main/notebooks/Solutions/Causal%20Interpretation%20for%20Employee%20Attrition%20Dataset.ipynb))* Just a small (or big?) warning to end on: Despite the smooth and easy analytical workflow enabled by tools like this, it doesn’t replace the need for solid domain knowledge—and a clear understanding of which variables actually make sense to include in the model to begin with. # Conclusion Causal inference is the next frontier in people analytics. We have not yet mastered it as a discipline and it’s a reliable path to actionable insight and business impact. While predictive models can tell us who might leave, who might perform well, or who might engage, they often leave HR leaders and business partners stuck at the “so what?” moment. To truly move from insight to intervention, we need more than pattern recognition—we need **causal understanding**. And the good news is, organizations are full of opportunities to generate it. Whether it’s staggered rollouts, policy thresholds, sudden organizational shocks, or naturally occurring differences across teams and roles, real-world business operations frequently provide the ingredients for quasi-experiments. We just need to be ready, and trained, to recognize them, model them correctly, and extract causal lessons with care. But we must also be wary. Causal inference from observational data is never perfect. It’s a practice rooted in informed assumptions, careful testing, and relentless scrutiny—not just sophisticated math or cutting-edge algorithms. It requires both statistical know-how and deep domain understanding. And frankly, sometimes it just takes too long for the process of business decision making. The goal isn’t to replace randomized experiments entirely (when possible, they’re still the gold standard), but to make the most of the messiness of the real world. In doing so, we can shift people analytics from being interesting to being indispensable to organizational decision making—from being merely predictive to being powerfully diagnostic. And that’s the difference between analytics that sits on a dashboard and analytics that shapes the future of work. # Further Resources and Readings Looking to go deeper? Here are some tips on resources for expanding your understanding of causal inference methods and their application in organizational contexts. ## Foundational Concepts * *The Book of Why: The New Science of Cause and Effect* by Judea Pearl and Dana Mackenzie * *The Effect: An Introduction to Research Design and Causality* by Nick Huntington-Klein * *Causal Inference: The Mixtape* by Scott Cunningham * *Causal Inference* by Paul R. Rosenbaum * *Mastering 'Metrics: The Path from Cause to Effect* by Joshua D. Angrist & Jörn-Steffen Pischke * *Mostly Harmless Econometrics: An Empiricist's Companion* by Joshua D. Angrist & Jörn-Steffen Pischke * *[Mastering Econometrics](https://www.youtube.com/playlist?list=PL-uRhZ_p-BM5ovNRg-G6hDib27OCvcyW8)* video series by Joshua Angrist * *Statistical Rethinking: A Bayesian Course with Examples in R and Stan* by Richard McElreath * *Causal Inference for Data Science* by Aleix Ruiz de Villa ## Technical Toolkits Python: * *Causal Inference and Discovery in Python* by Aleksander Molak * *Causal Inference in Python: Applying Causal Inference in the Tech Industry* by Matheus Facure * *[Causal Inference for the Brave and True](https://matheusfacure.github.io/python-causality-handbook/landing-page.html)* by Matheus Facure * `DoWhy`: A causal inference framework supporting DAGs, propensity scores, and IVs. * `EconML` by Microsoft: A package implementing Double Machine Learning (DML) and other modern estimation techniques. * `CausalPy`: A package focussing on causal inference for quasi-experiments allowing users to use different model types. * `DoubleML`: A package implementing the double / debiased machine learning framework. R: * *Causal Analysis: Impact Evaluation and Causal Machine Learning with Applications in R* by Martin Huber * *Impact Evaluation in Firms and Organizations With Applications in R and Python* by Martin Huber * *Causal Inference in R: Decipher Complex Relationships with Advanced R Techniques for Data-Driven Decision-Making* by Subhajit Das * `causalimpact`: A package useful for ITS designs. * `ivreg`: A package for instrumental variable analysis. * `ptetools`: A package streamlining causal inference based on panel data. * `DoubleM`L: A package implementing the double / debiased machine learning framework. * [DAGitty](https://www.dagitty.net/): A browser-based environment for creating and analysing causal diagrams (but is available also via R). A [GitHub repository](https://github.com/lstehlik2809/Beyond-Prediction-Exploiting-Organizational-Events-for-Causal-Inference-in-People-Analytics.git) with example Python and R code and dummy data illustrating some of the scenarios discussed in this article. (Note: Individual examples will be added gradually over time.) ---- **Update**: Check out [this post on Encouragement Quasi-Experimental Design](https://blog-about-people-analytics.netlify.app/posts/2026-03-12-encouragement-design-and-ivs/), which shows that finding an instrumental variable may not be so hard after all—or at least that it’s not so difficult to set things up in a way that creates one in the PA and HR context. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-05-21-causal-inference-in-people-analytics/) on my blog. --- # Can a simple algorithm read your personality from your face? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/detecting-personality-in-the-face Date: 2025-05-20 Tags: personality, big-five, machine-learning, python, r Original post: https://blog-about-people-analytics.netlify.app/posts/2025-05-20-detecting-personality-in-the-face/ Recently, I happened to stumble across some data I collected during my general psychology lab sessions with my students, where we explored whether our faces really show detectable traces of our personalities. It was a partial replication of the [Little & Perrett (2007)](https://bpspsychub.onlinelibrary.wiley.com/doi/10.1348/000712606X109648) study on how accurately people can judge personality based purely on facial features. We re-used their pairs of composite images—faces averaging people high and low in Big Five traits (see the picture from the original research paper below). With this setup, the signal was artificially boosted, so the test situation wasn’t exactly ecologically valid. Plus, students were only ever asked about the relevant trait, and they always saw the high/low images side by side. Still, it was interesting to see whether people (here, psychologists in training) could pick up the signal from these images, even under such idealized conditions.
![](./detecting-personality-in-the-face/composite_images.gif)
The results? Since the task was binary (“*Who from this pair has a higher level of trait X?*”), an accuracy of 0.5 would mean pure random guessing with no skill involved. The students (*N*=382) actually did reliably better than chance, but not by much: their overall average accuracy was 0.6 (95% CrI [0.59, 0.61]), with the best results for openness (0.75, 95% CrI [0.72, 0.78]), agreeableness (0.7, 95% CrI [0.67, 0.73]), and extraversion (0.65, 95% CrI [0.61, 0.69]), and the worst for conscientiousness (0.53, 95% CrI [0.5, 0.56]) and neuroticism (0.42, 95% CrI [0.39, 0.45]). Interestingly, students were able to detect neuroticism in faces to some extent—but in the wrong direction; they consistently flipped the labels. Not the main topic of this post, but definitely something worth thinking about. Any guesses or working hypotheses as to why that might be? ```r # uploading libraries library(tidyverse) library(brms) library(bayesplot) library(posterior) library(patchwork) # uploading collected data (number of successes from 5 traits x 4 trials) data <- readxl::read_xlsx("scores.xlsx") # glimpse(data) # data enrichment for data modeling mydata <- data %>% dplyr::mutate( trials_20 = 20, trials_4 = 4, respondent_id = stringr::str_glue("respondent_{row_number()}") ) # glimpse(mydata) # function for running hierarchical binomial model run_binomial_model <- function(df, successes, trials) { # constructing the formula dynamically formula <- brms::bf( as.formula(paste0(successes, " | trials(", trials, ") ~ 1 + (1 | respondent_id)")) ) # fitting the model binomial_model <- brms::brm( formula = formula, data = df, family = binomial(link = "logit"), prior = prior(normal(0, 5), class = "Intercept"), chains = 4, iter = 2000, warmup = 1000, cores = 4, seed = 2025 ) return(binomial_model) } # fitting the model for all responses and by individual traits # overall results all_model <- run_binomial_model( df = mydata, successes = 'overall_score', trials='trials_20' ) # extraversion ext_model <- run_binomial_model( df = mydata, successes = 'extraversion_score', trials='trials_4' ) # openness ope_model <- run_binomial_model( df = mydata, successes = 'openness_score', trials='trials_4' ) # neuroticism neu_model <- run_binomial_model( df = mydata, successes = 'neuroticism_score', trials='trials_4' ) # agreeableness agr_model <- run_binomial_model( df = mydata, successes = 'agreeableness_score', trials='trials_4' ) # conscientiousness con_model <- run_binomial_model( df = mydata, successes = 'conscientiousness_score', trials='trials_4' ) # summary(all_model) # function for ploting posterior distribution of the success rate parameter plot_post_distribution <- function(fitted_model, task){ fitted_model %>% tidybayes::gather_draws(b_Intercept) %>% dplyr::rename(value = .value) %>% dplyr::mutate(prop = plogis(value)) %>% dplyr::select(prop) %>% dplyr::reframe( Q025 = quantile(prop, probs = c(0.025)), Q975 = quantile(prop, probs = c(0.975)), mean = mean(prop) ) g <- fitted_model %>% tidybayes::gather_draws(b_Intercept) %>% dplyr::rename(value = .value) %>% dplyr::mutate(prop = plogis(value)) %>% ggplot2::ggplot(aes(x = prop)) + ggdist::stat_halfeye( fill = "lightblue", .width = c(0.5, 0.8, 0.95), interval_size_domain = c(1,4) ) + ggplot2::geom_vline(xintercept = 0.5, linetype='dashed', color='grey') + ggplot2::scale_x_continuous(limits = c(0,1)) + ggplot2::labs( title = stringr::str_glue("Posterior distribution of the success rate parameter for {task}"), y = 'Density', x = 'Success rate parameter' ) + ggplot2::theme_minimal() + ggplot2::theme( panel.grid.major = element_blank(), panel.grid.minor = element_blank(), # 2) Draw the x‐ and y‐axis lines axis.line.x = element_line(color = "grey"), axis.line.y = element_line(color = "grey"), # 3) Guarantee white everywhere panel.background = element_rect(fill = "white", color = NA), plot.background = element_rect(fill = "white", color = NA) ) return(g) } # creating the plots all_g <- plot_post_distribution(fitted_model = all_model, task = 'all traits') ext_g <- plot_post_distribution(fitted_model = ext_model, task = 'Extraversion') ope_g <- plot_post_distribution(fitted_model = ope_model, task = 'Openness') neu_g <- plot_post_distribution(fitted_model = neu_model, task = 'Neuroticism') agr_g <- plot_post_distribution(fitted_model = agr_model, task = 'Agreeableness') con_g <- plot_post_distribution(fitted_model = con_model, task = 'Conscientiousness') # combining the plots into one chart final_plot <- all_g / ope_g / agr_g / ext_g / con_g / neu_g final_plot + plot_annotation(caption = "The output from a Bayesian hierarchical binomial regression model.\nThe horizontal lines represent the 50%, 80%, and 95% credible intervals, respectively.\nThe vertical lines represent the success rate corresponding to random guessing.") ``` ![](./detecting-personality-in-the-face/unnamed-chunk-1-1.png) That inspired me to put together a simple Python script that compares someone’s facial photo to the “prototypical” high/low trait faces, and then makes a guess about that person’s trait level—all powered by the [FaceNet-512d](https://huggingface.co/py-feat/facenet) deep convolutional neural network (via the [deepface](https://github.com/serengil/deepface) package), which generates 512-dimensional embeddings of facial images, capturing the unique features of a face, and facilitating tasks like face recognition, verification, and clustering.
![](./detecting-personality-in-the-face/schema.png)
It worked surprisingly well given how simple the approach is—at least for me and a few friends—though I haven’t tested it more widely yet. I’m curious how it’ll work for you. If you feel like giving it a shot, you’ll find the script and all the supp files in this [GitHub repo](https://github.com/lstehlik2809/Estimating-personality-from-a-facial-photo.git). Let me know how it goes in the comments if you try it out. ⚠️ Caveat: I’m definitely not claiming this is a valid and reliable way to measure anyone’s personality. Sure, there may be some signal in faces, and we might be able to pick up on it a bit, but it’s so weak and noisy that it’s not very practical. There are far better tools for that. MBTI, for example (just kidding 🙃). Still, if you’re curious, have some fun and see what subtle signals your face might be broadcasting to the world 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-05-20-detecting-personality-in-the-face/) on my blog. --- # What actually makes us happier? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/systematic-review-of-happiness-interventions Date: 2025-05-06 Tags: well-being, psychology, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-05-06-systematic-review-of-happiness-interventions/ A recent systematic review by [Folk & Dunn (2024)](https://www.annualreviews.org/content/journals/10.1146/annurev-psych-022423-030818) explored what actually boosts our subjective well-being by focusing only on preregistered experiments—studies with pre-declared analysis plans that help prevent cherry-picking and the inflated effect sizes that plagued earlier happiness research. **What seems to work:** *Being more sociable* - Even brief interactions with strangers boost mood • *Smiling naturally* - The facial feedback effect is real (when not forced) • *Injecting novelty* - Treating regular weekends like vacations improves satisfaction • *Financial support* - Cash transfers to disadvantaged populations create lasting happiness gains **Mixed or limited evidence:** *Practicing gratitude* - Shows immediate benefits but lacks long-term effects • *Prosocial spending* - Using money to benefit others works, but effects are small • *Reducing social media* - Brief breaks don't help, but month-long breaks might • *Buying time-saving services* - Works for busy professionals but not for financially constrained populations **What doesn't seem to work (despite popular belief):** *Random acts of kindnes*s - No clear evidence they boost happiness • *Meditation* - Limited evidence for mood benefits • *Volunteering* - Surprisingly, no evidence from rigorous studies • *Changing diet* - Fruit/vegetable consumption showed no significant benefits. Key Takeaway: Many popular happiness strategies aren't backed by strong evidence. The most reliable path might be simpler: be more social, smile more genuinely, seek novel experiences, and if possible, help provide financial security to those in need. Having said that, we should not expect miracles as effect sizes for happiness interventions are typically small (d ≈ 0.20; see attached charts below with four major outcome variables for illustration). ```r # libraries for data manipulationan and dataviz library(tidyverse) library(patchwork) # uploading table with size effects based on results presented in the research paper (only for four major outcomes) data <- readxl::read_excel("results.xlsx") # plotting function plot_effect <- function(df, var_name, desc = FALSE, title) { var_sym <- sym(var_name) chart <- df %>% select(intervention, !!var_sym) %>% drop_na(!!var_sym) %>% group_by(intervention) %>% mutate(med = median(!!var_sym)) %>% ungroup() %>% ggplot(aes( x = fct_reorder(intervention, med, .desc = desc), y = !!var_sym )) + geom_point(size=2) + coord_flip() + geom_hline(yintercept = 0, linetype='dashed') + labs( x = NULL, y = "EFFECT SIZE (COHEN'S D)", title = title ) + ggplot2::theme( plot.title = element_text(face = "bold", size = 18, margin = margin(0, 0, 10, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin = margin(0, 0, 20, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 10, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15), color = '#2C2F46', face = "plain", size = 13, hjust = 0), axis.title.y.left = element_text(margin = margin(r = 15), color = '#2C2F46', face = "plain", size = 13), axis.text.y = element_text(size = 12, lineheight = 1.2), axis.line = element_line(colour = "#E0E1E6"), legend.position = "top", legend.box = "horizontal", legend.justification = "right", legend.key = element_blank(), legend.text = element_text(size = 12), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks = element_line(color = "#E0E1E6"), plot.margin = unit(c(5, 5, 5, 5), "mm"), plot.title.position = "plot", plot.caption.position = "plot" ) return(chart) } # creating plots for four major outcomes g1 <- plot_effect(data, "positive_affect", desc = FALSE, title = "Positive Affect") g2 <- plot_effect(data, "negative_affect", desc = TRUE, title = "Negative Affect") g3 <- plot_effect(data, "life_general_satisfaction", desc = FALSE,title = "Life & General Satisfaction") g4 <- plot_effect(data, "happiness", desc = FALSE, title = "Happiness") # combining the plots into 2x2 matrix g <- (g1 | g2) / (g3 | g4) # adding whole plot annotations g <- g + plot_annotation( title = "The Science of Happiness: Comparing Intervention Effects from Preregistered Experiments", caption = "Individual dots represent effect size estimates from the reviewed studies.\nIn each chart—except for the one on negative affect—interventions are listed in descending order of median effect size.\nSource: 'How Can People Become Happier? A Systematic Review of Preregistered Experiments' by Dunigan Folk and Elizabeth Dunn (2024)", theme = theme( plot.title = element_text(face = "bold", size = 20), plot.caption = element_text(size = 10, hjust = 1, face = 'italic') ) ) print(g) ``` ![](./systematic-review-of-happiness-interventions/unnamed-chunk-1-1.png) *Note: I put the charts together myself using data from the paper—so any slip-ups in the visuals are on me.* ⚠️ A few limitations are worth noting: the review included only 65 preregistered studies, many of which relied on WEIRD (Western, Educated, Industrialized, Rich, Democratic) populations and measured only short-term effects, limiting the ability to draw conclusions about long-term happiness. Additionally, some outcomes may have been influenced by participants’ expectations rather than the interventions themselves. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-05-06-systematic-review-of-happiness-interventions/) on my blog. --- # I’ve finally lived to see the day… URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-analytics-and-market-basket-analysis Date: 2025-04-30 Tags: machine-learning, teams, motivation, personality Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-30-people-analytics-and-market-basket-analysis/ Early in my data science journey, one method that kept popping up in courses and study materials was market basket analysis—a simple technique designed to identify items that commonly occur together, typically in retail scenarios. In this context, many of you probably remember the famous “beer and diapers” example from the ’90s—the finding that young dads were often picking up a six-pack and diapers in the same trip to the store. Despite its popularity, up until recently, I never really found a practical use for this method in my day-to-day work. But now, after many years, I finally came across a situation in a PA project where it actually came in handy. The task was to identify, within a team profile analysis, which pairs or trios of motives—as measured by the MVPI method—tend to show up together in a team within individual team members. This was meant to support interpretation for those running a series of team workshops (see the illustrative chart below).
![](./people-analytics-and-market-basket-analysis/mvpi_basket_analysis.png)
The long wait was probably simply because the PA problems I’d worked on hadn’t really called for it before. But maybe it was also partly down to a lack of imagination on my part 🤔 What about you? Curious if you've found this method useful in any of the common PA tasks. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-30-people-analytics-and-market-basket-analysis/) on my blog. --- # RWA – A go-to tool for key drivers analysis of employee survey data? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/rwa Date: 2025-04-22 Tags: employee-survey, regression-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-22-rwa/ If you've been working with employee survey data for a while, you're probably familiar with [Relative Weights Analysis (RWA)](https://www.scotttonidandel.com/rwa-web). But for those of us who started working with this type of data on a regular basis more recently, discovering RWA can be a (very welcome) surprise. 🤓 RWA is a statistical technique used to assess the relative importance of predictor variables in multiple regression models, particularly when multicollinearity is present. When predictors are highly correlated—which is very common in employee survey data—traditional regression coefficients (beta weights) can become unstable and difficult to interpret, making it nearly impossible to gauge the true importance of any single predictor. RWA tackles this by transforming the correlated predictors into a set of orthogonal (uncorrelated) variables that are maximally related to the originals. From there, it partitions the model’s total explained variance ($R^2$), attributing a specific percentage to each predictor. A key advantage is that these percentages—the relative weights—conveniently sum to 100% of the explained variance, providing a complete and intuitive breakdown of what drives the outcome. Crucially, each predictor's resulting weight represents its total share of the credit for explaining the outcome — a single number that fairly incorporates both the predictor's unique explanatory power and its proportional share of the variance it shares with correlated predictors. RWA does not separate these two components; instead, it blends them into one weight per predictor, which is why the weights sum cleanly to 100% of the explained variance. As you can imagine, this makes RWA especially useful for key drivers analysis of employee survey data when we want to determine the relative importance of (typically highly intercorrelated) survey items or indices for prediction of overall scores or external outcomes. One just needs to remember that these weights still reflect only association, not causation. Moreover, determining the statistical significance of individual relative weights isn’t straightforward, as their sampling distribution isn’t well-defined. However, with bootstrapping, estimating the corresponding confidence intervals becomes quite easy. Any helpful tips from folks who’ve used RWA more extensively? If so, feel free to share them in the comments. P.S. If interested, you can find an example of this method in action using the *rwa* R package below. ---------------------------------- We will use a sample dataset that accompanies the book [Predictive HR Analytics: Mastering the HR Metric](https://www.amazon.com/Predictive-HR-Analytics-Mastering-Metric/dp/0749484446) by Edwards & Edwards (2019). It contains the survey responses of 832 employees on a 1 ‘strongly disagree’ to 5 ‘strongly agree’ response scale for a following set of statements. ```r # uploading libraries library(readxl) library(DT) library(tidyverse) # uploading legend to the data legend <- readxl::read_excel("./surveyResults.xls", sheet = "Legend") # user-friendly table with individual survey items DT::datatable( legend %>% dplyr::mutate(Scale = as.factor(Scale)), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` And here is a table with the survey responses we will analyse. ```r # uploading data data <- readxl::read_excel("./surveyResults.xls", sheet = "Data") # selecting relevant vars mydata <- data %>% dplyr::select(ManMot1:pos3) # user-friendly table with the data used in the analysis DT::datatable( mydata, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` For the analysis, we'll need to make some adjustments to the data and compute scale scores based on the relevant items. ```r # preparing the data for analysis mydata <- mydata %>% # adding employee ID dplyr::mutate(employee_id = row_number()) %>% # changing data format from wide to long tidyr::pivot_longer(ManMot1:pos3, names_to = 'item', values_to = 'rating') %>% # adding information about the scales individual items belong to dplyr::left_join(legend %>% select(Scale, Item), by = c('item'='Item'), keep = FALSE) %>% dplyr::rename('scale' = 'Scale') %>% # computing scores for individual scales from relevant items dplyr::group_by(employee_id, scale) %>% dplyr::summarise(score = mean(rating, na.rm = TRUE), .groups = 'drop') %>% # changing data format back to wide tidyr::pivot_wider(names_from = 'scale', values_from = 'score') # user-friendly table with the data used in the analysis DT::datatable( mydata, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` In our example, we’ll focus on selected relevant predictors of quitting intentions. First, we’ll use a pair plot to explore the relationships among the individual scales. Our main interest lies in the correlations between these predictors and quitting intentions, but we’ll also examine the intercorrelations among the predictors themselves, as these can indicate how much RWA might help us address potential multicollinearity. ```r library(GGally) # setting outcome variable and predictors outcome <- 'Quitting intentions' predictors <- c('Autonomy', 'Management', 'Managerial motivation', 'Procedural justice', 'Perceived organizational support') # checking correlations between survey scales # custom functions for diag and lower panels lower_fn <- function(data, mapping, ...) { ggplot(data = data, mapping = mapping) + ggplot2::geom_point(alpha = 0.5) + ggplot2::geom_smooth(method = "lm", se = FALSE, color='darkred', ...) + ggplot2::theme_minimal() + ggplot2::theme( panel.grid.minor.y = element_blank(), panel.grid.minor.x = element_blank() ) } diag_fn <- function(data, mapping, ...) { ggplot2::ggplot(data = data, mapping = mapping) + ggplot2::geom_density(fill = "skyblue", color = "skyblue", ...) + ggplot2::theme_minimal() + ggplot2::theme( panel.grid.minor.y = element_blank(), panel.grid.minor.x = element_blank() ) } # ggpairs call GGally::ggpairs( mydata %>% select(outcome, predictors), lower = list(continuous = lower_fn), diag = list(continuous = diag_fn) ) ``` ![](./rwa/unnamed-chunk-4-1.png) In the top row of the pair plot, we can see that *Quitting Intentions* are moderately negatively correlated with the other scales—most strongly with *Perceived Organizational Support* (around *r*=-0.35), followed by *Management* and *Procedural Justice* (around *r*=-0.25), and finally with *Management Motivation* and *Autonomy* (around *r*=-0.20). Correlations between the predictor variables themselves are slightly higher but still fall within the moderate range. The highest correlation is around *r*=0.5 between *Procedural Justice* and *Management*. This suggests that multicollinearity should not be an issue, but still, the intercorrelations are big enough to have an impact on our efforts to estimate relative importance of individual predictors. Let’s see how it plays out. After running the RWA, we can see from the table below that the overall regression model explains about 16% of the variability in quitting intentions, and nearly 50% of this predictive power is attributable to *Perceived Organizational Support*, while the remaining predictors each contribute between 10% and 15%. ```r library(rwa) # running the RWA model model <- rwa::rwa( mydata, outcome = outcome, predictors = predictors, applysigns = TRUE, plot = FALSE ) # extracting the R-squared value r_squared <- model$rsquare print(paste("R-squared:", round(r_squared, 3))) # creating df with the results results_df <- data.frame( "Predictor" = predictors, "Rescaled Weight" = model$result$Rescaled.RelWeight ) results_df %>% dplyr::arrange(desc(Rescaled.Weight)) %>% print() ``` These numbers differ quite a bit from what we’d get by simply converting the correlation coefficients above into percentages—in that case, the predictors would appear more similar in their impact. ```r # running Persoan correlation analysis cor( mydata %>% select(outcome, predictors), use = "pairwise.complete.obs", method = "pearson" ) %>% as.data.frame() %>% tibble::rownames_to_column(var = "Predictor") %>% dplyr::select(Predictor, `Quitting intentions`) %>% dplyr::rename("Correlation" = "Quitting intentions") %>% dplyr::filter(Predictor != "Quitting intentions") %>% dplyr::mutate( "Rescaled Correlation" = round(abs(Correlation)/sum(abs(Correlation))*100,1) ) %>% dplyr::arrange(desc(abs(Correlation))) ``` And they’re also quite different from the rescaled coefficients from the standard multiple regression analysis—which suggests our efforts with RWA weren’t in vain. 😉 ```r library(broom) # creating a copy of mydata with properly named vars for using formula in the glm function modeling_data <- setNames(mydata, make.names(names(mydata))) # fitting the GLM model_lm <- glm( Quitting.intentions ~ Management + Job.satisfaction + Managerial.motivation + Procedural.justice + Perceived.organizational.support, data = modeling_data , family = gaussian(link = "identity") ) # making model summary tidy + computing rescaled coefficient estimates for comparison broom::tidy(model_lm, conf.int = TRUE) %>% filter(term != "(Intercept)") %>% select( Predictor = term, Estimate = estimate, SE = std.error, `CI Lower` = conf.low, `CI Upper` = conf.high, `p-value` = p.value ) %>% dplyr::arrange(desc(abs(Estimate))) %>% dplyr::mutate( `Rescaled Estimate` = round(abs(Estimate)/sum(abs(Estimate))*100,1) ) %>% dplyr::select(Predictor, `Rescaled Estimate`, everything()) ``` We may also want to quantify the uncertainty around our estimates of the relative weights. To do that, we’ll use a bootstrapping method, since the sampling distribution of relative weights isn’t well-defined. The resulting estimates, along with their 95% confidence intervals, are shown in the bar chart below. ```r library(boot) # estimating CI using bootstrapping # function to compute raw and rescaled relative weights with signs compute_rwa <- function(data, indices) { sample_data <- data[indices, ] model <- rwa::rwa( sample_data, outcome = outcome, predictors = predictors, applysigns = TRUE, plot = FALSE ) return(model$result$Rescaled.RelWeight) } # setting seed for reproducibility set.seed(2025) # performing bootstrap boot_results <- boot::boot( data = mydata, statistic = compute_rwa, R = 10000 ) # initializing a matrix to store confidence intervals boot_cis <- matrix(NA, nrow = length(predictors), ncol = 2) rownames(boot_cis) <- predictors colnames(boot_cis) <- c("CI_lower", "CI_upper") # calculating confidence intervals for each predictor using BCa method for (i in 1:length(predictors)) { ci <- boot::boot.ci(boot_results, type = "bca", index = i, conf = 0.95) boot_cis[i, ] <- ci$bca[4:5] } # calculating mean relative weights # relative_weights <- colMeans(boot_results$t) # creating a df with results final_results <- data.frame( Predictor = predictors, RescaledWeight = results_df[, "Rescaled.Weight"], CI_Lower = boot_cis[, "CI_lower"], CI_Upper = boot_cis[, "CI_upper"] ) # display the results # print(final_results) # visualizing the results # ensuring predictors are treated as ordered factors final_results <- final_results %>% dplyr::mutate(Predictor = forcats::fct_reorder(Predictor, RescaledWeight, .desc = FALSE)) ggplot2::ggplot(final_results, aes(x = Predictor, y = RescaledWeight)) + ggplot2::geom_bar(stat = "identity", fill = "skyblue") + ggplot2::geom_errorbar( aes(ymin = CI_Lower, ymax = CI_Upper), width = 0.2, color = '#2C2F46' ) + ggplot2::geom_text(aes( label = stringr::str_glue("{round(RescaledWeight, 1)}%")), color = '#2C2F46', size = 3.5, hjust = -0.2, vjust = -0.5 ) + ggplot2::scale_y_continuous( breaks = seq(0,70,10), labels = scales::number_format(suffix ="%"), expand = expansion(mult = c(0.01, 0.1)) ) + ggplot2::labs( title = "Relative Weights (Key Drivers) Analysis for Quitting Intentions", subtitle = stringr::str_glue("Model's R-squared: {round(r_squared, 2)}"), x = "", y = "Rescaled Relative Weight", caption = '\nBars show each predictor’s relative contribution to R² (rescaled to 100%).\nError bars are 95% CIs estimated via bootstrapping using the bias-corrected and accelerated (BCa) method.' ) + ggplot2::coord_flip() + ggplot2::theme_minimal(base_size = 14) + ggplot2::theme( plot.title = ggtext::element_markdown(face = "bold", size = 18, margin = margin(0, 0, 10, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 14, margin = margin(0, 0, 10, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 10, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15), color = '#2C2F46', face = "plain", size = 13, hjust = 0), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 11), panel.grid.major.y = element_blank(), panel.grid.minor.y = element_blank(), panel.grid.minor.x = element_blank(), plot.margin = unit(c(5, 5, 5, 5), "mm"), plot.title.position = "plot", plot.caption.position = "plot", plot.background = element_rect(fill='white', color = "white"), ) ``` ![](./rwa/unnamed-chunk-8-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-22-rwa/) on my blog. --- # Why do psychologists disagree—even when they use the same data and methods? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/scientific-divides-and-cognitive-traits Date: 2025-04-18 Tags: psychology, critical-thinking, cognitive-science, diversity Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-18-scientific-divides-and-cognitive-traits/ An interesting new study published in *Nature Human Behaviour* by [Sulik et al. (2025)](https://doi.org/10.1038/s41562-025-02153-1) suggests the answer might lie within the psychologists themselves. Researchers surveyed nearly 8,000 psychologists and found their positions on key scientific debates—such as whether behavior is best explained by biology or social context—were consistently (though modestly) linked to their cognitive traits. For instance, psychologists with high tolerance for ambiguity favored holistic, context-driven explanations, while those preferring clear structure leaned toward clear-cut, rule-based models of mind.
![](./scientific-divides-and-cognitive-traits/charts.png)
***a–d**,Regression coefficients for controversial themes as a function of research areas (**a**), research methods (**b**), cognitive traits (**c**) and gender (**d**). For tables of full numeric results, see Supplementary Tables 2–5. Cells marked ‘x’ are non-significant (with Bonferroni correction for multiple comparisons—the number of cells in each panel—yielding thresholds P < 0.000208 for a, P < 0.000142 for b and P < 0.000223 for c). All continuous variables are z-scored. Plot margins show hierarchical clusters (Ward’s method). d, In place of clusters for gender, given the low dimensionality of the space representing gender, two themes are shown where men gave lower scores than women and two with the reverse pattern (violin plots show full response distributions, with group means in red).* Interestingly, these cognitive dispositions were also reflected in their publication patterns, collaborations, and theoretical preferences. The study thus serves as a valuable reminder that scientific disagreements may not be just about facts, but also about how individual scientists think. It also highlights the importance of fostering cognitive diversity—both within and beyond scientific communities—to broaden research perspectives and enhance epistemic progress. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-18-scientific-divides-and-cognitive-traits/) on my blog. --- # Causal insights with no code? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/visual-diff-in-diff Date: 2025-04-15 Tags: data-science, causal-inference, difference-in-differences, people-analytics, evidence-based-management Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-15-visual-diff-in-diff/ If there’s one causal inference method that is both intuitive and easy to explain—even to non-data folks—it’s difference-in-differences (DiD). For those who haven’t come across it yet: DiD compares how things change over time for a group affected by an intervention (the treatment group) versus one that isn’t (the control group). The key assumption is that both groups would have followed similar trends if the intervention hadn’t occurred. This can be partially validated by checking whether their trends were similar *before* the change. If that holds, and we observe a notable shift in the treatment group *after* the intervention—beyond the usual trend—we can reasonably attribute the difference to the intervention. Besides the “parallel trends” assumption, there are a few other important ones—like no spillover effects, no simultaneous interventions, common shocks, and stable group composition—but these can usually be assessed with solid domain knowledge. Given the intuitive nature of DiD, even those who don’t work with data every day—like HRBPs and similar roles—can, IMO, apply a lighter, visual version of this method on their own in BI tools like Tableau, Power BI, or—specifically for people analytics—platforms like Visier or OneModel. As an example, check out the chart below. It was created in one of those BI platforms just by picking a relevant metric and filtering for the right departments and time period. It shows the trailing 12-month voluntary attrition rate for two departments—only one of which was supposed to be affected by a policy change, based on the nature of their work. Before the policy change, Department A had slightly higher attrition than Department B, but both were trending downward in parallel. After the change, the trend diverged sharply: attrition in Department B went up, while it continued downward in Department A. If we can reasonably confirm the other DiD assumptions, this gives us pretty solid evidence that the policy change caused the increase in attrition in Department B.
![](./visual-diff-in-diff/plot2.png)
I'm definitely not saying this lighter version of DiD is a silver bullet for all causal questions in people analytics—especially since it depends on specific quasi-experimental setups, like the one shown above, and doesn’t deal with the uncertainty present in the data. That said, IMO, it can still help non-technical folks handle some of the lower-stakes cases on their own, giving data science teams more room to focus on the big stuff. What do you think? Could this be a useful piece of the puzzle in boosting data literacy among HR professionals? Do you see any substantial risks? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-15-visual-diff-in-diff/) on my blog. --- # Putting the "ideal" personality for high work engagement in a broader context URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personality-and-work-engagement Date: 2025-04-08 Tags: employee-engagement, personality, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-08-personality-and-work-engagement/ It's well-known that personality explains a large part of work engagement. For example, a recent meta-analysis by [Fokuzaki & Iwata (2022)](https://pmc.ncbi.nlm.nih.gov/articles/PMC8980698/?hl=en-US) on this topic concluded that “*conscientiousness had the strongest association with WE (ρ=0.41), followed by extraversion and openness to experience (0.38), neuroticism (−0.36), and agreeableness (0.27). Moreover, 30% of the WE variance could be explained by the five-factor model (R^2^=0.33, 95%CI=0.26–0.49)*”.
![](./personality-and-work-engagement/table1.png)
This pattern closely aligns with the theory of the general factor of personality (The Big One), a proposed basic, highest-level personality disposition characterized by high vs. low emotional stability, conscientiousness, agreeableness, extraversion, and openness, and associated with social desirability, emotionality, motivation, well-being, life satisfaction, and self-esteem [(Musek, 2007)](https://www.sciencedirect.com/science/article/pii/S0092656607000256). But what about those of us who don’t fit the 'ideal' personality profile? Does that mean we're, on average, doomed to be less engaged at work? 🤔 Fortunately, no. First, 30% of the variance is substantial but still leaves much unexplained. Therefore, other factors certainly play a role, many of which we can influence more easily than our personality traits. For instance, a recent meta-analysis on the antecedents (and outcomes) of work engagement by Mazzetti et al. (2023) found that closely following personal resources, which had the strongest effect of *r*=.48, were organizational (.47) and developmental resources (.45), with social (.36) and job resources (.37) slightly further behind.
![](./personality-and-work-engagement/table_metaanalysis_results.png)
Second, various moderating mechanisms, such as workplace conditions, can be at play. For example, employees high in conscientiousness benefit more from increased job control, emotionally stable individuals respond better to higher job demands and greater autonomy, and introverted employees may derive more benefit from supervisor support compared to their extraverted counterparts [(Fokuzaki & Iwata, 2024)](https://www.mdpi.com/2076-328X/14/10/936).
![](./personality-and-work-engagement/interactions.png)
Third, the relationship between the two can be more complex, as suggested by [Narayanasami et al. (2023)](https://www.researchgate.net/publication/376263593_Disentangling_the_relationship_between_Big-Five_personality_characteristics_and_work_engagement_Evidence_from_India), who offered a more nuanced view by examining personality in relation to three specific facets of engagement—vigor, dedication, and absorption. Surprisingly, they found that “*contrary to what was hypothesized, the results suggest that (i) extraversion is negatively related to absorption, (ii) agreeableness is negatively related to dedication, (iii) neuroticism is positively related to vigor, and (iv) openness to experience is negatively related to dedication.*”
![](./personality-and-work-engagement/path_diagram.png)
In short, personality matters, but it seems to be far from the one-ideal-personality-fits-all story. 😅 ⚠️ A caveat to keep in mind: The studies referenced are primarily correlational and don't establish direct cause-and-effect relationships. Therefore, it's prudent to exercise caution when drawing causal conclusions. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-08-personality-and-work-engagement/) on my blog. --- # What topics might you encounter at the SIOP 2025 conference? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/siop-2025-conference-events Date: 2025-04-04 Tags: siop, i-o-psychology, topic-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-04-04-siop-2025-conference-events/ My LinkedIn bubble these days is full of posts from the [SIOP](https://www.siop.org/) 2025 conference. Unfortunately, it’s too far for me to attend in person. So, to participate at least symbolically, I quickly ran a [BERTopic](https://maartengr.github.io/BERTopic/index.html) pipeline on the SIOP event schedule to see what some of the major topics are and what I'll be missing. 😁
![](./siop-2025-conference-events/plot.png)
Apparently, a large part is devoted to AI & ML, workplace experience, I/O psychology career-building, and networking—some really nice ones! Fortunately, things aren't as hopeless as they might sound. We have a secret (or maybe not-so-secret) agent there: Marissa Post, our US colleague and an awesome member of our Sanofi People Insights team. She’s attending in person, so we'll at least get the industry news and gossip secondhand. 😉 To everyone attending, enjoy the conference—and please share some summaries on LinkedIn later! 🙏 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-04-04-siop-2025-conference-events/) on my blog. --- # Did the Nobel Prize put causal inference on the public radar? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/nobel-prize-and-causal-inference-popularity Date: 2025-03-30 Tags: causal-inference, segmented-regression, time-series, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2025-03-30-nobel-prize-and-causal-inference-popularity/ For me—and probably for many others—the final wake-up call to start taking causal inference seriously came when [David Card, Joshua Angrist, and Guido Imbens were awarded the Nobel Prize (NP)](https://www.nobelprize.org/prizes/economic-sciences/2021/press-release/) in October 2021 for demonstrating "*what conclusions about cause and effect can be drawn from natural experiments*" using econometric methods. This got me wondering if there was any concrete evidence that this event sparked broader interest—not just within my own bubble but across the wider public space. My go-to tool for questions like this is [Google Trends](https://trends.google.com/trends), which tracks the popularity of search terms over time and serves as a solid proxy for public interest. But there's a snag: [Google occasionally changes its data collection methods](https://link.springer.com/article/10.1007/s44248-024-00013-3), causing artificial jumps in the statistics. Unfortunately for me, one of these changes happened shortly after the NP announcement. Luckily, [segmented regression for interrupted time series](https://www.researchgate.net/publication/227681545_Segmented_Regression_Analysis_of_Interrupted_Time_Series_Studies_in_Medication_Use_Research)—a method I used to model shifts in interest levels and trends after the NP announcement—can easily account for such external shocks. So, what did the analysis of the search term "*causal inference*" (based on global data from the past 10 years) actually show? The evidence for immediate increase in interest was rather faint (especially compared to the spike due to Google's data collection update), but a steeper upward trend was quite clear in the months following the NP announcement. I also run a kind of refutation test, when looking at two related terms—"*causal analysis*" and "*data analysis*"—expecting only the first to show a similar, though probably weaker, pattern. And indeed, "*causal analysis*" behaved similarly to "*causal inference*", while "*data analysis*" showed only the method-change-related spike, adding credibility to the NP effect hypothesis. But it's still possible that there was an additional concurrent event, unknown to me, that could partially or fully explain the pattern. 🤔 If you're interested, you can find the analysis details—along with the code and data—below. By the way, if you haven't jumped on the causality bandwagon yet, there's plenty of great material available. Staying close to the Nobel laureates, Joshua Angrist co-authored a couple of super accessible books: ["Mastering 'Metrics: The Path from Cause to Effect"](https://www.amazon.com/Mastering-Metrics-Path-Cause-Effect/dp/0691152845) and ["Mostly Harmless Econometrics: An Empiricist's Companion"](https://www.amazon.com/Mostly-Harmless-Econometrics-Empiricists-Companion/dp/0691120358). He also offers an excellent video series, ["Mastering Econometrics"](https://www.youtube.com/playlist?list=PL-uRhZ_p-BM5ovNRg-G6hDib27OCvcyW8), available on YouTube. ---------------------------------- ### Appendix: Deep dive for the curious 🤓 Let's import the necessary packages for our analysis, along with the dataset. The data was downloaded from Google Trends and includes global web search interest from March 2015 to March 2025 for the following three search terms: “*causal inference*”, “*causal analysis*”, and “*data analysis”*. ```r # uploading libraries library(tidyverse) library(lubridate) library(DT) library(brms) library(tidybayes) library(bayesplot) library(ggdist) library(patchwork) library(parallel) # uploading the data downloaded from the Google Trends site (global web search between 03-2015 and 03-2025) causal_inference <- 'causal_inference' causal_analysis <- 'causal_analysis' data_analysis <- 'data_analysis' causal_inference_df <- read.csv(stringr::str_glue("multiTimeline_{causal_inference}.csv"), header = TRUE, skip = 1) causal_analysis_df <- read.csv(stringr::str_glue("multiTimeline_{causal_analysis}.csv"), header = TRUE, skip = 1) data_analysis_df <- read.csv(stringr::str_glue("multiTimeline_{data_analysis}.csv"), header = TRUE, skip = 1) # setting new col names colnames(causal_inference_df) <- c('date', 'causal_inference') colnames(causal_analysis_df) <- c('date', 'causal_analysis') colnames(data_analysis_df) <- c('date', 'data_analysis') # putting all three dfs into one df all_df <- causal_inference_df %>% dplyr::bind_cols(causal_analysis_df %>% dplyr::select(causal_analysis)) %>% dplyr::bind_cols(data_analysis_df %>% dplyr::select(data_analysis)) # changing the date var format from char to date all_df <- all_df %>% dplyr::mutate(date = lubridate::ym(date)) # dplyr::glimpse(all_df) # showing data in the table DT::datatable( all_df, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` First, let's visually examine how interest in these three search terms has evolved over time, including two key events: the announcement of NP in October 2021 and the change in data collection methods in January 2022. At a glance, the search patterns appear fairly similar across the three terms, though there seem to be subtle differences—particularly in the steepness of the slope following the NP announcement and the method change. However, our eyes and brains can easily be misled by random noise, so to more reliably tell signal from noise in the data, it’s safer to take the help of statistical modeling. ```r all_df %>% tidyr::pivot_longer(causal_inference:data_analysis, names_to = 'term', values_to = 'interest') %>% dplyr::mutate( term = stringr::str_to_title(stringr::str_replace_all(term, '_', ' ')), term = factor(term, levels = c("Causal Inference", "Causal Analysis", "Data Analysis")) ) %>% ggplot2::ggplot(aes(x=date, y=interest)) + ggplot2::geom_point() + ggplot2::geom_line() + ggplot2::facet_wrap(~term, nrow = 3) + # highlighting Nobel Prize announcement and change in the data collection method ggplot2::geom_vline(xintercept = as.Date('2021-10-01'), linetype='dashed') + ggplot2::geom_vline(xintercept = as.Date('2022-01-01'), linetype='dashed') + ggplot2::annotate( "text", x = as.Date("2021-10-01"), y = 105, label = "Nobel Prize", hjust = 1.1, vjust = 0, size = 3 ) + ggplot2::annotate( "text", x = as.Date("2022-01-01"), y = 105, label = "Method change", hjust = -0.1, vjust = 0, size = 3 ) + ggplot2::scale_x_date(date_breaks = "1 year", date_labels = "%Y") + ggplot2::labs( title = stringr::str_glue("Google search interest in selected terms over time"), x="", y="INTEREST", caption = "Source: Google Trends (global web search between 03-2015 and 03-2025)." ) + ggplot2::theme_minimal() + ggplot2::theme( strip.text = element_text(size = 11, face = 'bold') ) ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-2-1.png) Since we're working with time series data, it's important to check for the presence of seasonality and autocorrelation to determine whether—and how—these factors should be incorporated into our model. Here, we explicitly examine only the “*causal inference*” search term, as it is our primary outcome variable of interest. However, in the original analysis, this check was also performed for the two remaining search terms. The seasonal-trend decomposition plot and the autocorrelation function (ACF) plot both reveal meaningful patterns: a clear seasonal trend and significant autocorrelation at a lag of 2, respectively. We will account for these in our model by including a month variable and an appropriate autocorrelation term. ```r # checking presence of seasonality and autocorrelation in the data my_ts <- ts(all_df['causal_inference'], frequency = 12) # classical seasonal-trend decomposition decomposed <- decompose(my_ts) plot(decomposed) # ACF plot for checking the 'depth' of autocorrelation acf(my_ts, lag.max = 40, main="ACF plot (with max lag 40)") ``` As mentioned in the main text, to model Google search interest, we will use a segmented regression model with the following general structure: $$Y_{t} = β_{0} + β_{1}*time_{t} + β_{2}*intervention_{t} + β_{3}*time after intervention_{t} + e_{t}$$ The *β0* coefficient estimates the baseline level of the outcome variable at time zero; *β1* coefficient estimates the change in the mean of the outcome variable that occurs with each unit of time before the intervention (i.e. the baseline trend); *β2* coefficient estimates the level change in the mean of the outcome variable immediately after the intervention (i.e. from the end of the preceding segment); and *β3* estimates the change in the trend in the mean of the outcome variable per unit of time after the intervention, compared with the trend before the intervention (thus, the sum of *β1* and *β3* equals to the post-intervention slope). For a better understanding of the model, take a look at the illustrative chart taken from [Turner et al. (2021)](https://bmcmedresmethodol.biomedcentral.com/articles/10.1186/s12874-021-01306-w) below.
![](./nobel-prize-and-causal-inference-popularity/interruptedTimeSeriesAnalysis.png)
Since we are dealing with correlated and truncated data, we will also include two additional terms in the model—an autocorrelation term with lag 2 and a truncation term, to handle these specific properties of our data. As follows from the above, in order to perform segmented regression, we need to enrich the original dataset with several additional variables: * **Elapsed time** – a numerical variable representing the number of months that have passed since the beginning of the whole time series. This variable allows us to estimate the overall trend in the data. * **Nobel Prize** – a binary variable indicating whether the Nobel Prize announcement has already occurred. This enables us to estimate any immediate level change in interest following the event. * **Elapsed time after Nobel Prize announcement** – a numerical variable indicating the number of months since the Nobel Prize announcement. This allows us to assess any change in the trend of interest after the event. * **Data collection method change** – a binary variable marking whether the change in data collection method has already taken place. This helps estimate any immediate level shift caused by the event and control for its effect on the outcome of interest. (We assume this event influences only the level—not the trend—so no elapsed time variable is included for this event.) * **Month** – a categorical variable representing the specific month within a year. This allows us to control for seasonal effects in the model. ```r # enriching and munging the data modeling_data <- all_df %>% dplyr::mutate( month = lubridate::month(date,label = TRUE, abbr = TRUE), month = factor(month, levels = c("Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"), labels = c("Jan","Feb","Mar","Apr","May", "Jun","Jul","Aug","Sep","Oct","Nov","Dec"), ordered = FALSE) ) %>% # arranging data in ascending order by date dplyr::arrange(date) %>% # creating new variables dplyr::mutate( elapsed_time = row_number(), nobel_prize = case_when( date >= '2021-10-01' ~ 1, TRUE ~ 0 ), elapsed_time_after_np = cumsum(nobel_prize), nobel_prize = factor(ifelse(nobel_prize == 1, "After Nobel Prize", "Before Nobel Prize"), levels = c("Before Nobel Prize", "After Nobel Prize")), new_data_collection_method = case_when( date >= '2022-01-01' ~ 1, TRUE ~ 0 ), new_data_collection_method = factor(ifelse(new_data_collection_method == 1, "After method change", "Before method change"), levels = c("Before method change", "After method change")), ) # dplyr::glimpse(modeling_data) # showing data in the table DT::datatable( modeling_data, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` Now, let's specify our segmented regression model for Google search interest in causal inference, fit it using the [brms package](https://cran.r-project.org/web/packages/brms/index.html) for Bayesian regression modeling, and verify whether the model has successfully converged and provides a reasonable fit to the outcome variable—before proceeding to interpret the estimated coefficients. The MCMC chain plots, the posterior predictive check, and the data overlaid with draws from the posterior fit of the model all indicate that the model fitting was successful. ```r # defining the statistical model for the 'causal inference' search term causal_inference_model <- brms::brm( causal_inference | trunc(lb = 0, ub = 100) ~ elapsed_time + nobel_prize + elapsed_time_after_np + new_data_collection_method + month + ar(p = 2), data = modeling_data, family = gaussian(), # prior = priors, # let's use default, non-informative priors chains = 4, iter = 5000, warmup = 2000, control = list(adapt_delta = 0.98), seed = 12345, silent = 2, refresh = 0, cores = parallel::detectCores() ) # summary(causal_inference_model) # plotting the MCMC chains bayesplot::mcmc_trace( causal_inference_model, facet_args = list(nrow = 6) ) + ggplot2::labs( title = "Plots of the MCMC chains used for estimation of the model's parameters" ) ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-5-1.png) ```r # posterior predictive check # specifying the number of samples ndraws = 100 brms::pp_check( causal_inference_model, ndraws = ndraws ) + ggplot2::labs( title = stringr::str_glue("Posterior predictive check (using {ndraws} samples)"), x = "OUTCOME VARIABLE", y = "DENSITY" ) ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-6-1.png) ```r # visualizing original data overlaid with draws from the posterior fit of the model data_with_fitted_model <- function(model, search_term_name) { g <- modeling_data %>% tidybayes::add_epred_draws(model, ndraws = 100) %>% ggplot2::ggplot(aes(x = date, y = .data[[search_term_name]])) + ggplot2::geom_line(aes(y = .epred, group = .draw), alpha = 1/20, color = "#08519C") + ggplot2::geom_point(data = modeling_data) + ggplot2::geom_vline( xintercept = as.Date('2021-10-01'), linetype = 'dashed' ) + ggplot2::geom_vline( xintercept = as.Date('2022-01-01'), linetype = 'dashed' ) + ggplot2::annotate( "text", x = as.Date("2021-10-01"), y = 105, label = "Nobel Prize", hjust = 1.1, vjust = 0.1 ) + ggplot2::annotate( "text", x = as.Date("2022-01-01"), y = 105, label = "Method change", hjust = -0.1, vjust = 0 ) + ggplot2::scale_x_date(date_breaks = "1 year", date_labels = "%Y") + ggplot2::labs( title = stringr::str_glue("Google search interest in '{stringr::str_replace_all(search_term_name, '_', ' ')}' term over time"), subtitle = "With 100 draws from the posterior fit of the segmented regression model", x = "", y = "INTEREST", caption = "Source: Google Trends (global web search between 03-2015 and 03-2025)." ) + ggplot2::theme_minimal() return(g) } data_with_fitted_model(model=causal_inference_model, search_term_name = "causal_inference") ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-7-1.png) Now we can examine the estimated coefficients of interest. The evidence for an immediate increase in interest following the NP announcement is not particularly strong—especially when compared to the spike caused by Google's data collection update. However, a noticeably steeper upward trend is evident in the months following the NP announcement. ```r # function for creating posterior plots posterior_plots <- function(model, search_term_name){ # visualizing posterior distribution of the 'after Nobel Prize announcement' parameter from the fitted model g1 <- model %>% tidybayes::gather_draws(b_nobel_prizeAfterNobelPrize) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot(aes(x = value)) + ggdist::stat_halfeye( fill = "lightblue", .width = c(0.5, 0.8, 0.95), interval_size_domain = c(1,4) ) + ggplot2::geom_vline( xintercept = 0, linetype='dashed' ) + ggplot2::labs( title = "Posterior distribution of the 'after NP announcement' parameter", y = 'Density', x = 'Parameter value', caption = 'The horizontal lines represent the 50%, 80%, and 95% credible intervals, respectively.' ) + ggplot2::theme_minimal() # visualizing posterior distribution of the 'elapsed time after Nobel Prize announcement' parameter from the fitted model g2 <- model %>% tidybayes::gather_draws(b_elapsed_time_after_np) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot(aes(x = value)) + ggdist::stat_halfeye( fill = "lightblue", .width = c(0.5, 0.8, 0.95), interval_size_domain = c(1,4) ) + ggplot2::geom_vline( xintercept = 0, linetype='dashed' ) + ggplot2::labs( title = "Posterior distribution of the 'elapsed time after NP announcement' parameter", y = 'Density', x = 'Parameter value', caption = 'The horizontal lines represent the 50%, 80%, and 95% credible intervals, respectively.' ) + ggplot2::theme_minimal() # visualizing posterior distribution of the 'after new data collection method' parameter from the fitted model g3 <- model %>% tidybayes::gather_draws(b_new_data_collection_methodAftermethodchange) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot(aes(x = value)) + ggdist::stat_halfeye( fill = "lightblue", .width = c(0.5, 0.8, 0.95), interval_size_domain = c(1,4) ) + ggplot2::geom_vline( xintercept = 0, linetype='dashed' ) + ggplot2::labs( title = "Posterior distribution of the 'after new data collection method' parameter", y = 'Density', x = 'Parameter value', caption = 'The horizontal lines represent the 50%, 80%, and 95% credible intervals, respectively.' ) + ggplot2::theme_minimal() # combining all charts into one plot g <- g1 / g2 / g3 + patchwork::plot_annotation( title = stringr::str_glue("Posterior distribution of the coefficients from the '{stringr::str_replace_all(search_term_name, '_', ' ')}' model"), theme = theme(plot.title = element_text(size = 15)) ) return(g) } # visualizing posterior distribution of estimated coefficients of interest posterior_plots(model=causal_inference_model, search_term_name = "causal_inference") ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-8-1.png) To test the robustness of our conclusions regarding the NP hypothesis, let's perform a kind of refutation test by examining whether a similar pattern appears in the closely related term "*causal analysis*", but not in the less closely related term "*data analysis*". For brevity, we won’t run the full analysis pipeline here for these additional search terms; instead, we’ll only fit the model and check the posterior coefficient estimates—but rest assured that all the necessary steps were conducted in the original analysis (and you can always replicate it yourself using the code above to make sure I didn’t make any mistakes 😉). So, what are the results? As you can see in the charts below, "*causal analysis*" indeed behaves similarly to "*causal inference*", while "*data analysis*" shows only the spike related to the method change. This lends additional credibility to the NP effect hypothesis. However, it's still possible that an additional, unknown concurrent event could partially or fully explain the observed pattern. 🤔 ```r # defining the statistical model for the 'causal analysis' search term causal_analysis_model <- brms::brm( causal_analysis | trunc(lb = 0, ub = 100) ~ elapsed_time + nobel_prize + elapsed_time_after_np + new_data_collection_method + month + ar(p = 2), data = modeling_data, family = gaussian(), # prior = priors, # let's use default, non-informative priors chains = 4, iter = 5000, warmup = 2000, control = list(adapt_delta = 0.98), seed = 12345, silent = 2, refresh = 0, cores = parallel::detectCores() ) # summary(causal_analysis_model) # visualizing posterior distribution of estimated coefficients of interest posterior_plots(model=causal_analysis_model, search_term_name = "causal_analysis") ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-9-1.png) ```r # defining the statistical model for the 'data analysis' search term data_analysis_model <- brms::brm( data_analysis | trunc(lb = 0, ub = 100) ~ elapsed_time + nobel_prize + elapsed_time_after_np + new_data_collection_method + month + ar(p = 2), data = modeling_data, family = gaussian(), # prior = priors, # let's use default, non-informative priors chains = 4, iter = 5000, warmup = 2000, control = list(adapt_delta = 0.98), seed = 12345, silent = 2, refresh = 0, cores = parallel::detectCores() ) # summary(data_analysis_model) # visualizing posterior distribution of estimated coefficients of interest posterior_plots(model=data_analysis_model, search_term_name = "data_analysis") ``` ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-10-1.png) ## Figures ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-3-1.png) ![](./nobel-prize-and-causal-inference-popularity/unnamed-chunk-3-2.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-03-30-nobel-prize-and-causal-inference-popularity/) on my blog. --- # How to get causal interpretation for the Employee Attrition dataset? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/econml-and-employee-attriton Date: 2025-03-18 Tags: causal-inference, python, employee-turnover Original post: https://blog-about-people-analytics.netlify.app/posts/2025-03-18-econml-and-employee-attriton/ To be honest, whenever I see an analysis using the popular [IBM Employee Attrition dataset](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset), I tend to ignore it and quickly skip to something more interesting and engaging. A classic search for shiny new things in action. 😉 Btw, did you know this dataset has already been with us for about 10 years? Pretty nice milestone anniversary! At least, that's what my OpenAI deep research found—it seems it was first released by IBM around September 2015 on its blog to showcase the IBM Watson Analytics platform's capabilities in an HR context. So, please take this finding with a grain of salt. However, after checking the sources (including my own memory), it seems pretty plausible to me. But feel free to correct me/us if I’m/we’re wrong. Recently, however, I broke this habit when I came across a [Jupyter notebook on EconML’s GitHub](https://github.com/py-why/EconML/blob/main/notebooks/Solutions/Causal%20Interpretation%20for%20Employee%20Attrition%20Dataset.ipynb) showcasing how to effectively combine classical ML—providing a list of strongest predictors—with their subsequent causal interpretation using [Double ML (DML)](https://docs.doubleml.org/stable/index.html) and [Heterogeneous Treatment Effect (HTE)](https://www.sciencedirect.com/science/article/pii/S0049089X22001211) estimation, nicely packaged into the [CausalAnalysis class](https://econml.azurewebsites.net/_autosummary/econml.solutions.causal_analysis.CausalAnalysis.html). The entire pipeline includes the following steps: 1. Fine-tuning and training a traditional ML model. 2. Using [SHAP values](https://shap.readthedocs.io/en/latest/index.html) for correlation interpretation of the results, identifying the top predictors highlighted by the ML model. 3. Employing DML combined with HTE to test whether these top predictors actually have a direct causal effect on attrition. While these may be strong predictors of employee departure, they don't necessarily drive employees to leave. 4. Using the HTE component for employee segmentation by specific risk factors, enabling individualized plans to reduce attrition. For instance, salary might have a higher causal impact on shorter-tenured employees, whereas overtime could show the opposite pattern. 5. Finally, cohort analysis, enabling assessment of causal effects of selected factors on new datasets, even down to the individual employee level.
![](./econml-and-employee-attriton/causal_effect_chart_combo.png)
IMO, pretty cool stuff. It seems we indeed live not only in an AI revolution but also in a causal one ✊🙂 ⚠️ Just a small (or big?) warning at the end: Despite the smooth and easy analytical workflow enabled by the CausalAnalysis class, it doesn't replace the need for strong domain knowledge and understanding which variables make sense to include in the model in the first place. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-03-18-econml-and-employee-attriton/) on my blog. --- # How to analyze employee survey results with less (selection) bias? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/psw-and-selection-bias-in-employee-surveys Date: 2025-03-10 Tags: employee-survey, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2025-03-10-psw-and-selection-bias-in-employee-surveys/ When analyzing employee survey results, do you systematically account for the fact that your response rate isn't 100% and that participation isn't random, as employees probably decide to participate based on their satisfaction levels—the very thing you're aiming to measure? Personally, I haven't been rigorous about this. At best, I've checked the representativeness of survey respondents across various demographic factors. This allowed me to identify which groups within the organization were underrepresented and speculate which might be less satisfied, assuming less satisfied employees are less likely to participate (though it's possible the opposite scenario could be true as well!). Recently, while exploring [propensity score weighting](https://pmc.ncbi.nlm.nih.gov/articles/PMC3144483/) (PSW) in the context of causal inference, I discovered that this method isn't only useful for reducing confounding bias but also selection bias. This makes it highly relevant for analysis of employee surveys, where a significant portion of the workforce often doesn't participate, and participation itself is likely linked to the very attitudes and behaviors you're investigating. By modeling response behavior using demographic and other relevant variables and weighting responses inversely to the employees’ probability of participating, you can achieve a more accurate—neither overly optimistic nor pessimistic—estimate of average employee satisfaction, essentially approximating what you would observe if everyone had participated. (You can hear in this the echoes of causal inference—aiming to estimate a counterfactual scenario, one that didn't actually happen but is important to consider to make proper conclusions about the world). The implementation isn't overly complex. If you're interested in a quick start, you can check out illustrative Python code I've prepared using dummy data that simulates employee responses with a non-random likelihood of participation. First, let's prepare the data to fit our selection bias scenario—left-skewed engagement scores of 10,000 employees, along with demographic variables that influence the probability of employees' participation in the survey. ```r # reticulate library for running Python in .Rmd file library(reticulate) ``` ```python # importing libraries to be used import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression import matplotlib.pyplot as plt import seaborn as sns # setting random seed for reproducibility np.random.seed(2025) # simulating employee data n_employees = 10000 data = pd.DataFrame({ "employee_id": np.arange(n_employees), "gender": np.random.choice(["Male", "Female"], size=n_employees, p=[0.5, 0.5]), "age": np.random.randint(20, 65, size=n_employees), "department": np.random.choice(["Manufacturing", "Sales", "IT", "HR"], size=n_employees, p=[0.4, 0.3, 0.2, 0.1]), }) # defining base participation probability (100%) data["base_prob"] = 1 # adjusting participation probability based on bias factors data.loc[data["gender"] == "Female", "base_prob"] -= 0.2 # lower participation for females data.loc[data["age"] < 30, "base_prob"] -= 0.35 # lower participation for younger employees data.loc[data["department"] == "Manufacturing", "base_prob"] -= 0.4 # lower participation in Manufacturing data["base_prob"] = np.clip(data["base_prob"], 0.1, 0.99) # ensuring probabilities stay in a reasonable range # simulating left-skewed engagement scores with beta distribution raw_engagement = np.random.beta(a=2, b=10, size=n_employees) * 10 raw_engagement = np.max(raw_engagement) - raw_engagement # standardizing raw engagement scores to be between 0 and 10 raw_engagement = 10 * (raw_engagement - np.min(raw_engagement)) / (np.max(raw_engagement) - np.min(raw_engagement)) ``` ```python # plotting distribution of engagement scores sns.kdeplot(raw_engagement, label="Raw Engagement", fill=True, color='#a0cbe8', alpha=1) plt.xlabel("Engagement Score") plt.ylabel("Density") plt.title("Engagement Score Distribution") plt.show() ``` ![](./psw-and-selection-bias-in-employee-surveys/unnamed-chunk-3-1.png) Now, let's introduce a negative correlation between the engagement score and participation probability and verify that employees who didn't participate indeed have lower engagement scores on average. ```python # introducing correlation between engagement score and participation probability data["engagement"] = raw_engagement + 5 * (data["base_prob"] - np.mean(data["base_prob"])) data["engagement"] = np.clip(data["engagement"], 0, 10) # keeping engagement scores in range # simulating participation data["participated"] = np.random.rand(n_employees) < data["base_prob"] data['participated_num'] = data['participated'].astype(int) # comparing engagement by participation # preparing data for plotting data_x = [data[data['participated_num'] == 0]['engagement'], data[data['participated_num'] == 1]['engagement']] # creating the raincloud plot fig, ax = plt.subplots(figsize=(10, 6)) # defining colors boxplots_colors = ['#f28e2b', '#4e79a7'] violin_colors = ['#f28e2b', '#4e79a7'] scatter_colors = ['#f28e2b', '#4e79a7'] # adding boxplot bp = ax.boxplot(data_x, patch_artist=True, vert=False, showfliers=False, medianprops={'color': 'black', 'linewidth': 1.5}) for patch, color in zip(bp['boxes'], boxplots_colors): patch.set_facecolor(color) patch.set_alpha(0.4) # adding half of the violin plot vp = ax.violinplot(data_x, points=500, showmeans=False, showextrema=False, showmedians=False, vert=False) for idx, b in enumerate(vp['bodies']): m = np.mean(b.get_paths()[0].vertices[:, 0]) b.get_paths()[0].vertices[:, 1] = np.clip(b.get_paths()[0].vertices[:, 1], idx + 1, idx + 2) b.set_color(violin_colors[idx]) # adding scatter plot for idx, features in enumerate(data_x): y = np.full(len(features), idx + .8) idxs = np.arange(len(y)) out = y.astype(float) out.flat[idxs] += np.random.uniform(low=-.05, high=.05, size=len(idxs)) y = out plt.scatter(features, y, s=.3, c=scatter_colors[idx]) # adding mean values inside the boxplots means = [np.mean(features) for features in data_x] for idx, mean in enumerate(means): ax.scatter(mean, idx + 1, color='red', s=50, zorder=3) # adding labels and title plt.yticks(np.arange(1, 3, 1), ['Not Participated', 'Participated']); plt.xlabel('Engagement Score') plt.title("Engagement by Participation") plt.show() ``` ![](./psw-and-selection-bias-in-employee-surveys/unnamed-chunk-4-3.png) We can clearly see in the raincloud plots above that non-participants indeed have significantly lower engagement scores than participants. This discrepancy naturally results in a difference between the average engagement score of the full population and that of our observed, biased sample, as illustrated in the chart below. ```python # computing engagement means before adjustment for the full population and observed sample observed_mean = data.loc[data["participated"], "engagement"].mean() true_mean = data["engagement"].mean() # plotting engagement scores before weighting sns.kdeplot(data.loc[data["participated"], "engagement"], label="Biased Sample", fill=False) sns.kdeplot(data["engagement"], label="Full Population", fill=False) # adding vertical lines for mean values plt.axvline(observed_mean, color='#4e79a7', linestyle='dotted', label='Observed Mean') plt.axvline(true_mean, color='#f28e2b', linestyle='dotted', label='True Mean') plt.legend() plt.xlabel("Engagement Score") plt.ylabel("Density") plt.title("Engagement Score Distribution Before Weighting") plt.show() ``` ![](./psw-and-selection-bias-in-employee-surveys/unnamed-chunk-5-5.png) To improve our estimation, let's use PSW, which will enable us to approximate a scenario in which all employees participate, while relying solely on data from participating employees. ```python # fitting logistic regression to estimate propensity scores X = pd.get_dummies(data[["gender", "age", "department"]], drop_first=True) y = data["participated"].astype(int) logit = LogisticRegression() logit.fit(X, y) # computing propensity scores (predicted probabilities of participation) data["propensity_score"] = logit.predict_proba(X)[:, 1] data["weight"] = 1 / data["propensity_score"] # Inverse probability weighting data["weight"] = np.clip(data["weight"], 1, 5) # Prevent extreme weights # computing weighted engagement mean for observed sample weighted_mean = np.average(data.loc[data["participated"], "engagement"], weights=data.loc[data["participated"], "weight"]) # displaying results print(f"True engagement mean (full population): {true_mean:.2f}") print(f"Observed engagement mean (biased sample): {observed_mean:.2f}") print(f"Weighted engagement mean (after adjustment): {weighted_mean:.2f}") # plotting engagement scores before/after weighting sns.kdeplot(data.loc[data["participated"], "engagement"], label="Biased Sample", fill=False) sns.kdeplot(data["engagement"], label="Full Population", fill=False) # adding vertical lines for mean values plt.axvline(observed_mean, color='#4e79a7', linestyle='dotted', label='Observed Mean') plt.axvline(true_mean, color='#f28e2b', linestyle='dotted', label='True Mean') plt.axvline(weighted_mean, color='black', linestyle='dotted', label='Adjusted Observed Mean') plt.legend() plt.xlabel("Engagement Score") plt.ylabel("Density") plt.title("Engagement Score Distribution Before/After Weighting") plt.show() ``` As the numbers and chart above clearly show, using PSW has brought our estimation much closer to the average engagement of the full population, as if all employees had participated in the survey. However, the accuracy of this adjustment depends on how well we can model the probability of participation using available variables and the extent to which participation probability is systematically related to engagement scores in ways that our model can adjust for. In a real-world scenario, the results may not be as precise as in this simulation, where we have full control over the conditions. Nonetheless, PSW typically reduces bias, bringing estimates closer to the true engagement level, provided key assumptions hold. Have you tried similar methods in your employee survey analyses? What's your experience been like? Any caveats, lessons learned, or warnings you'd share? ## Figures ![](./psw-and-selection-bias-in-employee-surveys/unnamed-chunk-6-7.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-03-10-psw-and-selection-bias-in-employee-surveys/) on my blog. --- # Are age and gender variables really so non-actionable in the context of People Analytics? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/non-actionable-vars-in-pa Date: 2025-03-05 Tags: people-analytics, hr-metrics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-03-05-non-actionable-vars-in-pa/ Are age and gender variables really so non-actionable in the context of People Analytics? I hear this statement a lot—not just about these two specific demographic variables, but as a broader complaint about the data situation in PA in general. I see why people say this—after all, we can’t change age or gender at an individual level. But does that really mean they aren’t actionable? IMO, not really. Just because we can’t change these variables on an individual level doesn’t mean they aren’t actionable when it comes to policy, processes, or strategy. Here are a few common examples: * **Gender & Pay Equity:** A company’s audit reveals a 5% gender pay gap—not due to performance but structural bias. Potential action? Adjust salaries and implement bias-free promotion processes. * **Age & Workforce Planning:** A firm finds that 30% of senior leadership will retire in five years. Potential action? Develop succession pipelines and mentorship programs. * **Hiring Bias Detection:** An analysis shows that women drop off disproportionately after the first interview round. Potential action? Redesign interview criteria for bias-free selection. * **Health & Well-being Initiatives:**Younger employees struggle with mental health, while older employees need more physical health support. Potential action? Design and promote age-specific well-being programs. And the list could go on. True, these potential actions don’t stem from these two variables alone but from their combination with other variables and contextual information, including goals, values, etc. However, this applies to all variables we work with in any field of BI, right? What’s your perspective? Have you seen an example of turning demographic data into real impact? Feel free to drop your thoughts in the comments.
![](./non-actionable-vars-in-pa/change_my_mind.jfif)
--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-03-05-non-actionable-vars-in-pa/) on my blog. --- # Experiencing and seeing team similarities and differences URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/team-maps Date: 2025-02-26 Tags: personality, teams, data-visualization, feedback Original post: https://blog-about-people-analytics.netlify.app/posts/2025-02-26-team-maps/ One of the most powerful and impactful ways to present team personality profiles during team development workshops—at least in my experience—is by allowing people to experience their similarities and differences as spatial distances in the real world. A simple exercise for this is having the team stand in a line based on their scores for each personality dimension. This way, people can better see and "digest" how they relate to one another in psychological space. This exercise can then be further supported with a multidimensional team map that translates individuals’ profile similarities and differences across all personality dimensions into an easily understandable spatial layout. The map visually represents these dis/similarities as distances, with color coding to indicate how each person scored on different personality traits. This kind of dataviz can also help people recognize existing subgroups of like-minded individuals within the team. This process can even be automatized by applying a clustering algorithm to the personality profiles and displaying the results directly on the team map.
![](./team-maps/team_maps.gif)
*The GIF above shows several maps illustrating team members' psychological similarities across all HPI dimensions at once, their respective scores on individual HPI dimensions, and information about identified subgroups.* To sum it up, if you run team development workshops, give it a try—your clients might find it valuable, and it could become a regular part of your practice. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-02-26-team-maps/) on my blog. --- # A way to make prediction models more precise and interpretable at the same time? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/causal-identification-and-feature-selection Date: 2025-02-13 Tags: causal-inference, machine-learning, predictive-analytics, interpretability Original post: https://blog-about-people-analytics.netlify.app/posts/2025-02-13-causal-identification-and-feature-selection/ I just came across an interesting paper by [Ding, Zhang, & Bos (2018)](https://arxiv.org/abs/1712.07708?utm_source=chatgpt.com), who used automatic causal identification algorithms for feature selection and achieved better prediction performance with models based on these features than with models built in a more traditional way. The authors specifically studied the experiences and choices of 3,293 visitors at a large theme park and attempted to use them to predict their Big Five personality characteristics. First, they applied two algorithms for causal identification—the [PC algorithm](https://mschauer.github.io/CausalInference.jl/latest/examples/pc_basic_examples/) and the [Fast Greedy Equivalence Search algorithm](https://github.com/juangamella/ges?tab=readme-ov-file)—to narrow down the candidate DAGs that explain the data. Then, they used the outputs from these algorithms to identify features that were causally impacted by the personality characteristic of interest. Finally, they built prediction models using only these features.
![](./causal-identification-and-feature-selection/fig1.png)
The authors found that their ML pipeline, which incorporated causal identification for feature selection, outperformed baseline models in predicting individual characteristics (specifically, they used [LASSO linear regression](https://en.wikipedia.org/wiki/Lasso_(statistics))) as the baseline model, which also performs automatic feature selection). Beyond that, these models, according to the authors, provided more human-interpretable results. A win-win.
![](./causal-identification-and-feature-selection/fig2.png)
For me, these results are quite surprising, as I would expect that if prediction performance were the only criterion, a purely “correlational” approach without causal constraints would perform better. I’m curious how these alleged benefits generalize to other types of situations—such as cases with less noise in the data. Definitely worth trying in one of my future projects. Does anyone have experience with this specific approach to feature selection? Feel free to share your insights. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-02-13-causal-identification-and-feature-selection/) on my blog. --- # And what are your career hurdles? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/career-hurdles Date: 2025-02-05 Tags: career-development, i-o-psychology, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-02-05-career-hurdles/ I came across an interesting meta-analytic review by [Ng & Feldman (2014)](https://www.sciencedirect.com/science/article/pii/S0001879114000761) that might be useful for setting priors about the hurdles people face when it comes to their perception of subjective career success (SCS). The authors estimated the impact of 54 specific hurdles, grouped into six categories that can potentially lower people's SCS: 1. Trait-related 2. Social-network-related 3. Skill-related 4. Organizational and job-related 5. Motivational 6. Background-related hurdles On average, the strongest negative effects were found in the social and organizational hurdle groups, followed by trait-related and motivational hurdles. Interestingly, background-related and skill-related hurdles weren’t strongly associated with SCS. I’d say that’s good news, considering how non/malleable some of these types of hurdles can be 🤔 ```r # uploading libraries library(tidyverse) library(ggtext) library(readxl) # uploading the dataset with stats extracted from the research paper data <- readxl::read_excel('career_hurdles_95CI.xlsx') # defining colors for individual groups of career hurdles category_colors <- c( "Background-related hurdles" = "#1B39A6", "Motivational hurdles" = "#289ECC", "Organizational and job hurdles" = "#B13AA0", "Skill-related hurdles" = "#73A239", "Social network hurdles" = "#895F22", "Trait-related hurdles" = "#D08311" ) # enriching the dataset for coloring the value labels data <- data %>% dplyr::arrange(Category, desc(`95% CI Upper`)) %>% dplyr::mutate( Hurdle_ordered = factor(Hurdle, levels = unique(Hurdle)), Hurdle_colored = paste0("", Hurdle, "") ) # dataviz data %>% ggplot2::ggplot(aes(x = Hurdle_ordered, y = rc, color = Category)) + ggplot2::geom_point(size = 3) + ggplot2::geom_errorbar(aes(ymin = `95% CI Lower`, ymax = `95% CI Upper`), width = 0.2, position = position_dodge(0.05), linewidth = 1) + ggplot2::geom_hline(yintercept = 0, linetype = "dashed", color = "grey") + ggplot2::scale_color_manual(values = category_colors) + ggplot2::coord_flip() + ggplot2::labs( x = "", y = "SAMPLE-SIZE WEIGHTED CORRECTED CORRELATION", title = "Relationships of career hurdles with subjective career success", caption = "\nThe bars around the point estimates represent the 2.5% lower and 97.5% upper limits of the 95% confidence interval.\nThe hurdles are listed by groups and in ascending order based on the upper bound of the 95% confidence interval for effect size.", color = "" ) + ggplot2::theme( plot.title = ggtext::element_markdown(face = "bold", size = 18, margin = margin(0, 0, 10, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin = margin(0, 0, 20, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 10, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15), color = '#2C2F46', face = "plain", size = 13), axis.title.y.left = element_text(margin = margin(r = 15), color = '#2C2F46', face = "plain", size = 13), axis.text.y = element_markdown(size = 12, lineheight = 1.2), # Apply markdown here axis.line = element_line(colour = "#E0E1E6"), legend.position = "top", legend.box = "horizontal", legend.justification = "right", legend.key = element_blank(), legend.text = element_text(size = 12), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks = element_line(color = "#E0E1E6"), plot.margin = unit(c(5, 5, 5, 5), "mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + ggplot2::scale_x_discrete(labels = function(x) data$Hurdle_colored[match(x, data$Hurdle_ordered)]) + ggplot2::guides(color = guide_legend(reverse = TRUE)) ``` ![](./career-hurdles/unnamed-chunk-1-1.png) P.S. As is evident from the code snippet above, the attached chart isn't straight from the paper. I made it based on the results presented there, so any mistakes in the chart are on me! --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-02-05-career-hurdles/) on my blog. --- # How variations in analytic choices affect results? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/analytical-choices-and-variability Date: 2025-02-02 Tags: open-science, data-science Original post: https://blog-about-people-analytics.netlify.app/posts/2025-02-02-analytical-choices-and-variability/ Unfortunately, the same bias can also creep into data analysis. Along the way, we make numerous decisions—some small, some significant—that impact our results. Yet, we often act as if none of these choices happened, believing we've arrived at the one true, objective finding. A great example of this comes from [Silberzahn et al. (2018)](https://journals.sagepub.com/doi/10.1177/2515245917747646), who set out to expose these subjective analytical choices and their influence on results. They asked 29 teams, comprising 61 analysts, to analyze the same dataset and answer the same question: Are soccer referees more likely to give red cards to players with darker skin tones than to those with lighter skin tones? They found that analytical approaches varied widely, leading to effect size estimates ranging from 0.89 to 2.93 (Mdn = 1.31) in odds-ratio units. Twenty teams (69%) found a statistically significant positive effect, while nine teams (31%) did not. Interestingly, neither the analysts’ prior beliefs nor their level of expertise explained the variation. Even peer ratings of analysis quality failed to account for the differences.
![](./analytical-choices-and-variability/results_chart.jpeg)
This study highlights an important reality: even defensible, well-intentioned analytical choices can lead to vastly different results. What should we take from this? Should sensitivity analysis be a standard practice? Should we crowdsource high-profile analyses? What do you think? P.S. If you want to see how your own approach would shape the results, you can download the [original dataset from OSF](https://osf.io/47tnc/?view_only=) and give it a try! --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-02-02-analytical-choices-and-variability/) on my blog. --- # Does your personality interfere with your self-awareness? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/self-awareness-and-personality Date: 2025-01-20 Tags: personality, feedback Original post: https://blog-about-people-analytics.netlify.app/posts/2025-01-20-self-awareness-and-personality/ Many wise people (starting with Plato and his 'Know thyself' idea?) suggest that [self-awareness](https://journals.sagepub.com/doi/full/10.1177/1052562921990065) is one of the key ingredients for psychological well-being, career success, and effective leadership, among other things. If that’s true (see, for example, [this research paper](https://www.econstor.eu/handle/10419/251923) on the impact of self-awareness on leadership behavior and effectiveness), it would be useful to understand what might get in the way of well-calibrated self-awareness. One potential obstacle could be our habitual patterns of feelings, thoughts, and behaviors, i.e., our personality. But to explore this, we first need a way to measure self-awareness. One common (though imperfect) method in a business context is to compare self-evaluation with evaluations from others, such as through multi-rater feedback. The difference between these evaluations can then be analyzed alongside a valid personality measure. In a small-scale project, I had the opportunity to conduct exactly this type of analysis. The individuals assessed—a selected group of 72 managers from a large CEE company—were distributed relatively symmetrically around the zero difference between their self-evaluations and the average evaluations by others across several competencies, measured on a 1–5 Likert scale. For personality, I had access to their data from the [Hogan Personality Inventory](https://www.hoganassessments.com/assessment/hogan-personality-inventory/), a business-oriented personality measure based on the [Five-Factor Model of personality](https://www.sciencedirect.com/topics/psychology/five-factor-model-of-personality).
![](./self-awareness-and-personality/differences_distribution_plot.png)
When modeling the relationship between the discrepancy in self-evaluation and evaluation by others and individual personality traits, I employed a [generalized linear model](https://en.wikipedia.org/wiki/Generalized_linear_model) with a Gaussian link function, and fitted it using the [brms package](https://github.com/paul-buerkner/brms) for Bayesian modeling with its default priors. The results were as follows:
![](./self-awareness-and-personality/density_plots.png)
1. People with higher ambition (assertive, competitive, goal-oriented) and higher sociability (outgoing, talkative, socially confident) tend to overrate their skills—and vice versa. 2. People with a stronger learning approach (curious, improvement-focused, achievement-driven) tend to underrate their skills—and vice versa. 3. There’s some, though not very strong, evidence that people with high adjustment (calm, resilient, stress-tolerant, and less receptive to feedback) also tend to overrate their skills—and vice versa. To better understand the effects found, take a look at the scatter plots with fitted regression lines below:
![](./self-awareness-and-personality/ambition_scatter_plot.png)
![](./self-awareness-and-personality/sociability_scatter_plot.png)
![](./self-awareness-and-personality/learning_approach_scatter_plot.png)
![](./self-awareness-and-personality/adjustment_scatter_plot.png)
These results are, IMO, pretty intuitive and not all that surprising—at least in hindsight 😉—though I personally expected a stronger effect of adjustment and inquisitive traits. What would be your take on it? Have you done a similar type of analysis? If so, what were your results? Caveat: The sample used was very small (just 72 people!) and highly specific—managers from a single company, industry, country, and region—so the results can’t be easily generalized to a broader population. Still, IMO, the findings offer some useful hints about when we should be more mindful of the possibility of misjudging our self-evaluation, whether by overestimating or underestimating ourselves. That said, we shouldn’t forget that overly optimistic self-deception can sometimes be useful, in the “fake it till you make it” spirit 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-01-20-self-awareness-and-personality/) on my blog. --- # We are probably more similar - and different - than expected URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personality-and-cultural-values Date: 2025-01-17 Tags: personality, organizational-culture, multilevel-modeling, cross-cultural-differences Original post: https://blog-about-people-analytics.netlify.app/posts/2025-01-17-personality-and-cultural-values/ An interesting research paper by [Stackhouse et al. (2024)](https://www.sciencedirect.com/science/article/pii/S0191886924002551?ssrnid=4460757&dgcid=SSRN_redirect_SD) looked at the sources of variance in people’s [Big Five personality](https://www.sciencedirect.com/topics/psychology/five-factor-model-of-personality) and [Hofstede cultural values](https://en.wikipedia.org/wiki/Hofstede%27s_cultural_dimensions_theory) (individual-level vs nation-level differences), and at the relationship between these two types of individual characteristics, while taking into account the multi-level nature of the data. The authors used [intraclass correlation (ICC1)](https://en.wikipedia.org/wiki/Intraclass_correlation) to decompose the source of variance in these two constructs. Unsurprisingly, they found that personality only shows small variability that can be attributed to differences between nations (for individual traits, it ranged between 3.1% and 11%). However, surprisingly, similar results were found also for cultural values (from 3.6% to 9.7%)! We thus differ in our cultural values within nations more than we would have expected.
![](./personality-and-cultural-values/icc_table.png)
To assess the strength and direction of the association between personality and cultural values characteristics, the authors used [multilevel correlation analysis](https://www.sciencedirect.com/topics/psychology/multi-level-modeling) and found that the correlations are much weaker than those suggested by previous studies that used only aggregated, nation-level data. The top three correlations - between uncertainty avoidance and agreeableness (*r* = 0.25), gender egalitarianism and agreeableness (*r* = −0.23), and teamwork preference and extraversion (*r* = 0.20) - made it only into the ‘weak effect’ area.
![](./personality-and-cultural-values/correlations.jpg)
*Multilevel associations between the Five Factor Model and cultural values.* All this suggests that the next time you feel an urge to guess someone’s personality or cultural values from their nationality, or to infer one from another, think of this paper and maybe try to base your guess on other information about them 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-01-17-personality-and-cultural-values/) on my blog. --- # Unexpected protective effect of having a good manager? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/managerial-quality Date: 2025-01-15 Tags: people-management, employee-turnover, causal-inference, statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2025-01-15-managerial-quality/ Until now, based on my personal experience and common sense, I would have been quite certain that it would increase the odds of a direct report's departure. After all, we like good bosses so much that we might follow them – even outside our current company, right? So I was quite surprised when I examined a real-world dataset and checked the impact of managers' voluntary departure on employees' probability of leaving, in interaction with manager quality as measured by multi-rater feedback, while controlling for some typical organizational confounders. As expected, a manager's departure increased the odds of an employee leaving later. However, managerial quality actually had a protective effect and decreased the employee's risk of leaving when their manager left. Interestingly, more “technical” skills – such as communicating expectations, goal setting, or providing feedback – seemed to be more important in this respect than “softer” skills – like coaching, psychological safety, or wellbeing support. Now, besides cross-validating the results, I am trying to understand the potential reasons (including missed confounders) behind this pattern. What would be your favorite hypotheses to test? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-01-15-managerial-quality/) on my blog. --- # Self-Leadership: A New Superpower? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/self-leadership Date: 2025-01-09 Tags: leadership, performance, remote-work, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2025-01-09-self-leadership/ A recent meta-analysis by [Knotts et al. (2022)](https://www.tandfonline.com/doi/abs/10.1080/1359432X.2021.1953988), covering 57 studies with over 16K participants, put some numbers on just how important this skill may be when it comes to improving individual outcomes. Here are some of the key findings: * **Pretty strong effect:** The overall relationship between self-leadership and individual outcomes is ρ = .38. It’s even stronger when it comes to creativity and innovation (ρ = .49) compared to basic task performance (ρ = .28). * **It’s not just about actions — it’s about mindset too:** Behavioral strategies like goal-setting are helpful, but adding cognitive strategies like self-talk and mental imagery makes a big difference. People who use a combo of both have stronger results (ρ = .42) than those who focus only on behaviors (ρ = .27). * **Self-efficacy is a key mediator:** The meta-analysis also found that self-leadership boosts self-efficacy — confidence in one’s ability to succeed. This, in turn, leads to better outcomes like job satisfaction, work engagement, and creativity.
![](./self-leadership/path_model.png)
Caveat: Most of the studies included were correlational, meaning they show associations but don’t prove that self-leadership directly causes better performance. And here's some food for thought to end on: With companies implementing flexible work policies, it may be valuable for them to consider self-leadership as part of employee selection criteria. Do you already take this into account? If so, how do you assess this skill in candidates? Asking for a friend 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2025-01-09-self-leadership/) on my blog. --- # Stop. Start. Continue. URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/new-years-resolution Date: 2024-12-30 Tags: goal-setting, learning-and-development, fun-with-data Original post: https://blog-about-people-analytics.netlify.app/posts/2024-12-30-new-years-resolution/ As we enter this part of the year when the 'fresh start' effect can kick in and help us make some desirable changes in our lives, I began to wonder what types of changes people are most inclined to pursue. One way to approach this question is through the well-known [Stop, Start, Continue framework](https://clickup.com/blog/start-stop-continue/), commonly used in employee feedback and development. Looking at [Google Trends](https://trends.google.com/trends/) data and examining the frequency of search terms related to these three actions, it seems that people most often search for ways to stop doing things, followed by how to start doing things, and least often, how to continue doing things.
![](./new-years-resolution/google_trends.png)
Any hypotheses as to why this might be the case? Here are my 'three cents', based on my personal experience: 1. The things we want to stop doing may feel more urgent because we are already experiencing their negative impact in our lives. 2. Stopping certain behaviors can be perceived as more challenging, as it often requires overcoming deeply ingrained habits formed through repeated actions – habits we tend to revert to, especially under stress or cognitive load. Or, as Mark Twain put it, "*It is easier to stay out than to get out.*" 3. The availability heuristic might play a role, as behaviors we already engage in are more easily brought to mind for reflection. More importantly, whatever stops, starts, and continues make it onto your New Year’s resolution list – and for whatever reason – I wish you success in achieving them! 🖖🙂 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-12-30-new-years-resolution/) on my blog. --- # Divorce usually impacts both sides - but does that hold true for Brexit? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/brexit-uk-eurozone Date: 2024-12-15 Tags: causal-inference, fun-with-data Original post: https://blog-about-people-analytics.netlify.app/posts/2024-12-15-brexit-uk-eurozone/ This gap in the conversation provides a great opportunity to try out a causal inference method from the [CausalPy](https://causalpy.readthedocs.io/en/latest/?s=09) package, specifically, the [synthetic control method](https://en.wikipedia.org/wiki/Synthetic_control_method) (with the caveat that its implementation is still a wip, so the outputs should be taken with a grain of salt). Generally, this technique constructs a weighted combination of control units to mimic the characteristics of a treated unit, enabling us to estimate the causal effect of an intervention without a traditional control group. For the analysis, I looked at the combined GDP of 20 eurozone countries from 2007 to 2023 as the outcome of interest. To construct the synthetic control group for counterfactual comparison, I used GDP data from selected OECD countries (non-EU, non-eurozone, non-UK) over the same period.
![](./brexit-uk-eurozone/uk_eu_chart.png)
To my surprise, the results for the eurozone countries contrast pretty starkly with those for the UK (see the charts above). There doesn’t seem to be strong evidence of a significant negative impact from Brexit on eurozone GDP. Of course, this is just one facet of Brexit’s potential effects. There may be many other consequences - beyond what GDP metrics reveal - that apply to the eurozone as well. Still, it’s interesting to observe such a stark difference using the same type of data and methodology. Maybe it reflects what we often see in personal relationships: divorce usually affects both sides, but the costs are only rarely distributed equally and/or in the same ways 🤔 P.S. If interested, the data from the [OECD Data Explorer](https://data-explorer.oecd.org/) and the code for replicating this analysis are provided below. ```r # reticulate library for running Python in .Rmd file library(reticulate) # libraries for data manipulation and visualization library(tidyverse) library(DT) # uploading the data from the OECD Data Explorer at https://data-explorer.oecd.org/ df <- readr::read_csv("oecd_data.csv") # table view of two rows of the data DT::datatable( df, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 2, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy', 'csv', 'excel'), buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` ```python # uploading libraries for data manipulation, visualization, and causal inference import numpy as np import pandas as pd import matplotlib.pyplot as plt import arviz as az import causalpy as cp az.style.use("arviz-white") # uploading the data from the OECD Data Explorer at https://data-explorer.oecd.org/ data = pd.read_csv('oecd_data.csv') data.info() data.head() # keeping only relevant variables data = data[[ 'Reference area', 'TIME_PERIOD', 'OBS_VALUE' ]] # cleaning the quarter column # function to calculate the last day of the quarter def quarter_to_date(q): year, quarter = q.split('-') quarter_month_map = { 'Q1': '03-31', 'Q2': '06-30', 'Q3': '09-30', 'Q4': '12-31' } return f"{year}-{quarter_month_map[quarter]}" data['TIME_PERIOD'] = data['TIME_PERIOD'].apply(quarter_to_date) data['TIME_PERIOD'] = pd.to_datetime(data['TIME_PERIOD'], format='%Y-%m-%d') # cleaning the country names data['Reference area'] = data['Reference area'].str.replace('- ', '', regex=True).str.replace(' ', '_', regex=True).str.replace(r'[()]', '', regex=True) # putting data on the trillion/billion (10^12) unit scale data['OBS_VALUE'] = data['OBS_VALUE']/1000000 # getting data from long to wide format melted_df = data.melt(id_vars=['Reference area', 'TIME_PERIOD'], var_name='Variable', value_name='Value') pivoted_df = melted_df.pivot(index='TIME_PERIOD', columns='Reference area', values='Value') pivoted_df.info() # removing countries with missing values in the time series data pivoted_df.drop(columns=['Saudi_Arabia', 'Russia'], inplace=True) # removing row for date where we have data for only one country pivoted_df = pivoted_df[pivoted_df.index != pd.to_datetime('2024-09-30')] # identifying countries (that didn't left EU) GDP of which most strongly correlated with UK's GDP before actual Brexit (2020 January 31) correlation_df = pivoted_df[pivoted_df.index <= pd.to_datetime("2020 January 31")].corr() print(correlation_df[['United_Kingdom']].drop('United_Kingdom').sort_values(by='United_Kingdom', ascending=False)) # Analysis 1: The impact of Brexit on UK GDP # target country of interest target_country = 'United_Kingdom' # control countries (top 15) other_countries = [ "United_States", "Costa_Rica", "Germany", "New_Zealand", "Sweden", "Austria", "Luxembourg", "Denmark", "Belgium", "Korea", "Canada", "Lithuania", "France", "India", "Estonia" ] # formula formula = target_country + " ~ " + "0 + " + " + ".join(other_countries) print(formula) # modeling data preparation treatment_time = pd.to_datetime("2020 January 31") modeling_data = pivoted_df.copy() modeling_data = modeling_data[[target_country] + other_countries] modeling_data.dropna(axis='rows', inplace=True, how='all') # fitting the model result = cp.SyntheticControl( modeling_data, treatment_time, formula=formula, model=cp.pymc_models.WeightedSumFitter( sample_kwargs={ "random_seed": 2024, "draws": 10000, "tune": 5000, "target_accept": 0.99, "chains": 4 } ) ) # results summary az.summary(result.idata, var_names=["~mu"]) # visualization of the posterior samples az.plot_trace(result.idata, var_names="~mu", compact=False) plt.show() # visualization of the estimated causal effect fig, ax = result.plot(plot_predictors=False) for i in [0, 1, 2]: ax[i].set(ylabel="Trillion USD") plt.show() # Analysis 2: The impact of Brexit on the GDP of eurozone countries # identifying countries (non-EU, non-eurozone, non-UK) GDP of which most strongly correlated with eurozone countries' GDP before actual Brexit (2020 January 31) correlation_df = pivoted_df[pivoted_df.index <= pd.to_datetime("2020 January 31")].corr() print(correlation_df[['Euro_area_20_countries']].drop('Euro_area_20_countries').sort_values(by='Euro_area_20_countries', ascending=False)) # target country of interest target_country = 'Euro_area_20_countries' # control countries (top 12) other_countries = [ "Costa_Rica", "New_Zealand", "United_States", "India", "Korea", "Colombia", "Canada", "Israel", "Iceland", "Switzerland", "Indonesia", "Australia" ] # formula formula = target_country + " ~ " + "0 + " + " + ".join(other_countries) print(formula) # modeling data preparation treatment_time = pd.to_datetime("2020 January 31") modeling_data = pivoted_df.copy() modeling_data = modeling_data[[target_country] + other_countries] modeling_data.dropna(axis='rows', inplace=True, how='all') # fitting the model result = cp.SyntheticControl( modeling_data, treatment_time, formula=formula, model=cp.pymc_models.WeightedSumFitter( sample_kwargs={ "random_seed": 2024, "draws": 10000, "tune": 5000, "target_accept": 0.99, "chains": 4 } ) ) # results summary az.summary(result.idata, var_names=["~mu"]) # visualization of the posterior samples az.plot_trace(result.idata, var_names="~mu", compact=False) plt.show() # visualization of the estimated causal effect fig, ax = result.plot(plot_predictors=False) for i in [0, 1, 2]: ax[i].set(ylabel="Trillion USD") plt.show() ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-12-15-brexit-uk-eurozone/) on my blog. --- # "A new broom sweeps clean" URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/managers-and-performance-evaluations Date: 2024-12-12 Tags: performance-management, employee-survey, i-o-psychology Original post: https://blog-about-people-analytics.netlify.app/posts/2024-12-12-managers-and-performance-evaluations/ As the year comes to a close, my LinkedIn feed is buzzing with posts about performance evaluations - some positive, but mostly calling out the negatives. This, combined with reflecting on one of my own past experiences as a people manager, got me wondering: do managers who are relatively new to their direct reports tend to be stricter (or perhaps more objective?) in their evaluations, on average? My hypothesis is based on the assumption that a shorter history with their direct reports allows managers to focus more purely on performance, without the influence of relationship-building efforts, potentially leading to more objective assessments. One straightforward way to explore this question is to analyze how employee evaluations vary based on the length of time they’ve worked under their current manager - while controlling for other relevant factors like employees’ and managers’ age, company and position tenure, gender, etc. Looking at the available data, it seems to support my initial “suspicion.” The effect is there but quite small - specifically, there’s only a 2.5% increase in the odds of being in a higher performance category for each additional year spent working under the same manager (95% CI [1.4%, 3.5%]). However, when compounded over four years - a realistic scenario for the average employee - this results in a more noticeable 10.3% increase.
![](./managers-and-performance-evaluations/performance_eval_estimates.png)
While this sheds some light on my original question, it’s worth noting that this pattern could still be explained by alternative mechanisms. For instance, over time, direct reports’ performance might better align with their manager’s expectations as they incorporate their feedback. Another possible explanation is a selection effect, where managers are more likely to retain those they consider to be high performers in their teams over time. So, the quest can happily continue 😉 Curious if anyone has practical experiences with systematically studying the mechanisms behind managers’ evaluations of their direct reports’ performance. If so, I’d love to hear your thoughts or suggestions - feel free to share them in the comments or drop me a message via [email](mailto:ludek.stehlik@gmail.com) or [LinkedIn](https://www.linkedin.com/in/ludekstehlik/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-12-12-managers-and-performance-evaluations/) on my blog. --- # Not all workplace relationships are created equal when it comes to retaining talent URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/instrumental-and-expressive-networks Date: 2024-11-21 Tags: network-analysis, employee-turnover, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2024-11-21-instrumental-and-expressive-networks/ I recently came across an interesting and potentially useful meta-analysis by [Porter et al. (2018)](https://www.researchgate.net/publication/328379014_How_Do_Instrumental_and_Expressive_Network_Positions_Relate_to_Turnover_A_Meta-Analytic_Investigation/link/5bd1ee3fa6fdcc3a8da63aee/download?_tp=eyJjb250ZXh0Ijp7ImZpcnN0UGFnZSI6InB1YmxpY2F0aW9uIiwicGFnZSI6InB1YmxpY2F0aW9uIn19) that used meta-analytic path analysis to explore specific mechanisms through which two types of workplace networks are related to employee turnover: 1. **Instrumental networks:** These are job-related connections that provide resources like advice, expertise, or work-related information, built through initiatives like knowledge-sharing programs, access to key stakeholders, and attending external conferences. 2. **Expressive networks:** These are connections with focus on emotional support, friendships, and social bonding, developed through activities like company social events, cross-team job rotations, and peer mentorship programs.
![](./instrumental-and-expressive-networks/model_to_be_tested.png)
The study found that both network types are connected with turnover but through different mechanisms. Instrumental ties reduce turnover by improving job performance (thanks to access to work-related resources) and strengthening organizational commitment. Meanwhile, expressive ties lower turnover by increasing job satisfaction (through emotional support and social bonds) and boosting organizational commitment. What also stood out: Expressive network ties appear to be nearly three times as "sticky" (95% CI: [-0.60, -0.37]) as instrumental ties (95% CI: [-0.23, -0.07]) based on simple bivariate correlations. Moreover, after accounting for turnover antecedents such as job performance and satisfaction, only expressive ties maintained a negative association with turnover (*b* = -0.47, *SE* = 0.02). While there are some limitations to consider - such as the relatively small number of studies, variability in how networks were measured, sampling bias, or missing contextual moderators - these estimates may still be useful for setting priors when considering the shape of retention strategies before conducting a similar analysis on your own data. Meanwhile, with only these results in hand, we might assume that, while instrumental ties help employees succeed in their jobs, they don’t generate the same level of 'stickiness' as expressive ties. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-11-21-instrumental-and-expressive-networks/) on my blog. --- # ChatGPT as a new email writing coach? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/chatgpt-emails-and-causalpy Date: 2024-11-13 Tags: communication, generative-ai, fun-with-data, causal-inference, python Original post: https://blog-about-people-analytics.netlify.app/posts/2024-11-13-chatgpt-emails-and-causalpy/ I wanted to try out the new [CausalPy](https://causalpy.readthedocs.io/en/latest/?s=09) package for causal inference and was brainstorming interesting research questions to apply it to. My inspiration came from a recent wave of LI posts in my feed, all sharing a chart showing a drop in Stack Overflow traffic after ChatGPT’s release in November 2022.
![](./chatgpt-emails-and-causalpy/stackoverflow.png)
Given that many studies - including one we conducted internally at Sanofi - show that one of the most frequent use cases for GenAI in the business world is helping with email writing (we've probably all seen the hilarious meme on this topic shown below 😁), I got curious about whether a similar pattern might show up in Google Trends for searches like “*How to write an email.*”
![](./chatgpt-emails-and-causalpy/meme.png)
For the analysis, I used an [interrupted time series method](https://academic.oup.com/ije/article/46/1/348/2622842), which examines the effect of an intervention by comparing time series data before and after the intervention that happened at a known point in time, allowing us to assess any shifts in level or trend. As the resulting charts below show, there does seem to be a noticeable drop in searches on how to write emails following ChatGPT’s official release. In fact, the shift is so pronounced that it’s clear even with a quick eyeballing analysis. Sure, we can speculate about other factors to be involved - maybe we're sending fewer emails because we’re relying more on platforms like Slack or Teams - but as other available stats suggest (see, for example, [this one](https://financesonline.com/email-statistics/)), we can only wish that were the case 😉
![](./chatgpt-emails-and-causalpy/output_plot.png)
Either way, CausalPy turned out to be a super easy-to-use and user-friendly tool. I'm looking forward to using it in my future projects. Kudos to the entire CausalPy team! 👏 P.S. To replicate the analysis, you can use the Python code below. ```r # uploading the reticulate package for running Python in .Rmd file library(reticulate) ``` ```python # uploading libraries import pandas as pd import numpy as np from pytrends.request import TrendReq import matplotlib.pyplot as plt import arviz as az import causalpy as cp # getting global Google Trends data for a key phrase of interest # initializing the TrendReq object pytrends = TrendReq(hl='en-US', tz=360, requests_args={'verify': False}) # specifying keyword and time range keywords = ['How to write email'] timeframe = '2020-01-01 2024-10-31' # creating payload and fetching data pytrends.build_payload(keywords, cat=0, timeframe=timeframe, geo='', gprop='') data = pytrends.interest_over_time() # data overview # data.head() # data.info() # creating month variable for capturing seasonality in the data data['month'] = data.index.month # creating time variable data['t'] = np.arange(len(data)) # renaming target variable data.rename(columns={'How to write email': 'y'}, inplace=True) # saving the data for later usage # data.to_csv('google_trends_data.csv', index=True) # specifying the date of intervention treatment_time = pd.to_datetime("2022-10-30") # specifying and fitting the model seed = 2024 result = cp.InterruptedTimeSeries( data, treatment_time, formula="y ~ 1 + t + C(month)", model=cp.pymc_models.LinearRegression( sample_kwargs={ "random_seed": seed, "draws": 5000, "tune": 1000, "chains": 4 } ), ) # summary of the fitted model result.summary() # plotting the results fig, ax = result.plot() plt.show() # summary statistics of the causal impact over the entire post-intervention period az.summary(result.post_impact.mean("obs_ind")) # summary statistics of the cumulative causal impact # getting index of the final time point index = result.post_impact_cumulative.obs_ind.max() # grabbing the posterior distribution of the cumulative impact at this final time point last_cumulative_estimate = result.post_impact_cumulative.sel({"obs_ind": index}) # getting summary stats az.summary(last_cumulative_estimate) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-11-13-chatgpt-emails-and-causalpy/) on my blog. --- # How does your company stack up in the Big Nine Cultural Values? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/culture-500 Date: 2024-11-11 Tags: organizational-culture, employee-survey, glassdoor, nlp Original post: https://blog-about-people-analytics.netlify.app/posts/2024-11-11-culture-500/ I recently came across the [Culture 500](https://sloanreview.mit.edu/culture500) - an interesting tool created by MIT Sloan Management Review and CultureX, which offers a data-driven evaluation of corporate culture across the Big Nine Cultural Values as assessed through analysis of over 1 million employee reviews on Glassdoor. The model used includes the following nine culture values: * **Agility:** *Employees can respond quickly and effectively to changes in the marketplace and seize new opportunities.* * **Collaboration:** *Employees work well together within their team and across different parts of the organization.* * **Customer:** *Employees put customers at the center of everything they do, listening to them and prioritizing their needs.* * **Diversity:** *The company promotes a diverse and inclusive workplace where no one is disadvantaged because of their gender, race, ethnicity, sexual orientation, religion, or nationality.* * **Execution:** *Employees are empowered to act, have the resources they need, adhere to process discipline, and are held accountable for results.* * **Innovation:** *The company pioneers novel products, services, technologies, or ways of working.* * **Integrity:** *Employees consistently act in an honest and ethical manner.* * **Performance:** *The company rewards results through compensation, informal recognition, and promotions, and deals effectively with underperforming employees.* * **Respect:** *Employees demonstrate consideration and courtesy for others, and treat each other with dignity.* The tool’s primary chart visualizes each company's results by showing, in standard deviations, the frequency of mentions and the sentiment toward each cultural value discussed in employee reviews. The resulting 2x2 matrix provides a clear dataviz that aids in identifying a company's core strengths, critical weaknesses, emerging positives, and under-the-radar issues (see the example below). With a standardized scale, one can quickly see how their company’s culture compares to that of other companies across individual values. The tool also allows side-by-side comparisons, helping leaders benchmark against both industry standards and standout peers (see the example below).
![](./culture-500/chart2.png)
![](./culture-500/chart3.png)
This tool can be a nice complement to an internal employee listening program, giving an outside perspective. That said, if your listening program is on point, the results shouldn’t come as a big surprise 😉 Also could be a helpful resource for anyone weighing job offers and looking to understand a company’s culture before joining. ⚠️ When using the tool, one should keep in mind its limits, like potential review bias, limited cross-industry comparability, constraints in capturing nuanced sentiments and emerging cultural dimensions, the freshness of data reflecting real-time changes, dependency on Glassdoor’s review policies, focus restricted to nine core values, and exclusion of global employee perspectives. And as its name suggests, it includes results for only around 500 leading companies. To learn more about the methodology behind the tool, check out [this site](https://sloanreview.mit.edu/projects/measuring-culture-in-leading-companies/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-11-11-culture-500/) on my blog. --- # Do you use XmR charts for People Analytics use cases? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/xmr-charts-in-people-analytics Date: 2024-10-30 Tags: time-series, statistics, data-visualization Original post: https://blog-about-people-analytics.netlify.app/posts/2024-10-30-xmr-charts-in-people-analytics/ I'm curious if anyone in People Analytics space has experience - good, bad, or a bit of both - using XmR charts for tracking and interpreting HR-related metrics and guiding follow-up process improvements, especially among non-technical end users. I stumbled upon this interesting tool, originally rooted in statistical process control in industrial engineering and manufacturing, through posts by [Frank Corrigan](https://www.linkedin.com/in/frankxc/), and recently dug a bit deeper into it through a series of email lectures from [Commoncog](https://commoncog.com/). From the get-go, it struck me as a potentially effective way for HR or business people - who may not have deep data expertise - to become more data-driven, as it simplifies the task of separating signal from noise and makes it straightforward for even laypeople to determine whether changes in metrics are due to real shifts or just random fluctuations. In effect, it helps users address the "So what?" question by validating when data points signify something meaningful, like a trend, shift, or outlier that warrants further action or adjustment. As the attached charts illustrate (based on a sample of dummy data tracking internal mobility), it is pretty intuitive to spot if a metric should (or should not) require further investigation into the process behind it, or whether a change introduced at the start of 2024 is actually impacting the numbers. All you really need to do is keep an eye on the limit lines and follow some basic rules, such as: 1. Investigate when a point is outside the limit lines. 2. Take a closer look if three out of four successive points are near the limits rather than the center. 3. If eight consecutive points appear on one side of the average, that may signal a meaningful shift.
![](./xmr-charts-in-people-analytics/charts.png)
Would love to hear any experiences you might’ve had with XmR charts in the PA space! 🙏 P.S. The attached charts were created with [Xmrit, a free online tool](https://click.convertkit-mail2.com/zlup0g28rga7ulmg9quphw09qg00c6/owhkhqhr55le7eaq/aHR0cHM6Ly94bXJpdC5jb20vdC8=) from Commoncog, whose website also has more useful information about this analytical tool. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-10-30-xmr-charts-in-people-analytics/) on my blog. --- # Do people’s personalities vary across different jobs? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-personality-fit Date: 2024-10-29 Tags: personality, career-development Original post: https://blog-about-people-analytics.netlify.app/posts/2024-10-29-job-personality-fit/ Over the years, it seems I’ve gravitated toward a job family that aligns well with my personality - or at least, that’s how it appears when you find job choices of other similarly-minded people as a validation of your own choices 😉 Attached, you can see one of my results from an online tool that measures the Big Five personality traits and compares them with the personality profiles of individuals across various occupations.
![](./job-personality-fit/results_screenshot.jpg)
It’s based on research by [Anni, Vainik, & Mõttus (2024)](https://psycnet.apa.org/fulltext/2025-38154-001.html), which examined personality trait patterns across 263 occupations and yielded following key insights: * Certain personality traits align predictably with specific jobs, supporting the person–job fit model. For instance, openness is more common in creative fields, while conscientiousness tends to be higher in managerial roles. * Occupations account for 2%–7% of the variance in Big Five personality traits, suggesting modest but meaningful differences across roles. * Occupations with high average levels of traits linked to job performance (e.g., conscientiousness) show greater homogeneity in these traits, indicating selective environments. * The results align with previous studies across different regions, suggesting that these occupational-personality trends are broadly applicable across cultures. * Detailed personality traits beyond the Big Five (e.g., specific aspects of extraversion or conscientiousness) explain even more variance, illustrating nuanced differences among job incumbents. These findings suggest that while personality explains only part of the variation among occupations, it may still offer valuable insights for career counseling and personal development. If you’d like to try the tool yourself, here’s the [link](https://apps.psych.ut.ee/JobProfiler/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-10-29-job-personality-fit/) on my blog. --- # Nonlinear relationships between personality traits and business outcomes seem to be the norm rather than the exception URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personality-and-non-linearities Date: 2024-10-26 Tags: personality, predictive-analytics, regression-analysis, machine-learning, interpretability Original post: https://blog-about-people-analytics.netlify.app/posts/2024-10-26-personality-and-non-linearities/ I recently came across a post discussing research that highlights how the prediction power of personality traits in relation to business outcomes may be underestimated because most studies rely on linear analytical methods, which, unlike many ML algorithms like Random Forest or XGBoost, fail to capture and utilize nonlinear signals in the data. This led me to reflect that maybe it’s not just the prediction power that suffers from this limitation. The true nature and shape of these relationships are likely misunderstood as well as a result of relying on linear methods. We’re used to reading about positive or negative correlations between personality traits and various outcomes, but the reality is probably more complex and nuanced. For a quick visual, I’ve attached charts from one of my recent projects, illustrating how the top three personality trait predictors, as measured by the HPI, relate to employee performance across two competencies, as modeled by XGBoost and visualized using Partial Dependence Plots - a global ML interpretation tool. Notice how these relationships aren’t simply positive or negative but show clear non-linearities.
![](./personality-and-non-linearities/plots.png)
With interpretable ML, exploring these nuances becomes pretty straightforward. If you haven’t dived into these waters yet, give it a try - you might be surprised (or not) by what you find 😉 P.S. Below is a snippet of R code if you'd like to start experimenting with your own data. ```r # libraries library(tidyverse) library(readxl) library(recipes) library(tidymodels) library(xgboost) library(vip) library(pdp) # uploading data data <- read_xlsx("your_data.xlsx") # setting the target and predictors target <- "Agility_and_Initiative" predictors <- c("Adjustment", "Ambition", "Sociability", "Interpersonal_Sensitivity", "Prudence", "Inquisitive", "Learning_Approach") # specifying the formula fmla <- as.formula(paste(target, " ~ .")) # dropping rows without the target predDf <- mydata %>% drop_na(target) %>% select(all_of(c(target, predictors))) # defining recipe for data preparation recipe_spec <- recipe(fmla, data = predDf) %>% step_impute_knn(all_predictors(), neighbors = 3) %>% step_dummy(all_nominal_predictors()) # specifying the model and running the whole pipeline set.seed(1234) workflow_fit_xgb <- workflow() %>% add_model(boost_tree(mode = "regression") %>% set_engine("xgboost")) %>% add_recipe(recipe_spec) %>% fit(predDf) # identifying and visualizing the most important predictors workflow_fit_xgb$fit$fit$fit %>% vi() %>% ggplot(aes(x = fct_reorder(Variable, Importance), y = Importance)) + geom_bar(stat = "identity", fill = "#003d73", color = "#003d73") + coord_flip() + labs( y = "IMPORTANCE", x = "PREDICTORS", title = str_glue("Strength of predictors of {target}"), caption = "\nXGBoost algorithm is used for identification of the most important predictors." ) + theme(plot.title = element_text(color = '#2C2F46', face = "bold", family = "URW Geometric", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", family = "URW Geometric", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 14, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 14, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16), panel.border = element_rect(color = "#E0E1E6", fill = NA), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position = "top", legend.direction = "horizontal", legend.justification = c(0, 1), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # exploring the shape of relationship between the target and its predictors using ICE & PDP # preparing pre-processed data for partial function preprocessedData <- recipe_spec %>% prep() %>% bake(new_data = predDf) # function for computation of ICE and PDP pred <- function(object, newdata){ # Predict and return the vector of predictions, not the mean results <- as.numeric(as.data.frame(predict(object, newdata))[[1]]) return(results) # Return a vector, not a single number } # function for computation of PDP only # pred <- function(object, newdata){ # results <- mean(as.numeric(as.data.frame(predict(object, newdata))[[1]])) # return(results) # } # predictor of interest predictor <- "Prudence" # computing ICE g <- partial( object = workflow_fit_xgb, pred.var = predictor, pred.fun = pred, grid.resolution = 50, ice=TRUE, type = "regression", train = preprocessedData %>% select(-target), plot = FALSE ) # computing PDP part (average yhat for individual predictor values) g2 <- g %>% group_by(!!sym(predictor)) %>% summarise(avg_yhat = mean(yhat)) %>% ungroup() # ICE & PDP dataviz ggplot()+ geom_line(mapping=aes_string(x = predictor, y = "yhat", group="yhat.id"), data=g, size = 1.35, color = "grey", alpha=0.2) + geom_line(mapping=aes_string(x = predictor, y = "avg_yhat", group=1), data=g2, size = 1.5, color = "#340078", alpha=1) + labs( x = str_to_upper(str_replace_all(predictor, '_', ' ')), y = str_glue("{str_to_upper(str_replace_all(target, '_', ' '))} (yhat)"), title = str_glue("PDP plot for the {str_to_upper(str_replace_all(predictor, '_', ' '))} predictor") ) + theme(plot.title = element_text(color = '#2C2F46', face = "bold", family = "URW Geometric", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", family = "URW Geometric", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 14, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 14, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16), panel.border = element_rect(color = "#E0E1E6", fill = NA), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position = "top", legend.direction = "horizontal", legend.justification = c(0, 1), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-10-26-personality-and-non-linearities/) on my blog. --- # Simulating the "survivorship" effect in employee satisfaction data over time URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/tenure-vs-satisfaction Date: 2024-10-02 Tags: employee-turnover, employee-satisfaction, simulation, r Original post: https://blog-about-people-analytics.netlify.app/posts/2024-10-02-tenure-vs-satisfaction/ In a [recent post on LinkedIn](https://www.linkedin.com/posts/ludekstehlik_employeesatisfaction-tenure-age-activity-7237423725715820545-Gr7x?utm_source=share&utm_medium=member_desktop), I shared a research paper by [Dobrow & Ganzach (2014)](https://www.researchgate.net/publication/276893636_Job_Satisfaction_over_Time_A_Longitudinal_Study_of_the_Differential_Roles_of_Age_and_Tenure) that used longitudinal data to disentangle the effects of age and job tenure on employee satisfaction and concluded that people are on average more satisfied with their jobs over their lifetimes, but their satisfaction declines over time within a given company (see the illustrative chart below).
![](./tenure-vs-satisfaction/plot.png)
A reader of this post reached out to me and asked how this might relate to the U-shaped pattern they observe in their data: new hires tend to be the most satisfied, then there is a slight decline at "mid-company" age, and then satisfaction rises again. My general reply was that this pattern might be a kind of illusion caused by the fact that the more satisfied employees "survive" and are more likely to stay longer, and the more dissatisfied ones leave after a few years, so they couldn't lower the average satisfaction level of those who stayed longer and are, by definition, more satisfied. In support of my point, I also created a simple simulation in R that takes a cohort of 2000 employees who joined the company at the same point in time, each of whom exhibits a more or less steep and noisy downward (or flat) slope of declining satisfaction levels over their years at the company, and a probability of leaving that is non-linearly (sigmoidally) related to their respective satisfaction levels. As can be seen in the attached chart, the resulting pattern closely resembles what might be observed in real data. ```r # loading necessary libraries library(gganimate) library(tidyverse) # setting basic params of the simulation set.seed(2024) n_employees <- 2000 years <- 20 desired_sd <- 5 # creating basic df employee_data <- data.frame( employee_id = 1:n_employees, tenure = 0, satisfaction = runif(n_employees, min = 80, max = 100), status = "Active", degree = abs(rnorm(n_employees, mean = 0, sd = desired_sd)) ) # function to compute leaving probability compute_prob_leave <- function(satisfaction, k, midpoint) { # lower satisfaction should correspond to higher probability of being cut # non-linear/logistic probability p_leave <- 1 / (1 + exp(-k * (satisfaction - midpoint))) return(p_leave) } midpoint <- 60 # satisfaction level where probability of cut is 50% k <- -0.15 # controlling the steepness of the logistic curve # just checking the shape of the function determining probability of leaving depending on satisfaction level visualize_prob_leave <- function(k, midpoint, satisfaction_range = c(0, 100)) { # generating a sequence of satisfaction values satisfaction <- seq(from = satisfaction_range[1], to = satisfaction_range[2], length.out = 1000) # compute probabilities prob_leave <- compute_prob_leave(satisfaction, k, midpoint) # creating a data frame for plotting df <- data.frame(satisfaction = satisfaction, probability = prob_leave) # creating the plot p <- ggplot(df, aes(x = satisfaction, y = probability)) + geom_line(color = "blue", size = 1) + geom_vline(xintercept = midpoint, linetype = "dashed", color = "red") + geom_hline(yintercept = 0.5, linetype = "dashed", color = "red") + labs(title = "Probability of Leaving vs. Satisfaction", x = "Satisfaction", y = "Probability of Leaving") + theme_minimal() + coord_cartesian(ylim = c(0, 1)) # displaying the plot print(p) } visualize_prob_leave(k, midpoint) # function to update satisfaction level of employees over time update_satisfaction <- function(data, year) { data <- data %>% mutate( # increasing tenure tenure = tenure + 1, # decreasing satisfaction according to degree parameter and add some noise satisfaction = satisfaction - degree + rnorm(n(), mean = 0, sd = 5), # ensuring satisfaction is between 0 and 100 satisfaction = pmin(pmax(satisfaction, 0), 100), # computing probability to leave prob_leave = compute_prob_leave(satisfaction, k, midpoint), # determining employee status status = ifelse(runif(n()) <= prob_leave, "Leave", "Active") ) %>% # keeping only active employees filter(status == "Active") if (nrow(data) > 0) { data$year <- year # assigning the year if there are remaining employees } else { # if there are no employees left after filtering, creating an empty data frame for consistency data <- data.frame(employee_id = integer(0), tenure = integer(0), satisfaction = numeric(0), status = character(0), year = integer(0), degree = numeric(0)) } return(data) } # running simulation over 20 years set.seed(2023) all_years_data <- list() all_years_data[[1]] <- employee_data for (i in 2:(years + 1)) { all_years_data[[i]] <- update_satisfaction(all_years_data[[i - 1]], i - 1) } # combine all data simulated_data <- bind_rows(all_years_data) %>% drop_na() # plotting with gganimate plot <- ggplot(simulated_data, aes(x = as.factor(tenure), y = satisfaction)) + geom_line(aes(group = employee_id), alpha = 0.25) + geom_boxplot(alpha = 0.5, fill = "grey", color = "black", width = 0.45, outlier.shape = NA) + stat_summary( fun = mean, geom = "point", color = "#00FFFF", size = 2.5, shape = 21, fill = "#00FFFF" ) + labs( title = 'Satisfaction and departures of 2000 simulated employees over time', x = 'TENURE (YEARS)', y = 'EMPLOYEE SATISFACTION' ) + scale_x_discrete() + scale_y_continuous(limits = c(0, 100)) + theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 16, margin = margin(0, 0, 12, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin = margin(0, 0, 0, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.line.x = element_line(color = "lightgrey"), axis.line.y = element_line(color = "lightgrey"), axis.title.x = element_text(size = 13, color = '#2C2F46', hjust = 0, margin = margin(5, 0, 0, 0)), axis.title.y = element_text(size = 13, color = '#2C2F46', margin = margin(0, 5, 0, 0), hjust = 1), axis.text.x = element_text(size = 12, color = '#2C2F46'), axis.text.y = element_text(size = 12, color = '#2C2F46'), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot", legend.position = "", plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA) ) + transition_reveal(year) # animating the plot and saving it anim <- animate(plot, nframes = 125, fps = 10, height = 6, width = 11, units = "in", res = 200) gganimate::anim_save(filename = "employee_tenure_satisfaction.gif", animation = anim) ```
![](./tenure-vs-satisfaction/employee_satisfaction_10_200_0.25.gif)
Although this mechanism seems to provide a plausible answer to the reader's question, perhaps I have missed something important or there are alternative or complementary explanations. Feel free to share your ideas and suggestions. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-10-02-tenure-vs-satisfaction/) on my blog. --- # Motivation vs. Performance: What causes what? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/performance-vs-motivation Date: 2024-09-20 Tags: performance, motivation, meta-analysis, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2024-09-20-performance-vs-motivation/ The authors of the paper examined four competing hypotheses: 1. Motivation causes performance. 2. Performance causes motivation. 3. A reciprocal relationship exists. 4. They are causally unrelated. Below are visual illustrations of arguments for these four hypotheses.
![](./performance-vs-motivation/performance_motivation.png)
Their findings suggest that **work motivation is more likely to cause job performance than vice versa**. The results appear reasonably robust, as the finding that work motivation predicts job performance was consistent across various moderators, including the type of job performance measure, the type of motivation measure, and the length of the time lag.
![](./performance-vs-motivation/performance_motivation2.png)
The practical implications for people management are straightforward according to the authors: leveraging human performance practices (e.g., compensation management and performance management) and motivation-based leadership (e.g., empowering leadership) can positively influence employee motivation, which in turn enhances employee performance over the long term. Additionally, there was one surprising finding: job performance does not predict work motivation. According to the authors, this suggests that HR practitioners should identify effective practices to strengthen feedback mechanisms between them, helping employees to continuously improve their performance. For a deeper dive into this research, check out the full article [here](https://www.nature.com/articles/s41599-024-03038-w). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-09-20-performance-vs-motivation/) on my blog. --- # Why are introverts often described in terms of negative emotionality, and should they be? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/extraversion-vs-neuroticism Date: 2024-09-18 Tags: personality, big-five Original post: https://blog-about-people-analytics.netlify.app/posts/2024-09-18-extraversion-vs-neuroticism/ In public discourse, one often encounters defenses of the strengths and advantages of introverts in a world that generally prefers and celebrates more extroverted behaviors. While this advocacy can have many positive effects, it seems to me that those defending introverts often (mistakenly?) describe them in terms of negative emotionality, such as shyness, anxiety, fear, insecurity, etc., i.e. in terms of qualities that fall under the umbrella of the trait of Neuroticism rather than Extraversion. I was curious about why this is the case. One obvious explanation is the strong correlation between these two traits. When examining data from [Johnson's IPIP-NEO data repository](https://osf.io/tbmh5/), the data indeed appear to support this hypothesis. Compared to all other traits, Neuroticism much more strongly (negatively) correlates with Extraversion (see the pairplot below). ```r # libraries for data manipulation and dataviz library(foreign) library(tidyverse) library(GGally) # uplaoding .por file # data from Johnson, J. A. (2014). Measuring thirty facets of the five factor model with a 120-item public domain inventory: Development of the IPIP-NEO-120. J. of Research in Personality, 51, 78-89. # source: https://osf.io/tbmh5/ data <- read.spss("IPIP300.por", to.data.frame = TRUE) # assigning items to individual traits o_items <- c(3, 8, 13, 18, 53, 28, 33, 68, 43, 138, 203, 58, 63, 188, 223, 168, 233, 148, 93, 218, 283, 288, 263, 268, 23, 38, 48, 73, 78, 83, 88, 98, 103, 108, 113, 118, 123, 128, 133, 143, 153, 158, 163, 173, 178, 183, 193, 198, 208, 213, 228, 238, 243, 248, 253, 258, 273, 278, 293, 298) c_items <- c(5, 40, 45, 50, 55, 120, 35, 160, 105, 140, 145, 150, 65, 190, 165, 260, 205, 210, 155, 220, 195, 290, 265, 270, 10, 15, 20, 25, 30, 60, 70, 75, 80, 85, 90, 95, 100, 110, 115, 125, 130, 135, 170, 175, 180, 185, 200, 215, 225, 230, 235, 240, 245, 250, 255, 275, 280, 285, 295, 300) e_items <- c(2, 7, 12, 17, 22, 27, 62, 37, 42, 47, 52, 57, 212, 157, 132, 77, 142, 147, 272, 247, 162, 167, 172, 177, 32, 67, 72, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 137, 152, 182, 187, 192, 197, 202, 207, 217, 222, 227, 232, 237, 242, 252, 257, 262, 267, 277, 282, 287, 292, 297) a_items <- c(4, 99, 74, 169, 144, 29, 34, 159, 104, 199, 174, 59, 64, 249, 194, 229, 204, 149, 184, 279, 284, 259, 264, 239, 9, 14, 19, 24, 39, 44, 49, 54, 69, 79, 84, 89, 94, 109, 114, 119, 124, 129, 134, 139, 154, 164, 179, 189, 209, 214, 219, 224, 234, 244, 254, 269, 274, 289, 294, 299) n_items <- c(1, 6, 11, 76, 111, 26, 31, 36, 41, 106, 171, 56, 61, 126, 71, 136, 201, 86, 91, 216, 251, 256, 231, 176, 16, 21, 46, 51, 66, 81, 96, 101, 116, 121, 131, 141, 146, 151, 156, 161, 166, 181, 186, 191, 196, 206, 211, 221, 226, 236, 241, 246, 261, 266, 271, 276, 281, 286, 291, 296) # adding 'I' prefix o_items <- paste0("I", o_items) c_items <- paste0("I", c_items) e_items <- paste0("I", e_items) a_items <- paste0("I", a_items) n_items <- paste0("I", n_items) # selecting a random sample of 5000 respondents and computing scores for all five major scales set.seed(2024) mydata_sample <- data %>% dplyr::select(all_of(c(o_items, c_items, e_items , a_items, n_items))) %>% dplyr::rename_with(~ str_replace_all(., "I", "O"), .cols = all_of(o_items)) %>% dplyr::rename_with(~ str_replace_all(., "I", "C"), .cols = all_of(c_items)) %>% dplyr::rename_with(~ str_replace_all(., "I", "E"), .cols = all_of(e_items)) %>% dplyr::rename_with(~ str_replace_all(., "I", "A"), .cols = all_of(a_items)) %>% dplyr::rename_with(~ str_replace_all(., "I", "N"), .cols = all_of(n_items)) %>% dplyr::sample_n(5000,replace = FALSE) %>% dplyr::mutate( Openness = rowSums(select(., starts_with("O"))), Conscientiousness = rowSums(select(., starts_with("C"))), Extraversion = rowSums(select(., starts_with("E"))), Agreeableness = rowSums(select(., starts_with("A"))), Neuroticism = rowSums(select(., starts_with("N"))) ) %>% dplyr::select(Openness:Neuroticism) # pairplot GGally::ggpairs( mydata_sample, lower = list( continuous = wrap("smooth", method = "loess", se = FALSE, alpha = 0.1, color='#5c00ae') ), diag = list( continuous = wrap("densityDiag", fill = "grey", color=NA) ) ) + ggplot2::labs( title = 'Relationships between Big Five traits as measured by the IPIP-NEO-300', caption = "\nData Source: Johnson's IPIP-NEO data repository (only a random sample of 5,000 out of 307,313 observations was used).\nNote: Smoothing lines were fitted using the LOESS method." ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 22, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), panel.background = element_rect(fill = "white", color = NA), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), plot.background = element_rect(fill = "white", color = NA), strip.text = element_text(size = 12, face = "plain"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ```
![](./extraversion-vs-neuroticism/pairplot2.png)
At the level of sub-facets of these two traits, we can observe in the correlation plot below - where individual items are ordered based on the first principal component, which groups together highly correlated variables - that the sub-facets of Extraversion are most strongly predicted by scores on the Neuroticism sub-facets of *Self-Consciousness* (sensitivity to others' judgments and fear of embarrassment) and *Depression* (inclination toward feelings of sadness, hopelessness, and low self-esteem). To some extent, *Anxiety* (tendency to worry, feel nervous, or experience fear) and *Vulnerability* (susceptibility to stress and inability to cope under pressure) also contribute. ```r # library for correlation plot library(corrplot) # assigning items to individual sub-facets of Extraversion and Neuroticism E_Friendliness_items <- c(2, 62, 212, 272, 32, 92, 122, 152, 182, 242) E_Gregariousness_items <- c(7, 37, 157, 247, 67, 97, 127, 187, 217, 277) E_Assertiveness_items <- c(12, 42, 132, 162, 72, 102, 192, 222, 252, 282) E_Activity_Level_items <- c(17, 47, 77, 167, 107, 137, 197, 227, 257, 287) E_Excitement_Seeking_items <- c(22, 52, 142, 172, 82, 112, 202, 232, 262, 292) E_Cheerfulness_items <- c(27, 57, 147, 177, 87, 117, 207, 237, 267, 297) N_Anxiety_items <- c(1, 31, 61, 91, 121, 151, 181, 211, 241, 271) N_Anger_items <- c(6, 36, 126, 216, 66, 96, 156, 186, 246, 276) N_Depreesion_items <- c(11, 41, 71, 251, 101, 131, 161, 191, 221, 281) N_Self_Consciousness_items <- c(76, 106, 136, 256, 16, 46, 166, 196, 226, 286) N_Immoderation_items <- c(111, 171, 201, 231, 21, 51, 81, 141, 261, 291) N_Vulnerability_items <- c(26, 56, 86, 176, 116, 146, 206, 236, 266, 296) # adding 'I' prefix E_Friendliness_items <- paste0("I", E_Friendliness_items) E_Gregariousness_items <- paste0("I", E_Gregariousness_items) E_Assertiveness_items <- paste0("I", E_Assertiveness_items) E_Activity_Level_items <- paste0("I", E_Activity_Level_items) E_Excitement_Seeking_items <- paste0("I", E_Excitement_Seeking_items) E_Cheerfulness_items <- paste0("I", E_Cheerfulness_items) N_Anxiety_items <- paste0("I", N_Anxiety_items) N_Anger_items <- paste0("I", N_Anger_items) N_Depreesion_items <- paste0("I", N_Depreesion_items) N_Self_Consciousness_items <- paste0("I", N_Self_Consciousness_items) N_Immoderation_items <- paste0("I", N_Immoderation_items) N_Vulnerability_items <- paste0("I", N_Vulnerability_items) # selecting a random sample of 5000 respondents and computing scores for all sub-facets considered set.seed(2024) mydata_sample <- data %>% dplyr::select(all_of(c( E_Friendliness_items, E_Gregariousness_items, E_Assertiveness_items, E_Activity_Level_items, E_Excitement_Seeking_items, E_Cheerfulness_items, N_Anxiety_items, N_Anger_items, N_Depreesion_items, N_Self_Consciousness_items, N_Immoderation_items, N_Vulnerability_items ))) %>% dplyr::sample_n(5000,replace = FALSE) %>% dplyr::mutate( Friendliness = rowSums(select(., all_of(E_Friendliness_items))), Gregariousness = rowSums(select(., all_of(E_Gregariousness_items))), Assertiveness = rowSums(select(., all_of(E_Assertiveness_items))), Activity_Level = rowSums(select(., all_of(E_Activity_Level_items))), Excitement_Seeking = rowSums(select(., all_of(E_Excitement_Seeking_items))), Cheerfulness = rowSums(select(., all_of(E_Cheerfulness_items))), Anxiety = rowSums(select(., all_of(N_Anxiety_items))), Anger = rowSums(select(., all_of(N_Anger_items))), Depression = rowSums(select(., all_of(N_Depreesion_items))), Self_Consciousness = rowSums(select(., all_of(N_Self_Consciousness_items))), Immoderation = rowSums(select(., all_of(N_Immoderation_items))), Vulnerability = rowSums(select(., all_of(N_Vulnerability_items))) ) %>% dplyr::select( Friendliness, Gregariousness, Assertiveness, Activity_Level, Excitement_Seeking, Cheerfulness, Anxiety, Anger, Depression, Self_Consciousness, Immoderation, Vulnerability ) # correlation matrix cor_matrix <- cor(mydata_sample, use = "complete.obs") # correlation plot corrplot::corrplot( cor_matrix, method = "circle", type = "full", order = "FPC", # ordering vars based on the first principal component which groups together highly correlated vars tl.col = "black", tl.cex = 1.2, cl.cex=1.2, mar=c(0,0,5,0), tl.offset = 1 ) title( main = "Correlation matrix with Extraversion and Neuroticism sub-facets", cex.main = 2, font.main = 2 ) ```
![](./extraversion-vs-neuroticism/corrplot2.png)
These patterns could indeed provide an answer to the question posed in the title of my post. However, the question now is whether, when discussing introverts, we should limit ourselves to behaviors that fall exclusively under the umbrella of Extraversion, as described in modern personality psychology, or whether we should also include other closely related behaviors. My personal stance is that we should include them, while explicitly noting that the picture we are describing is not a monolith but rather a blend of multiple traits. This approach helps prevent oversimplification, avoid stereotypes, and appreciate the complexity of introverts' strengths and challenges, ultimately leading to greater self-awareness and more tailored strategies in personal and professional contexts. What’s your take on this? 🤔 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-09-18-extraversion-vs-neuroticism/) on my blog. --- # Are people during exit surveys more honest in their responses than in engagement surveys? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/honesty-in-engagement-vs-exit-surveys Date: 2024-09-12 Tags: employee-experience, employee-survey, psychometrics Original post: https://blog-about-people-analytics.netlify.app/posts/2024-09-12-honesty-in-engagement-vs-exit-surveys/ One way to test whether this ‘political distortion’ exists, or to what extent, is to examine how consistent the responses of voluntarily departing employees are across the exit and engagement surveys. If such a distortion exists, one would expect to see in the data that the primary reason people gave for leaving is NOT reflected in lower scores on the corresponding items in the most recent engagement survey they completed before leaving the company, compared to leavers who gave a different primary reason. Another compatible observation would be that the primary reason for leaving DOES translate into lower scores, but this effect diminishes with the longer lag between the administration of the two surveys, because people further away from the actual date of departure may not have decided whether to leave and may therefore feel less free to express their true positions on politically sensitive topics. In analyzing a sample dataset and focusing on 'direct manager' as a primary reason for leaving, the results from an ordinal regression analysis appear to support the second scenario described above. The results are illustrated by the pair of heatmaps below, which show the negative effect of that primary reason (i.e., direct manager vs. all other reasons) on ratings on the engagement survey, and the interaction between that primary reason and the distance between the two surveys in their effect on ratings of one particular engagement survey item related to the quality of the direct manager.
![](./honesty-in-engagement-vs-exit-surveys/pic_combo_manager.png)
However, this pattern could also be alternatively explained by the simple, non-problematic fact that people's opinions change over time. The closer the exit survey is to the last engagement survey, the more it reflects what people answered in the last engagement survey. This suggests that the observed interaction/ "diminishing" effect may be actually common across all stated reasons and engagement survey items. To differentiate between these two explanations, one could examine another primary reason for leaving that is assumed to be less politically sensitive, where employees may feel more comfortable expressing their true opinions before deciding to leave. An example of such a reason might be dissatisfaction with flexibility in work location and/or schedule. In this case, the data shows a negative (though statistically non-significant) effect of the stated primary reason (i.e., flexibility vs. all other reasons) on engagement survey ratings, and almost zero interaction between the stated primary reason and the time between the two surveys. This finding seems to align more closely with the expectation of how honest responses should be reflected in the data.
![](./honesty-in-engagement-vs-exit-surveys/pic_combo_flexibility.png)
However, we can still speculate about alternative explanations. It could be that the dynamics of different reasons for leaving evolve differently over time. For example, dissatisfaction with flexibility might stay similarly high over the long term without any employee’s action till the moment when better opportunity occurs and they leave. In contrast, dissatisfaction with a direct manager may have a tendency to show a more sudden, abrupt increase before an employee makes a final decision to depart. To further investigate this, we can examine a reason for leaving that is less politically sensitive than dissatisfaction with a direct manager but has similar dynamics over time. One such reason could be dissatisfaction with career growth opportunities. The data for this specific reason shows a pattern very similar to what we observed already for the 'flexibility' reason.
![](./honesty-in-engagement-vs-exit-surveys/pic_combo_growth.png)
This similarity, therefore, supports the conclusion that, in cases of dissatisfaction with a direct manager, there is some evidence in the data of a "political distortion" in people's responses to the engagement survey on this topic. However, this conclusion should be taken with a big grain of salt, as it relies on several significant, and perhaps overly speculative, assumptions made throughout the analysis; and it is also crucial to consider the limitations of the data used, such as the shorter time span of the data and the self-selected nature of the group of people motivated enough to complete both the engagement and exit surveys. All this weakens the validity of the conclusion made above. Despite all the uncertainty, it was a very useful exercise to confront my thoughts and ideas with some evidence and refine them a bit. To build on this work and move closer to answering the question in the title of this post, I would welcome any suggestions for next steps, detours or completely alternative approaches that you think might help. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-09-12-honesty-in-engagement-vs-exit-surveys/) on my blog. --- # Unpacking surprises in the women’s 100m World Championships URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/sprint Date: 2024-09-04 Tags: fun-with-data, project-management, performance, statistics Original post: https://blog-about-people-analytics.netlify.app/posts/2024-09-04-sprint/ I just finished watching Netflix's excellent sport documentary [Sprint](https://www.imdb.com/title/tt28669701/), which shows the ups and downs of several top male and female sprinters during their 2023 season. In the final episode, while discussing [Sha'Carri Richardson’s](https://en.wikipedia.org/wiki/Sha%27Carri_Richardson) victory in the women's 100m race at the World Athletics Championships in Budapest 2023, the reporter highlighted how surprising and exceptional her win was, especially considering she did not qualify directly for the final (i.e., by being among the top two in one of the semifinals). The data enthusiast in me felt compelled to investigate just how surprising this achievement truly was. So, I examined the [data from the women's 100m finals and semifinals at all World Athletics Championships from 2001 to 2023](https://worldathletics.org/competition/calendar-results?hideCompetitionsWithNoResults=true&competitionGroupId=6), and used a slopegraph and a multilevel Spearman's rank correlation to analyze the consistency of finalists’ rankings across the finals and semis. As the plot below illustrates, the consistency is relatively high (*r* = 0.8), but it's not absolute, which leaves room for some surprising wins, which are rare but not impossible. However, in Sha'Carri's case, imho, her victory doesn’t seem particularly surprising in light of the available data, as she moved “only” from third place in the semis to first in the final (which, of course, doesn’t make her victory any less remarkable). ```r # uploading libraries library(tidyverse) library(correlation) # uploading data for female sprinters data <- readxl::read_xlsx('sprint_data.xlsx', sheet = 'female_100m') mydata <- data %>% dplyr::select(-name) %>% dplyr::mutate(event = as.factor(event)) # computing multilevel Bayesian Pearson correlation analysis c <- correlation::correlation( mydata, include_factors = TRUE, method = "spearman", multilevel = TRUE, bayesian = FALSE, ci = 0.99 ) # extracting correlation estimates Spearman_r = c[1,3] CI95L = c[1,4] CI95H = c[1,5] # preparing data for dataviz via slopegraph dataviz_data <- data %>% dplyr::mutate(event = as.factor(event)) %>% dplyr::group_by(event) %>% dplyr::mutate( final_rank = rank(time_seconds_final, ties.method = "min", na.last = "keep"), semifinal_rank = rank(time_seconds_semifinal, ties.method = "min", na.last = "keep"), ) %>% dplyr::ungroup() %>% dplyr::mutate( athlete = row_number(), group = ifelse(final_rank < semifinal_rank, 'better', ifelse(final_rank > semifinal_rank, 'worse', 'same')) ) %>% tidyr::pivot_longer( cols = c(final_rank, semifinal_rank), names_to = "stage", values_to = "rank" ) %>% dplyr::mutate( highlight = ifelse(name=="Sha'Carri RICHARDSON" & event == 'World Athletics Championships, Budapest 2023', TRUE, FALSE) ) # jitter function for slopegraph jitter_width <- 0.25 jitter_fn <- function(x) x + runif(length(x), -jitter_width, jitter_width) # creating the slopegraph ggplot2::ggplot(dataviz_data, aes(x = stage, y = rank, group = athlete, color = group)) + # Plot non-highlighted lines first ggplot2::geom_line(data = dataviz_data %>% filter(!highlight), aes(y = jitter_fn(rank)), alpha = 0.4, size = 1) + # Plot highlighted line separately to ensure it is on top ggplot2::geom_line(data = dataviz_data %>% filter(highlight), aes(y = jitter_fn(rank)), alpha = 1, size = 1.5, color = 'black', linetype='dashed') + # Define custom x-axis settings ggplot2::scale_x_discrete( position = "bottom", expand = c(0.03, 0.03), limits = c("semifinal_rank", "final_rank"), labels = c("semifinal_rank" = "Semifinal Ranking", "final_rank" = "Final Ranking") ) + ggplot2::scale_color_manual( values = c('worse'='#F44336', 'same'='#757171', 'better'='#008080'), labels = c('worse'='Worse Ranking', 'same'='Same Ranking', 'better'='Better Ranking') ) + # Define y-axis settings ggplot2::scale_y_reverse( breaks = seq(1, 10, by = 1), sec.axis = dup_axis(name = NULL) ) + # Add plot labels ggplot2::labs( x = NULL, y = NULL, color = NULL, title = "Stability of women's 100m sprinters' rankings across finals and semifinals\nat the 2001-2023 World Athletics Championships", subtitle = stringr::str_glue("Multilevel Spearman rank correlation = {round(Spearman_r,2)}; 99% CI: [{round(CI95L,2)}, {round(CI95H,2)}]"), caption = "\nOnly those sprinters who made it to the finals are included, and the semifinal rankings are based on all semifinalists.\nThe highlighted line corresponds to Sha'Carri Richardson's victory in the women's 100m at the 2023 World Championships in Budapest.\nData Source: worldathletics.org" ) + ggplot2::theme_minimal() + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin = margin(0, 0, 12, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 14, margin = margin(0, 0, 12, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), panel.grid = element_blank(), axis.ticks = element_blank(), axis.text.x = element_text(size = 14, color = '#2C2F46', hjust = c(0, 1)), axis.text.y = element_text(size = 12, color = '#2C2F46'), plot.margin = margin(t = 10, r = 30, b = 10, l = 30), plot.title.position = "plot", plot.caption.position = "plot", legend.position = "top", legend.margin = margin(0, 0, 0, 0), legend.box.margin = margin(0, 0, -10, 0) , legend.text = element_text(size = 12, color = '#2C2F46'), plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA) ) + ggplot2::guides(color = guide_legend(override.aes = list(linewidth = 5))) ``` ![](./sprint/unnamed-chunk-1-1.png) We could also ask how those who improved their rankings achieved this. Was it because they ran faster, or was it that their competitors ran slower in the final compared to the semis? A comparison of time differences between the finals and semis for those who improved, worsened, or maintained their ranking suggests that it's a combination of both. Those who improved or maintained their rankings tended to improve their times in the final, though the former group did so more significantly. Conversely, those who dropped in ranking generally ran slower in the final in comparison with the semi. ```r # uploading library library(ggdist) # preparing data for dataviz compa_data <- data %>% dplyr::mutate(event = as.factor(event)) %>% dplyr::group_by(event) %>% dplyr::mutate( final_rank = rank(time_seconds_final, ties.method = "min", na.last = "keep"), semifinal_rank = rank(time_seconds_semifinal, ties.method = "min", na.last = "keep"), ) %>% dplyr::ungroup() %>% dplyr::mutate( athlete = row_number(), group = ifelse(final_rank < semifinal_rank, 'Better Ranking', ifelse(final_rank > semifinal_rank, 'Worse Ranking', 'Same Ranking')), group = relevel(factor(group), ref = "Same Ranking"), diff = time_seconds_final - time_seconds_semifinal ) # raincloud plot ggplot2::ggplot( compa_data %>% mutate(group = factor(group, levels = c('Better Ranking', 'Same Ranking', 'Worse Ranking'))), aes(x = diff, y = fct_rev(group), color = group, fill = group)) + ggdist::stat_halfeye( adjust = 0.5, justification = -0.2, .width = 0, point_interval = "mean_qi" ) + ggplot2::geom_jitter( aes(color = group), # Add jittered raw data points width = 0.1, height = 0.1, alpha = 0.6 ) + ggplot2::geom_boxplot( aes(color = group), # Add a boxplot width = 0.1, outlier.shape = NA, alpha = 0.4, position = position_nudge(y = 0.12) ) + ggplot2::geom_vline(xintercept = 0, linetype='dashed') + ggplot2::scale_color_manual( values = c('Worse Ranking'='#F44336', 'Same Ranking'='#757171', 'Better Ranking'='#008080') ) + ggplot2::scale_fill_manual( values = c('Worse Ranking'='#F44336', 'Same Ranking'='#757171', 'Better Ranking'='#008080') ) + ggplot2::labs( title = "Difference in women's 100m sprinters' run time between finals and semifinals\nat the 2001-2023 World Athletics Championships", x = "DIFFERENCE IN RUN TIME BETWEEN FINAL AND SEMIFINAL (IN SECS)", y = "", caption = "\nOnly those sprinters who made it to the finals are included.\nData Source: worldathletics.org" ) + ggplot2::theme_minimal() + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin = margin(0, 0, 12, 0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 14, margin = margin(0, 0, 12, 0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x = element_text(color = '#2C2F46', face = "plain", size = 14, hjust = 0, margin = margin(15, 0, 0, 0)), axis.text.x = element_text(size = 12, color = '#2C2F46'), axis.text.y = element_text(size = 12, color = '#2C2F46'), plot.margin = margin(t = 10, r = 30, b = 10, l = 30), plot.title.position = "plot", plot.caption.position = "plot", plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA), legend.position = '' ) ``` ![](./sprint/unnamed-chunk-2-1.png) Not sure if there are any deeper lessons to be drawn from these results, perhaps just that while patterns and predictability exist, there is always room for surprises, the awareness of which can help nurture resilience and flexibility, key qualities for navigating uncertain environments. But maybe I just have a limited imagination, so feel free to share any suggestions for a better lesson 🙂 What I am 100% sure of, though, is that I enjoyed playing with the data a lot. If you'd like to play with them yourself, you can download them for both women and men from the two tables below.

Women's 100m Data

```r library(DT) DT::datatable( data, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, lengthMenu = c(5, 10, 15, 25), autoWidth = TRUE, dom = 'Blfrtip', buttons = c('copy', 'excel', 'csv'), scrollX = TRUE, scrollY = TRUE ) ) ```

Men's 100m Data

```r data_males <- readxl::read_xlsx('sprint_data.xlsx', sheet = 'male_100m') DT::datatable( data_males, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, lengthMenu = c(5, 10, 15, 25), autoWidth = TRUE, dom = 'Blfrtip', buttons = c('copy', 'excel', 'csv'), scrollX = TRUE, scrollY = TRUE ) ) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-09-04-sprint/) on my blog. --- # People may signal their exit intentions not only by their actions but also by their inactions URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/survey-participation-and-attrition-prediction Date: 2024-08-29 Tags: employee-turnover, employee-survey Original post: https://blog-about-people-analytics.netlify.app/posts/2024-08-29-survey-participation-and-attrition-prediction/ I recently came across a LI post discussing a more or less surprising fact about the relatively high predictive power of a single item from employee surveys in relation to employee turnover. The item in question was a simple question about the employee's intention to stay with the company if they were offered the same job at another organization. An insight corresponding to my mom’s wise advice she gave me when I was a small child: If you don’t know, just ask, most people will be happy to answer 🙂 To expand on this post, I'd like to add another related fact that I've encountered in several real-world datasets. There is another aspect of employee surveys that is quite highly predictive of people’s intention to leave the company. However, this time, it’s not a specific survey item, but the team (or individual) survey participation rate. The lower the participation rate, the higher the probability of leaving, all other things being equal. We can view it as one specific manifestation of employees’ withdrawal that is highly predictive of the intention to leave, as illustrated in the attached chart showing the strength, direction, and estimation precision of voluntary turnover predictors, as assessed in [Rubenstein et al. (2017) meta-analysis](https://onlinelibrary.wiley.com/doi/10.1111/peps.12226?msockid=2f13ca573eec66351787ded23f9b6733).
![](./survey-participation-and-attrition-prediction/plot_predictors_screenshot.png)
[Link to the app with the interactive plot](https://peopleanalyticsblog.shinyapps.io/voluntary_turnover_predictors/)
Apparently, people may signal their exit intentions not only by their actions but also by their inactions. As Sherlock Holmes famously observed in one of his cases, what’s suspicious is not only the barking of the dog but also its silence. Have you noticed any other forms of inaction that might predict an intention to leave? For example, one might consider lateness and absenteeism as types of inaction as well (they are, btw, also included among the predictors in the chart and are performing pretty well). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-08-29-survey-participation-and-attrition-prediction/) on my blog. --- # Insights from the Team Assessment Survey results of C-suite teams URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/tas-c-suit-teams Date: 2024-08-29 Tags: teams, network-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2024-08-29-tas-c-suit-teams/ Recently, [Dr. Gordon (Gordy) Curphy](https://www.linkedin.com/in/gordoncurphy/) posted an insightful [article discussing lessons learned from working with nearly 40 C-suite teams](https://www.linkedin.com/pulse/lessons-learned-working-c-suite-teams-gordon-gordy-curphy-phd-yezgc/?trackingId=jSnNpf%2B89EccpKTSog38IA%3D%3D), based on their results in the [Team Assessment Survey (TAS)](https://rocketmodelforteams.com/what-is-rocket-model/), a tool designed to evaluate the efficiency and effectiveness of teams by assessing various factors that contribute to team performance. The lessons could be briefly summarized as follows: * **Need for Improvement:** Many C-suite teams overestimate their performance and lack "how" feedback on team dynamics, revealing a need for benchmarking to improve efficiency and effectiveness. * **Optimal Team Size:** Teams often struggle with inclusivity issues, becoming too large to be effective. Smaller, focused "tiger teams" are recommended for strategic decision-making, while larger groups can handle execution. * **Group vs. Team Dynamics:** Most C-suite teams operate as hybrids, balancing individual and collective goals. Clarifying when to function as a group versus a team can enhance performance and reduce inefficiencies. * **Alpha Paralysis:** Dominance by assertive members and avoidance of difficult topics often lead to ineffective meetings. Facilitators should help teams prioritize strategic discussions and decide on group or team approaches to minimize this issue. * **Adaptability Challenges:** C-suite teams may need restructuring or personnel changes when performance lags, especially if team members’ skills or attitudes are mismatched with evolving organizational needs. * **Continuous Improvement:** Benchmarking feedback is essential but insufficient; ongoing efforts and accountability are crucial for sustained improvement in team dynamics and performance. The article also included a screenshot of a data table with the C-suite teams’ TAS results, along with information about their respective team size and the extent to which they operate as a team versus as a group.
![](./tas-c-suit-teams/table_pic.png)
It sparked my curiosity about whether further insights could be gleaned from this data using an analytical technique known as a [regularized partial correlation network](https://eiko-fried.com/wp-content/uploads/Epskamp-Fried-2018-Tutorial-partial-corr.pdf), a method that identifies direct relationships between variables by controlling for others while using regularization to enforce sparsity, ensuring clarity and reducing overfitting. ```r # libraries library(tidyverse) library(bootnet) # data extracted from the table data <- readxl::read_xlsx('table_extracted.xlsx') # preparing data mydata <- data %>% dplyr::select(-Team, -TQ) %>% dplyr::rename(`Team x Group`=`Group vs Team`) # estimating a regularized partial correlation network network <- bootnet::estimateNetwork( mydata, default = "EBICglasso", corMethod = "spearman", threshold = FALSE # when TRUE, enforces higher specificity, at the cost of sensitivity ) # plotting the estimated network plot( network, layout = "spring", groups = NaN, nodeNames = names(mydata), weighted = TRUE, directed = FALSE, label.cex = 0.7, label.color = 'black', label.prop = 0.9, negDashed = TRUE, legend = FALSE, font = 2, theme = "classic" ) ``` ![](./tas-c-suit-teams/unnamed-chunk-1-1.png) ```r # computing centrality measures for individual variables qgraph::centralityPlot(network, include = "all", orderBy = "ExpectedInfluence") ``` ![](./tas-c-suit-teams/unnamed-chunk-2-1.png) The resulting network, IMHO, revealed two or three extra insights that are at least interesting, if not useful: * **The Central Role of Norms and Buy-In:** These factors emerged as central in the network, a conclusion supported not only by a visual inspection of the network but also by the expected influence centrality measure. This finding may suggest that interventions targeting these areas could have the greatest leverage when trying to improve team efficiency and effectiveness. * **Team Dynamics and Courage:** Operating more as a team than as a group appears to predict lower levels of Courage - a factor related to team trust and psychological safety. A plausible explanation for this surprising finding could be that teams, as opposed to groups, place a stronger emphasis on cohesion, interdependence, and collective identity, which may create pressures that suppress perceived psychological safety. If this is true, it underscores the need to strike a delicate balance between fostering team cohesion and encouraging individual courage, ensuring that members feel safe to speak up without fear of disrupting team harmony. * **The Trade-offs of Team Size:** While having more members can be detrimental to a team’s efficiency and effectiveness - particularly regarding Buy-In (motivation to achieve team goals) and Talent (right size, skills, roles, rewards, and followership) - larger teams may benefit from better access to resources such as budget, equipment, data, and the authority or autonomy needed to accomplish goals. As is often the case in real life, things are not black and white and one must navigate various trade-offs and tailor their approach to specific circumstances. ⚠️ A small caveat: These findings are based on a very small sample of highly specific teams, which limits their reliability and validity. They should therefore be treated as such and used only as an inspiration for further reflection on team performance and potential improvement strategies. P.S. Kudos to ChatGPT for extracting the data from the screenshot of the data table - it made the analysis much easier. Thanks! 🤖😀 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-08-29-tas-c-suit-teams/) on my blog. --- # Kohonen's Self-Organizing Maps URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/kohonen-self-organizing-maps Date: 2024-08-21 Tags: data-visualization, data-science, machine-learning, personality, big-five, work-life-balance Original post: https://blog-about-people-analytics.netlify.app/posts/2024-08-21-kohonen-self-organizing-maps/ Recently, I needed to explore the interactions of several variables in relation to a variable of interest, and I was wondering what tool could help me, until I remembered that in the distant past I had used [Kohonen's Self-Organizing Maps (SOMs)](https://en.wikipedia.org/wiki/Self-organizing_map) in such situations - an artificial neural network designed for unsupervised learning that relies on competitive learning mechanism to preserve topological properties of high-dimensional data and map them onto a lower-dimensional grid for visualization and clustering. To illustrate, I applied it to a piece of data collected by [Clearer Thinking](https://www.clearerthinking.org/), which used it to compare the [predictive performance of different popular personality test frameworks in relation to various life outcomes](https://www.clearerthinking.org/post/how-accurate-are-popular-personality-test-frameworks-at-predicting-life-outcomes-a-detailed-investi). Specifically, I tried to explore the relationship between [Big Five traits](https://en.wikipedia.org/wiki/Big_Five_personality_traits) and work-life satisfaction. ```r # loading necessary libraries library(tidyverse) library(kohonen) library(gridExtra) # uploading data data <- readr::read_csv('big5_mbti-stlye_astrologicalSigns_enneagramTypes_outcome_variables.csv') # data preparation mydata <- data %>% dplyr::select(contains("Big5"), outcome_SatisfiedWithWorkLife ) %>% tidyr::drop_na() %>% dplyr::rename( Extraversion = Big5_E, Openness = Big5_O, Agreeableness = Big5_A, Conscientiousness = Big5_C, Neuroticism = Big5_N, "Work-Life Satisfaction" = outcome_SatisfiedWithWorkLife ) # normalize the data data_scaled <- mydata %>% dplyr::mutate(across(everything(), ~scale(.))) # creating a matrix for SOM function data_matrix <- as.matrix(data_scaled) data_matrix <- apply(data_matrix, 2, as.numeric) # keeping the original names of the vars colnames(data_matrix) <- colnames(data_scaled) # creating a SOM grid with hexagonal topology 20x20 som_grid <- kohonen::somgrid(xdim = 20, ydim = 20, topo = "hexagonal") # training the SOM set.seed(2024) som_model <- kohonen::som( data_matrix, grid = som_grid, rlen = 5000, alpha = c(0.05, 0.01), keep.data = TRUE ) # plotting the SOM using ggplot # SOM with hexagonal cells using ggplot # function to create hexagonal coordinates hex_coords <- function(x, y) { r <- 1 / sqrt(3) # the radius of a hexagon angles <- seq(0, 2*pi, length.out = 7) # angles for hexagon vertices data.frame( x = x + r * cos(angles), y = y + r * sin(angles) ) } # defining a blue-to-red color palette blue_to_red_palette <- colorRampPalette(c("blue", "white", "red")) # creating a list to store ggplot objects plots <- list() # generating the component planes for each variable and store them as ggplot objects for(i in 1:ncol(data_matrix)){ # extracting the component plane data plane_data <- data.frame( x = rep(1:som_grid$xdim, each = som_grid$ydim), y = rep(1:som_grid$ydim, som_grid$xdim), z = getCodes(som_model)[,i] ) # creating hexagon coordinates for each tile hex_list <- lapply(1:nrow(plane_data), function(j) { hex_coords(plane_data$x[j], plane_data$y[j]) }) # combining all hexagons into one data frame hex_data <- do.call(rbind, hex_list) hex_data$z <- rep(plane_data$z, each = 7) hex_data$group <- rep(1:nrow(plane_data), each = 7) # creating a ggplot object with hexagons p <- ggplot2::ggplot(hex_data, aes(x = x, y = y, group = group, fill = z)) + ggplot2::geom_polygon() + ggplot2::scale_fill_gradientn(colors = blue_to_red_palette(100)) + ggplot2::coord_fixed() + ggplot2::labs( title = colnames(data_matrix)[i], fill = "Value" ) + ggplot2::theme_minimal() + ggplot2::theme( axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank(), plot.title = element_text(hjust = 0.5) ) plots[[i]] <- p } # arranging the plots in a 2x3 grid gridExtra::grid.arrange(grobs = plots, nrow = 2, ncol = 3) # an alternative and easier SOM visualization # par(mfrow = c(2, 3)) # par(cex.main = 1.5) # for(i in 1:ncol(data_matrix)){ # plot(som_model, type = "property", property = getCodes(som_model)[,i], # main = colnames(data_matrix)[i], shape = "straight", palette.name = blue_to_red_palette) # } # dev.off() # # resetting the layout to default # par(mfrow = c(1, 1), cex.main = 1) ``` The maps clearly show that satisfaction with work-life is strongly associated with Extraversion (positively), Neuroticism (negatively), and Conscientiousness (positively), and only weakly—if at all—with Openness and Agreeableness (both positively). More importantly, however, we can also check various “exceptions” to these bivariate patterns by looking at the same specific locations across all maps. For example, one can observe a group of individuals with higher Neuroticism (in the lower left corner) who nevertheless report greater work-life satisfaction—possibly due to higher levels of Extraversion, Conscientiousness, and, to some extent, Openness and Agreeableness. That's something that wouldn't be possible to find out from the pairwise plots that are usually used for similar data exploration. Check it out for yourself below. ```r # exploring the data in the pairplot library(GGally) GGally::ggpairs( mydata, lower = list(continuous = GGally::wrap("smooth", alpha = 0.3)), diag = list(continuous = GGally::wrap("densityDiag", fill = "grey")) ) + ggplot2::theme_minimal() ``` ![](./kohonen-self-organizing-maps/unnamed-chunk-2-1.png) If you find yourself in a similar situation, definitely give it a try. Happy exploration(s) 🙂 ## Figures ![](./kohonen-self-organizing-maps/unnamed-chunk-1-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-08-21-kohonen-self-organizing-maps/) on my blog. --- # Analyzing LinkedIn connections' jobs using LLMs and the BERTopic package URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/linkedin-contacts-job-positions Date: 2024-08-15 Tags: nlp, generative-ai, topic-analysis, linkedin, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2024-08-15-linkedin-contacts-job-positions/ I took advantage of one rainy day during my vacation to dive into some LI data and explore what my nearly 7K contacts are doing professionally. The challenge was that the available LI data about my contacts' jobs was pretty limited - just their job titles. So, my first step was to enrich this information using a LLM to generate brief descriptions of their positions based on those titles. It's not perfect and it's a bit error-prone, but in a low-stakes situation like this, fortunately we can accept a bit of imperfection and some minor “hallucinations” 🧙 Next, I fed these descriptions into a [BERTopic](https://maartengr.github.io/BERTopic/index.html) workflow, which applies several NLP techniques to identify clusters of similar documents: 1. Generating sentence embeddings. 2. Reducing the dimensionality of those embeddings. 3. Clustering the reduced embeddings. 4. Vectorizing texts at the cluster level. 5. Extracting key terms per cluster using class-based TF-IDF. 6. Fine-tuning the cluster representations with the help of an LLMs. As you can see in the chart below, the range of roles is quite broad, but it seems that most of my contacts work in people analytics, data science, HR & people management, as executives, and as researchers in academia or in enterprises.
![](./linkedin-contacts-job-positions/li_contacts_jobs.png)
Below is an interactive version of the chart, where you can check the individual job titles behind the job categories. ```r library(reticulate) ``` ```python # packages used import numpy as np import pickle import pandas as pd import plotly.graph_objects as go import plotly.offline as py import matplotlib.pyplot as plt import matplotlib.colors as mcolors # data used with open('all_labels_remapped.pkl', 'rb') as f: all_labels_remapped = pickle.load(f) reduced_embeddings = np.load('reduced_embeddings.npy') data = pd.read_csv('data.csv') # extracting job titles titles = data['position'] # defining the 'Uncategorized' label uncategorized_label = 'Uncategorized' # generating 20 distinct colors cmap = plt.get_cmap('tab20') colors = [cmap(i) for i in range(20)] # convert matplotlib colors to hex format colors_hex = [mcolors.to_hex(c) for c in colors] # defining color for 'Uncategorized' uncategorized_color = '#D3D3D3' # Light gray # creating an empty figure fig = go.Figure() # plotting 'Uncategorized' points first uncategorized_indices = pd.Series(all_labels_remapped) == uncategorized_label fig.add_trace(go.Scatter( x=reduced_embeddings[uncategorized_indices, 0], y=reduced_embeddings[uncategorized_indices, 1], mode='markers', marker=dict( size=5, color=uncategorized_color, opacity=0.5 ), name=uncategorized_label, text=[f'Title: {titles[j]}
Category: {all_labels_remapped[j]}' for j in range(len(uncategorized_indices)) if uncategorized_indices[j]], hoverinfo='text', showlegend=True )); # plotting the rest of the groups on top unique_labels = pd.Series(all_labels_remapped).unique() for i, label in enumerate(unique_labels): if label == uncategorized_label: continue # skipping 'Uncategorized' since it was already plotted indices = pd.Series(all_labels_remapped) == label color = colors_hex[i % len(colors_hex)] fig.add_trace(go.Scatter( x=reduced_embeddings[indices, 0], y=reduced_embeddings[indices, 1], mode='markers', marker=dict( size=5, color=color, opacity=0.5 ), name=f'{label}', text=[f'Title: {titles[j]}
Category: {all_labels_remapped[j]}' for j in range(len(indices)) if indices[j]], hoverinfo='text', showlegend=True )); # updating layout fig.update_layout( title='', xaxis=dict( showgrid=False, zeroline=False, showline=False, showticklabels=False, title='', ), yaxis=dict( showgrid=False, zeroline=False, showline=False, showticklabels=False, title='', ), plot_bgcolor='rgba(0,0,0,0)', height=600, width=850, template='plotly_white' ) ``` No big surprises here, considering my career path and control over who I connect with. However, it can be more useful to view it from the perspective of who is missing and use it as a tool for intentional LI network building. After all, as some wise people say, we are the average of the people we spend the most time with. For example, I would appreciate having more artists among my contacts, but it’s a question of whether LI is the right network for finding such connections 😉 P.S. If interested, feel free to check out [one of my earlier apps](https://blog-about-people-analytics.netlify.app/posts/2021-12-16-linkedin-connections-analysis/) that automatically generates basic descriptive statistics about your LI connections. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-08-15-linkedin-contacts-job-positions/) on my blog. --- # Does a stick work? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-insecurity-and-behavioral-outcomes Date: 2024-07-01 Tags: motivation, meta-analysis, performance-management Original post: https://blog-about-people-analytics.netlify.app/posts/2024-07-01-job-insecurity-and-behavioral-outcomes/ Such data seems to be provided by an interesting meta-analysis conducted by [Jiang, Lawrence, and Xu (2022)](https://onlinelibrary.wiley.com/doi/full/10.1002/job.2652). The authors conclude that although job insecurity has curvilinear relationships with some employee workplace behaviors, such as task performance and organizational citizenship behavior (OCB-organization) (i.e., they first decrease and then increase with increasing job insecurity after reaching a certain inflection point), it is not a good motivational strategy for several reasons: 1. The positive relationships after the inflection points are relatively weak. 2. There is a linear, negative relationship of job insecurity with safety behavior, and a linear, positive relationship with counterproductive work behavior (CWB-organization). 3. The (negative) relationship of job insecurity with OCB-individual and creative performance becomes nonsignificant as job insecurity further increases, so if the goal is to increase these two specific behavioral outcomes, increasing employee job insecurity is not a way to achieve that. 4. As other studies have shown, such as meta-analysis by [Jiang & Lavaysse (2018)](https://www.researchgate.net/publication/325022952_Cognitive_and_Affective_Job_Insecurity_A_Meta-Analysis_and_a_Primary_Study), increased job insecurity has unwanted negative impacts on employee well-being and job attitudes.
![](./job-insecurity-and-behavioral-outcomes/job_insecurity_plot.jpg)
*A plot of the curvilinear relationship between (overall) job insecurity and task performance where the blue line is the fitted curve, whereas the red, straight lines are based on the interrupted regression results from the two-lines test.* All in all, low levels of job insecurity seem to be more beneficial as they are related to higher levels of task performance, OCB-I, OCB-O, creative performance, and safety behavior, as well as lower levels of CWB-O, without unwanted negative impacts on employee attitudes and well-being. For more interesting details of the study, check the original paper [here](https://onlinelibrary.wiley.com/doi/full/10.1002/job.2652). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-07-01-job-insecurity-and-behavioral-outcomes/) on my blog. --- # Using network graph modeling to capture overarching thematic clusters in employee comments URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/network-graph-employee-comments Date: 2024-06-24 Tags: employee-survey, topic-analysis, network-analysis, generative-ai, ai, python, r Original post: https://blog-about-people-analytics.netlify.app/posts/2024-06-24-network-graph-employee-comments/ I recently came across a [qualitative study](https://www.frontiersin.org/journals/psychology/articles/10.3389/fpsyg.2024.1293171/full?utm_source=F-AAE&utm_source=sfmc&utm_medium=EMLF&utm_medium=email&utm_campaign=MRK_2309690_a0P58000000G0YfEAK_Psycho_20240227_arts_A&utm_campaign=Article%20Alerts%20V4.1-Frontiers&id_mc=312035856&utm_id=2309690&Business_Goal=%25%25__AdditionalEmailAttribute1%25%25&Audience=%25%25__AdditionalEmailAttribute2%25%25&Email_Category=%25%25__AdditionalEmailAttribute3%25%25&Channel=%25%25__AdditionalEmailAttribute4%25%25&BusinessGoal_Audience_EmailCategory_Channel=%25%25__AdditionalEmailAttribute5%25%25) that used interviews with NHS Covid staff to explore the impact of working in COVID during a pandemic on their experience, ability to work effectively together and the impact of social dynamics (e.g. cohesion, social support) on teamwork and mental health. What particularly caught my attention was usage of network graph modeling for capturing overarching thematic clusters based on the co-occurrences of themes in the thematically coded interview transcripts. It inspired me to try to apply this approach to the open-ended comments from the employee survey after identifying the topics present in each comment using the LLM. IMO, this can help with better interpretation of the survey results by providing a broader context of the topics identified and thus help to more wisely select appropriate follow-up actions. Let’s check out how this works on sample comments taken from [Glassdoor](https://www.glassdoor.com). You can download the data from Kaggle [here](https://www.kaggle.com/datasets/davidgauthier/glassdoor-job-reviews/data). To preserve the anonymity of the company whose reviews I will be analyzing, I will not show all the filters used and will upload the pre-prepared data directly. This data consists of the cons reported by previous or current employees of one specific company at one specific location during the first half of 2021. Our input data looks as follows. ```python # uploading data import pandas as pd df = pd.read_csv('company_cons.csv') ``` ```r library(reticulate) library(DT) DT::datatable( py$df, rownames = FALSE, options = list(pageLength = 5) ) ``` Now let's extract individual pain points from employee feedback using LLM, specifically [Mixtral-8x7B-Instruct-v0.1](https://huggingface.co/mistralai/Mixtral-8x7B-Instruct-v0.1) from [Mistral AI](https://mistral.ai/). ```python from huggingface_hub import login login(token="your_token", add_to_git_credential=True) from transformers import AutoTokenizer, pipeline, AutoModelForCausalLM import torch import accelerate import bitsandbytes import re import ast import numpy as np # specifying the llm model model_id = 'mistralai/Mixtral-8x7B-Instruct-v0.1' tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained( model_id, device_map="auto", load_in_4bit=True, torch_dtype=torch.float32 ) generator = pipeline( task='text-generation', model=model, tokenizer=tokenizer, max_new_tokens=2048, repetition_penalty=1.1, device_map="auto", torch_dtype=torch.float32, do_sample=True, temperature=0.001 ) ``` We will loop over individual comments and extract pain points using the following prompt. ```python # looping over individual comments responses = [] for row in range(0, df.shape[0]): comment = df.loc[row, 'cons'] prompt = f'''[INST] You are a first-class expert in analyzing feedback provided by employees. Your task is to identify main pain points (negative feedback) in the comment. Be short and precise: use short phrases or sentences only to record main pain points, i.e. what employee does not like or complaint about. Use only those phrases or sentences that closely relate to the employee's feedback in the comment. Do not include points that indirectly or remotely relate to the employee's feedback. Always return a json file with the following key-value pair: pain points - a string of short sentences or phrases separated by commas describing what employee does not like or complaint about. Don't use brackets or parentheses in the value part. If there is no pain point mentioned in the comment or if you do not know which value to assign, always return an empty string with the key, i.e. {{"pain points": ""}}. As an example, the following comment "Poor decision-making. People who make decisions don't know much about the topic, and usually they don't consult experts. However, the workload is appropriate. I would like to see a more professional approach." will return the following json file: {{"pain points": "poor decision-making, missing expertise in decision-making"}}; the following comment "My direct manager was completely incompetent." will return the following json file: {{"pain points": "incompetent direct manager"}}. Return a json file as instructed. Always include the name of the key and its corresponding value, even if it is an empty string! Don't use brackets or parentheses in the value part. Avoid duplications or similar phrases or sentences. Don't provide any alternative solutions. Don't make any comments or notes on the output, just provide me the json file in the format I asked for, i.e. {{"pain points": "a string of short sentences or phrases separated by commas describing what employee does not like or complaint about"}}!!! Here is a comment to analyze: {comment} [/INST]''' response = generator(prompt) extracted_content = response[0]["generated_text"].split("[/INST]")[-1].strip() extracted_dict = re.search(r"\{.*\}", extracted_content, re.DOTALL).group() cleaned_dict = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', extracted_dict) proper_dict = ast.literal_eval(cleaned_dict) current_key = list(proper_dict.keys())[0] proper_dict['pain points'] = proper_dict.pop(current_key) responses.append(proper_dict) # enriching the original dataset with identified pain points pain_points_df = pd.DataFrame(responses) pain_points_df['pain points'].replace('', np.nan, inplace=True) pain_points_df = pd.concat([pain_points_df, df], axis=1) ``` Before the next step, we need to explode the enriched dataset on a pain point basis to get them on separate lines while preserving information about the original comments from which they were extracted. ```python # exploading the df by pain points pain_points_df['pain points'] = pain_points_df['pain points'].str.split(', ') pain_points_df_exploaded = pain_points_df.explode('pain points') ``` We get the following resulting table. ```r pain_points_df_exploaded = read.csv('pain_points_exploaded.csv') DT::datatable( pain_points_df_exploaded, rownames = FALSE, options = list(pageLength = 5) ) ``` Now we can proceed further and use the pre-prepared list of topic labels to classify all the extracted pain points. ```python # list of employee experience topic labels employee_experience_topics = pd.read_csv("employee_experience_topics.csv") employee_experience_topics['topic_description'] = employee_experience_topics['topic'] + ": " + employee_experience_topics['description'] ``` ```r library(tidyverse) DT::datatable( py$employee_experience_topics %>% dplyr::select(topic, description), rownames = FALSE, options = list(pageLength = 5) ) ``` For topic labeling, we will use the LLM again with the following prompt. ```python # making from topic labels one string that will be used in the prompt response_topic_bank = employee_experience_topics['topic_description'].to_list() pain_points_topic_list_str = "\n".join(response_topic_bank) responses_topics = [] # looping over individual pain points for index, row in pain_points_df_exploaded.iterrows(): text = row['pain points'] if not text or pd.isna(text): responses_topics.append(np.nan) else: prompt = f'''[INST] You are a first-class expert in analyzing feedback provided by employees. Your task is to select from the provided list and assign one specific topic label that best captures the general meaning of short snippet of text extracted from employee's feedback during exit interview while leaving the company. Don't make up your own topic labels and use only the topic labels from the following list: {pain_points_topic_list_str} If you consider more options from the provided list of labels, always choose only one of them!!! Don't provide me more alternatives, I want just one solution! If you are not sure about the meaning of the text or you don't know what label from the provided list to assign to the text, use the label 'Other', nothing more. Assign always only one label to the text. Don't provide any additional comment, note, or explanation of your reasoning before or after the suggested label. Don't provide me more alternatives, I want just one solution! Don't mix two different labels into one label. You are provided with the topic labels together with their description, but put into your output only the name of the topic, not its description! Return a json file in the format {{"topic": "suggested topic label"}}. As an example, the following text 'lack of sufficient staff in functional positions' will return the following json file: {{"topic": "Staffing & Recruitment"}}; text 'not selected for positions' will return the following json file: {{"topic": "Career Development & Growth Opportunities"}}; text 'lack of feedback' will return the following json file: {{"topic": "Performance Management"}}; text 'recognition based solely on sales results' will return the following json file: {{"topic": "Recognition"}}; text 'perceived threat from employee' will return the following json file: {{"topic": "Psychological Safety"}}; if you are not sure about the meaning of the text or you don't know what label from the provided list to assign to the text, you will return the following json file: {{"topic": "Other"}}. Return a json file in the required format. Assign always only one label that matches the meaning of the text best!!! Provide me only with the topic label name, not its description!!! Don't make any comment, note, description of your reasoning, or explanation of your reasoning. Don't provide me more alternatives. If there are more options, choose only one of them - the one you think is the best option!!! Avoid using any brackets or parentheses in your output! Return just one json file. Remember, before giving me required output, check if you are giving one proper json file I can immediately process in Python! If not, wait and redo your work so I get what I asked for!!! Now give me the best label available in the provided list of labels for the following text: {text} [/INST]''' response = generator(prompt) extracted_content = response[0]["generated_text"].split("[/INST]")[-1].strip() extracted_dict = re.search(r"\{.*?\}", extracted_content, re.DOTALL).group() proper_dict = ast.literal_eval(extracted_dict) responses_topics.append(proper_dict) # enriching dataset with pain points with selected topic labels responses_topics_df = pd.DataFrame([x if isinstance(x, dict) else {'topic': np.nan} for x in responses_topics]) pain_points_df_exploaded['topic_label'] = responses_topics_df['topic'].values ``` Now we have for each pain point also a corresponding topic label. ```r pain_points_topics_exploaded = read.csv('pain_points_topics_exploaded.csv') DT::datatable( pain_points_topics_exploaded, rownames = FALSE, options = list(pageLength = 5) ) ``` In the final step, we need to obtain descriptive statistics for the co-occurrence of all pairs of topics across all employee comments and in a format suitable for network analysis. ```python from itertools import combinations from collections import Counter # counter for storing co-occurrence counts co_occurrence_counter = Counter() # finding topic combinations within each employee comment for _, group in pain_points_df_exploaded.groupby('id'): topics = group['topic_label'].tolist() for combo in combinations(sorted(topics), 2): co_occurrence_counter[combo] += 1 # converting the counter to a df co_occurrence_df = pd.DataFrame( list(co_occurrence_counter.items()), columns=['topic_pair', 'weight'] ) # splitting the tuple into separate columns co_occurrence_df[['from', 'to']] = pd.DataFrame(co_occurrence_df['topic_pair'].tolist(), index=co_occurrence_df.index) # dropping the original topic_pair column co_occurrence_df = co_occurrence_df.drop(columns='topic_pair') # removing edges between the same topic labels co_occurrence_df = co_occurrence_df[co_occurrence_df['from']!=co_occurrence_df['to']] co_occurrence_df.reset_index(drop=True, inplace=True) # changing order of cols co_occurrence_df = co_occurrence_df[['from', 'to', 'weight']] co_occurrence_df.sort_values(by='weight', ascending=False) ``` And this is how the final table looks like. ```r pain_points_cooccurrence = read.csv('pain_points_cooccurrence.csv') DT::datatable( pain_points_cooccurrence, rownames = FALSE, options = list(pageLength = 5) ) ``` The above table can then be used to create an undirected network object and visualize it using, for example, the [d3Network package](https://christophergandrud.github.io/networkD3/) for D3 JavaScript network graphs. The resulting network graph shows the relationships between topics such that topics that are closer together and connected by stronger edges tend to appear more frequently in employee comments together. The node size indicates how often the topic occurs with other topics in employee comments ([degree centrality](https://en.wikipedia.org/wiki/Centrality)), and the colors represent the communities (clusters) of topics as detected by the [Louvain method for community detection](https://en.wikipedia.org/wiki/Louvain_method). The graph is interactive, so you can zoom in/out and highlight specific parts of the graph you're interested in. ```r library(igraph) library(networkD3) # creating network object network <- graph_from_data_frame(pain_points_cooccurrence, directed=FALSE) # computing degree centrality and community detection using Louvain method V(network)$degree <- degree(network) clusters <- cluster_louvain(network) V(network)$community <- clusters$membership # preparing data for networkD3 dataviz networkD3_data <- igraph_to_networkD3(network) networkD3_data$nodes$group <- V(network)$community networkD3_data$nodes$degree <- V(network)$degree networkD3_data$nodes$reporting_name <- V(network)$name # specifying custom color scale colors <- c("#1b39a6", "#b13aa0", "#72a239", "#895f22", "#d08311", "#ca4f1a", "#8764d9") # networkD3 dataviz networkD3::forceNetwork( Links = networkD3_data$links, Nodes = networkD3_data$nodes, Source = 'source', Target = 'target', NodeID = 'reporting_name', Value = "value", Group = "group", Nodesize = 'degree', arrows = FALSE, legend = FALSE, opacity = 1, zoom = TRUE, fontSize = 20, opacityNoHover = 1, linkDistance = 60, charge = -900, colourScale = JS(paste0("d3.scaleOrdinal().range([\"", paste(colors, collapse = "\", \""), "\"])")) ) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-06-24-network-graph-employee-comments/) on my blog. --- # Can there be too much cognitive diversity in teams? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/cognitive-diversity Date: 2024-06-04 Tags: diversity, cognitive-science, teams Original post: https://blog-about-people-analytics.netlify.app/posts/2024-06-04-cognitive-diversity/ In a very interesting study, [Aggarwal et al. (2019)](https://www.frontiersin.org/journals/psychology/articles/10.3389/fpsyg.2019.00112/full) investigated how the diversity of cognitive styles in teams (as measured by object-spatial imagery and OSIVQ verbal questionnaire) indirectly affects team learning through collective intelligence (as measured by a battery of various tasks, including the minimum-effort tacit coordination game to estimate team learning). They found that cognitive style diversity has a curvilinear, inverted U-shaped relationship with collective intelligence, which is further positively related to the rate at which teams learn and is a mechanism guiding the indirect relationship between cognitive style diversity and team learning.
![](./cognitive-diversity/chart.jpg)
*The relationship between cognitive style diversity and collective intelligence controlling for team size and cognitive style level.* The authors did not directly study the causes of this type of relationship, but the hypothesis they tested was based on an existing theorizing that contrasts cognitive diversity, which, on the one hand, brings a wealth of cognitive resources (knowledge, skills, etc.) useful for tackling the range of tasks the team faces, but on the other hand, also causes higher coordination costs due to different perspectives of team members. These results thus suggest that good old "moderation" might be a sound guiding principle in this area, as in many others. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-06-04-cognitive-diversity/) on my blog. --- # Estimating body height using an inventory? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/body-height-inventory Date: 2024-05-30 Tags: psychometrics, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2024-05-30-body-height-inventory/ Out of curiosity and a bit for fun, I just completed the 11-item height inventory by Hynek Cigler from the Department of Psychology, FSS MU, a teaching aid designed to demonstrate some of the basics of measurement in psychology and related fields. Instead of using a ruler, you indicate how much you agree with statements like “*I often have to stand on my tiptoes to see better.*” or “*I have plenty of legroom on the bus.*” Its decent psychometric qualities showed up in a fairly accurate estimate of my actual height (193 cm): it estimated my body height at 189 cm with a 95% confidence interval of [182-197] cm. The attached pic illustrates where the test put me. In terms of the height quotient (IQ), I was at 117 with a 95% CI of [100-129], so it's not up to Mensa, but at least I won't have to pay membership fees 😁
![](./body-height-inventory/height_histogram_results.jpg)
If you are doing training in psychometrics or need to educate your audience in this area, this tool can be very handy. The inventory is in Czech, but the online translation in Chrome does a pretty good job, so you can try it out for yourself even if you're not a Czech speaker (with all the limitations given by non-standard localization and not 100% adequate norms). You can find it [here](http://fssvm6.fss.muni.cz/vyska/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-05-30-body-height-inventory/) on my blog. --- # What makes people more likely to comment on a question in an employee survey? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/probability-of-comments-in-a-survey Date: 2024-05-21 Tags: employee-survey, employee-experience, machine-learning, interpretability, python Original post: https://blog-about-people-analytics.netlify.app/posts/2024-05-21-probability-of-comments-in-a-survey/ I've been curious about this for a while, but until recently, I only had my personal pet theories to rely on. Luckily, one of my recent projects gave me the chance to explore this question with real-world data and satisfy my curiosity a bit. Why bother? Well, you can get a better feel for the representativeness of the comments, from which a lot of useful insights into the employee experience can be gleaned. Btw, what’s your guess? And try to make a prediction before reading on and/or checking the charts – with hindsight the results may seem too obvious 😉 To test my ideas, I used a classification RF model to be able to capture non-linear relationships, and used item score and some common controls as predictors of whether an employee would leave a comment on a given item. Then, I applied Partial Dependence Plot – a global ML interpretation tool – to the fitted model to examine the relationship between item scores and the likelihood of leaving a comment.
![](./probability-of-comments-in-a-survey/item_pdps.png)
What were the results? Well, as usual, it depends. However, across the sample of items shown, we can observe a common pattern of a non-linear, S-reversed-shaped relationship. Dissatisfied employees tend to comment more, except for those who are extremely dissatisfied. As satisfaction increases, the probability of commenting decreases, only to slightly rise again as we approach a satisfaction level of 10. Generally, it can be said that less satisfied employees comment more on average. Given that we collect feedback from employees to improve things, it makes kind of sense, right? 🤓 Does this match your expectations, or are you surprised? Would you expect different patterns for different items? Have you conducted a similar exercise with your own data? If so, what were the results? Perhaps you also know of some relevant research on this topic. Feel free to share. P.S. If you would like to replicate this analysis using your own data, you can use the following Python script as inspiration. ```python # required libraries # data manipulation import pandas as pd import numpy as np import copy # dataviz from plotnine import * # ML from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.ensemble import RandomForestClassifier # ML explanation & interpretation from sklearn.inspection import partial_dependence # function for changing snake case names to titles (to beautify titles in generated charts) def snake_to_title(snake_str): # Split the string by underscores words = snake_str.split('_') # Capitalize each word capitalized_words = [word.capitalize() for word in words] # Join the words with spaces title_case_string = ' '.join(capitalized_words) return title_case_string # the analysis assumes wide-format data with individual-level records of employees' responses to employee survey questions on a scale 0-10 and their comments to these questions mydata = pd.read_csv('your_data.csv') # list of survey items of interest items = [ 'autonomy', 'engagement', 'workload', 'recognition', 'reward', 'strategy', 'growth', 'management_support', 'peer_relationship', 'diversity_inclusion', 'health_wellbeing_balance' ] # looping over individual items for item in items: print(item) # dataset to be used for ML task ml_data = copy.deepcopy(mydata) # name of the field with comments to a specific question item_comment = f'comment_{item}' # creating a flag indicating presence/absence of a comment ml_data[item_comment] = np.where( ml_data[item_comment].isna(), False, True ) # keeping only those employees who replied to the question on a scale 0-10 ml_data.dropna(subset=[item], inplace=True) # defining the predictors and target variable predictors = [item, 'age', 'gender', 'country', 'job_family_group', 'is_manager', 'management_level', 'org_unit', 'tenure'] target = item_comment # stratified split of the data into training and testing sets X = ml_data[predictors] y = ml_data[target] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1979, stratify=y) # defining the column transformer for data pre-processing preprocessor = ColumnTransformer( transformers=[ ('num', StandardScaler(), ['age', 'tenure']), ('cat', OneHotEncoder(drop='first'), [item, 'gender', 'country', 'job_family_group', 'is_manager', 'management_level', 'org_unit']) ] ) # Random Forest Classifier # skipping hyper-parameter fine-tuning for the sake of brevity rf = RandomForestClassifier(min_samples_leaf=5, min_samples_split=30, n_estimators=500,random_state=1979) # creating a pipeline pipeline = Pipeline(steps=[ ('preprocessor', preprocessor), ('classifier', rf) ]) # fitting the pipeline pipeline.fit(X_train, y_train) # skipping assessment of the quality of the model for the sake of brevity # PDP (Partial Dependence) plots # size of the sample of individual conditional expectation (ICE) curves n = 500 feature_names = X.columns fIndex = np.where(feature_names == item)[0][0] pdp_results = partial_dependence(pipeline, X_train, [fIndex], grid_resolution=50, kind="both") # extracting the data values = pdp_results['values'][0] average = pdp_results['average'][0] individual = pdp_results['individual'][0] # df for the average line pdp_data_avg = pd.DataFrame({ 'Score': values, 'Partial Dependence': average }) # df for the ICE curves pdp_data_ind = pd.DataFrame(individual, columns=values) pdp_data_ind['ID'] = pdp_data_ind.index pdp_data_ind = pdp_data_ind.melt(id_vars='ID', var_name='Score', value_name='Partial Dependence') # sampling 500 unique IDs sampled_ids = pdp_data_ind['ID'].unique() if len(sampled_ids) > n: np.random.seed(1979) sampled_ids = np.random.choice(sampled_ids, n, replace=False) pdp_data_ind = pdp_data_ind[pdp_data_ind['ID'].isin(sampled_ids)] pdp_data_ind['Score'] = pdp_data_ind['Score'].astype(float) # plotting the results item_title = snake_to_title(item) plot = ( ggplot() + geom_line(aes(x='Score', y='Partial Dependence', group='ID'), size=0.1, alpha=0.2, data=pdp_data_ind) + geom_line(aes(x='Score', y='Partial Dependence', group=1), size=1.5, data=pdp_data_avg) + scale_x_continuous(breaks=range(0,11)) + labs( title=f'PDP plot for score on "{item_title}" survey item', x=f'Score on "{item_title}" survey item', y='Probability of commenting' ) + theme_bw() + theme( plot_title=element_text(size=18, margin={'t': 0, 'r': 0, 'b': 10, 'l': 0}), axis_text=element_text(size=12), axis_title=element_text(size=14), axis_title_x=element_text(margin={'t': 10, 'r': 0, 'b': 0, 'l': 0}), axis_title_y=element_text(margin={'t': 0, 'r': 10, 'b': 0, 'l': 0}), strip_text_x=element_text(size=13), panel_grid_major=element_blank(), panel_grid_minor=element_blank(), figure_size=(11, 6) ) ) #print(plot) # saving the plot plot.save(filename=f"{item}_item_pdp.png", width=11, height=6, dpi=500) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-05-21-probability-of-comments-in-a-survey/) on my blog. --- # NetLogo: Don’t tell me, show me URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/netlogo Date: 2024-04-28 Tags: simulation, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2024-04-28-netlogo/ Recently, my 10-year-old son came across the concept of the [exploration vs. exploitation dilemma](https://en.wikipedia.org/wiki/Exploration-exploitation_dilemma) in one of his books and wanted me to help him understand it. I got quite sweaty in explaining it before I remembered [NetLogo](https://ccl.northwestern.edu/netlogo/), which contains a series of pre-programmed simulations of various multi-agent systems and emergent phenomena. One of them shows how this particular dilemma is solved by a colony of ants foraging for food using a very simple but effective system of rules: 1. **Random Exploration:** Ants explore randomly, ensuring the discovery of new resources. 2. **Pheromone Trails:** When an ant finds a piece of food, it carries the food back to the nest, dropping a chemical as it moves. When other ants "sniff" the chemical, they follow the chemical toward the food. As more ants carry food to the nest, they reinforce the chemical trail, focusing on exploitation. 3. **Pheromone Decay:** Trails weaken over time, prompting exploration when resources dwindle.

After watching a few rounds of the simulation and a brief explanation, everything became much clearer to my son. If you are ever faced with similar types of questions, give NetLogo a chance. It's free to use, contains a number of pre-programmed simulations, and if you don't find what you're looking for there, it's not hard to learn how to program what you need in NetLogo. Btw, I can't wait for my son to stumble upon the topic of how order can arise without some central controlling authority - there's a very effective bird flocking simulation in NetLog for that 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-04-28-netlogo/) on my blog. --- # Feedback can be a gift or a curse... URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/feedback-effect-on-performance Date: 2024-04-21 Tags: feedback, performance, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2024-04-21-feedback-effect-on-performance/ From personal experience, I've “always” known that feedback can have both positive and negative effects on my performance, depending on various factors (e.g. clarity, tone, timing, etc.). However, I wasn't aware of the data supporting this **dual nature of feedback** until I stumbled upon [a meta-analysis by Kluger & DeNisi (1996)](https://www.researchgate.net/publication/232458848_The_Effects_of_Feedback_Interventions_on_Performance_A_Historical_Review_a_Meta-Analysis_and_a_Preliminary_Feedback_Intervention_Theory). Their study analyzed 607 effect sizes from 23,663 observations and found that while feedback interventions (FIs) generally improved performance (*d*=0.41), over one-third of the FIs actually decreased performance.
![](./feedback-effect-on-performance/effects_distribution.png)
To explain this pattern, the authors proposed and tested, using moderator analysis, a theory suggesting that **FIs shift the focus among three hierarchically organized levels of control, influencing how individuals process and react to feedback:** 1. **Meta-tasks (including self-related processes):** Feedback at this level often focuses on the individual’s feelings and identity rather than the task itself. It can be counterproductive by diverting attention away from the task and toward the self, potentially leading to defensive responses or disengagement. 2. **Task motivation processes:** This level aims to affect the motivational factors that drive an individual to undertake and persist with a task. Effective feedback here can enhance motivation by setting goals, encouraging persistence, or highlighting the task’s importance, thereby boosting willingness to invest effort. 3. **Task learning processes:** Feedback at this level provides specific guidance on improving task performance, including details on errors, corrective actions, or skill enhancement techniques. The results showed that the effectiveness of FI indeed tends to decrease as the focus shifts upward in the hierarchy, moving closer to self-related aspects and away from the task at hand. So the next time you ask for feedback, you should now know better what to wish for 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-04-21-feedback-effect-on-performance/) on my blog. --- # If you are a leader, don't be afraid to be a nice one URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/nice-leaders Date: 2024-03-21 Tags: personality, big-five, leadership Original post: https://blog-about-people-analytics.netlify.app/posts/2024-03-21-nice-leaders/ Contrary to the old adage that "nice guys finish last", a [meta-analysis by Blake et al. (2022)](https://www.sciencedirect.com/science/article/pii/S1048984321000989) that examined the relationship between leader agreeableness and leadership outcomes found that leader agreeableness was positively related to both leadership effectiveness and leadership emergence, though more strongly to the latter.
![](./nice-leaders/meta-analysis-results.png)
In the authors' own words, “*It seems that leaders no longer need to choose to be “effective” or “nice,” but rather both can be achieved simultaneously.*” Interestingly, this applies, though perhaps to varying degrees, to leaders regardless of whether they work in collectivist or individualist cultures, whether they are executive or non-executive leaders, or whether they are male or female. Considering that a large part of a manager's job is interacting with other people, be it individual employees, entire teams or larger groups, we probably shouldn't be so surprised. Still, it's good to see some support for this claim in the data. The article also contains other interesting results of various moderation and post-hoc analyses, so I recommend you check out the original (open-access) article linked above. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-03-21-nice-leaders/) on my blog. --- # Does agile pay off? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/agile-project-management Date: 2024-03-06 Tags: i-o-psychology, project-management, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2024-03-06-agile-project-management/ I must admit with some shame that it was only when I came across the meta-analysis by [Koch et al. (2023)](https://bpspsychub.onlinelibrary.wiley.com/doi/pdf/10.1111/joop.12429) that I wondered whether there was any systematic evidence to support the benefits of an Agile Project Management (APM). The authors of this pre-registered meta-analysis (with *k* = 41 independent studies, *N* = 73,825) focused specifically on the affective, behavioral and cognitive outcomes of APM. What did they find? Beneficial effects of APM across all three outcomes: * For the affective outcomes of job satisfaction, affective strain and organizational commitment, the effect sizes were, on average, small. * For the behavioral outcomes of performance and innovative behavior, the effect sizes were medium to large. * For the cognitive outcome of psychological empowerment, the results of the meta‐analysis suggest a medium effect.
![](./agile-project-management/metaanalysis_table_marked.png)
Interesting were also the results of the analysis of moderating effects of contextual factors, specifically team size (no significant moderating effect), and occupational groups (compared to studies conducted with software developers, the effect sizes were stronger in other occupations, such as manufacturing, health care and logistics). So pretty good news for APM proponents, however, it's good to keep in mind that this is "only" a preliminary meta-analysis, that there aren't that many studies, especially regarding affective and cognitive outcomes, that the relationships found cannot be interpreted as causal as 90% of the studies only report cross-sectional correlations, and that there is also some evidence of publication bias that may overestimate the effects. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-03-06-agile-project-management/) on my blog. --- # A showdown between the Big Five, Enneagram, MBTI, and astrology URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personality-frameworks-contest Date: 2024-02-29 Tags: personality, validity, big-five, critical-thinking Original post: https://blog-about-people-analytics.netlify.app/posts/2024-02-29-personality-frameworks-contest/ Just came across a nice, however small (N=559) and, AFAIK, non-peer reviewed, [study](https://www.clearerthinking.org/post/how-accurate-are-popular-personality-test-frameworks-at-predicting-life-outcomes-a-detailed-investi) comparing accuracy of popular personality test frameworks at predicting 37 different life outcomes (e.g., frequency of exercise, life satisfaction, number of close friends, etc.). The main findings? They shouldn't be a big surprise to I/O psych folks, but IMO they still contain two or three interesting, not widely known details. * The Big Five personality test was more accurate than Jungian (MBTI-based) and Enneagram tests in predicting life outcomes. Fun fact, MBTI-style test was halfway between science and astrology, literally 🧙️ * The accuracy of these tools is negatively related to their popularity as measured by the frequency of Google searches. * Removing Neuroticism from the Big Five reduced its predictive power significantly. * Most personality traits form bell curves, suggesting that MBTI’s binary categorization may be less accurate. * Continuous scores in the Jungian framework improved predictions over binary categories, but even with adjustments, the Big Five (minus Neuroticism) slightly outperformed the modified Jungian test. * Jungian traits correlated with specific Big Five traits, but combining both tests didn't enhance predictive accuracy. * Despite its simplicity, the Enneagram performed better than the binary Jungian Type but was still less effective than the Big Five. * Participants preferred their Jungian assessments, likely due to its more positive framing and missing Neuroticism trait.
![](./personality-frameworks-contest/personality_frameworks_accuracy.png)
Note: [Link](https://www.scientificamerican.com/article/personality-tests-arent-all-the-same-some-work-better-than-others/) to the related Scientific American article. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-02-29-personality-frameworks-contest/) on my blog. --- # Does your team belong among “light bulbs” or “wines”? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/expected-remaining-time Date: 2024-02-26 Tags: employee-turnover, statistics, python Original post: https://blog-about-people-analytics.netlify.app/posts/2024-02-26-expected-remaining-time/ For those interested in data analytics, I highly recommend the book [Probably Overthinking It](https://www.allendowney.com/blog/) by [Allen B. Downey](https://www.linkedin.com/in/allendowney/). It offers insightful details on the various, more or less known data analytical situations that one might encounter while using data to understand the world better or to make more informed decisions. Even if you are not a beginner in data analytics, there is a good chance that you will come across some new and valuable insights, as it happened to me. For example, as a people analytics practitioner, I found it particularly enlightening to examine the survival function - commonly used in employee attrition modeling - from the perspective of average remaining time and related concepts of *new better than used in expectation* (NBUE) vs. *new worse than used in expectation* (NWUE). From now on, for me, org units exhibiting NBUE and NWUE characteristics are "light bulbs" and "wines", respectively 😉 If you’re curious about how to get from survival function to average remaining time, check out the short snippets of Python code below that do the trick. It's not a big deal, but it can still save you some time 👇 First, let's upload the dummy data and create functions that allow us to estimate and display the survival curve and the corresponding expected remaining time curve. ```python # libraries used import pandas as pd from plotnine import * from sksurv.nonparametric import kaplan_meier_estimator # uploading the data data_bulb = pd.read_csv('./attrition_data_bulb.csv') data_wine = pd.read_csv('./attrition_data_wine.csv') # function for estimating and plotting the survival function def survival_function_estimation_plotting(data, plot_name = 'chart'): # estimating the survival function using the Kaplan-Meier estimator time, survival_prob, conf_int = kaplan_meier_estimator(data["event"], data["tenure_years"], conf_level=0.95, conf_type="log-log") # supp df for dataviz supp_df = pd.DataFrame({ 'time': time, 'survival_prob': survival_prob, 'lower_ci': conf_int[0], 'upper_ci': conf_int[1] }) # dataviz plot = ( ggplot(supp_df, aes(x='time')) + geom_step(aes(y='survival_prob'), color='#23004C', size=1) + geom_ribbon(aes(ymin='lower_ci', ymax='upper_ci'), alpha=0.25) + scale_y_continuous(limits=[0,1]) + labs( title="Probability of staying over time since joining the company", x='TIME (IN YEARS)', y='ESTIMATED PROBABILITY OF STAYING' ) + theme_bw() + theme( plot_title=element_text(size=20, margin={'b': 12}, ha='left'), axis_title_x=element_text(size=15, margin={'t': 15}), axis_title_y=element_text(size=15, margin={'r': 15}), axis_text_x=element_text(size=10), axis_text_y=element_text(size=10), strip_text_x=element_text(size=14, weight='bold'), panel_grid_major=element_blank(), panel_grid_minor=element_blank(), figure_size=(12, 6.5) ) ) # saving the plot # ggsave(plot=plot, filename=f'survival_curve_{plot_name}.png', width=12, height=6, dpi=500) # printing the plot print(plot) # function for computing the average remaining time in the company def remaining_time(data): results = [] # iterating over each time point in the data's index for t in data.index: if data.loc[t, "survival_prob"] > 0: # calculating the conditional survival probabilities from time t onwards # by dividing the survival probabilities by the survival probability at time t conditional_df = data.loc[t:, "survival_prob"] / data.loc[t, "survival_prob"] # removing the survival probability at time t from the calculations # as it's not needed for the expected additional time calculation conditional_df = conditional_df.iloc[1:] # calculating the expected additional time by taking the weighted average # of the time points, using the conditional survival probabilities as weights expected_additional_time = sum(conditional_df.values * (conditional_df.index - t)) / conditional_df.sum() result = { "time": t, "expected_additional_time": expected_additional_time } # adding the result to the results list results.append(result) else: pass # converting the results list to a DataFrame return pd.DataFrame(results) # function for estimating the uncertainty using the bootstrapping technique and for plotting the results def remaining_time_bootstraping_plotting(data, n_bootstrap=100, plot_name='chart'): # calculating the expected remaining time using all the data time, survival_prob = kaplan_meier_estimator(data["event"], data["tenure_years"]) supp_df_all = pd.DataFrame({'time': time, 'survival_prob': survival_prob}) supp_df_all = supp_df_all .set_index('time') all_results_df = remaining_time(supp_df_all) # estimating the uncertainty using the bootstrapping technique all_curves = [] for i in range(n_bootstrap): # resampling the data with replacement bootstrap_sample = data.sample(n=len(data), replace=True) # estimating the survival function using the Kaplan-Meier estimator time, survival_prob = kaplan_meier_estimator(bootstrap_sample["event"], bootstrap_sample["tenure_years"]) # supp df for further calculations supp_df = pd.DataFrame({'time': time, 'survival_prob': survival_prob}) supp_df = supp_df.set_index('time') # calculating the expected remaining time remaining_time_bootstrap = remaining_time(supp_df) # adding a column to identify the bootstrap iteration remaining_time_bootstrap['bootstrap_id'] = i # adding the result to the all_curves list all_curves.append(remaining_time_bootstrap) # concatenating all bootstrap results into a single df bootstrap_results_df = pd.concat(all_curves) # dataviz plot = ( ggplot() + geom_step(bootstrap_results_df, aes(x='time', y='expected_additional_time', group='bootstrap_id'), color='grey', alpha=0.1) + geom_step(all_results_df, aes(x='time', y='expected_additional_time'), color='#23004C', size=1) + labs( title="Average remaining time in the company", x='TIME SINCE JOINING THE COMPANY (IN YEARS)', y='AVERAGE REMAINING TIME (IN YEARS)' ) + theme_bw() + theme( plot_title=element_text(size=20, margin={'b': 12}, ha='left'), axis_title_x=element_text(size=15, margin={'t': 15}), axis_title_y=element_text(size=15, margin={'r': 15}), axis_text_x=element_text(size=10), axis_text_y=element_text(size=10), strip_text_x=element_text(size=14, weight='bold'), panel_grid_major=element_blank(), panel_grid_minor=element_blank(), figure_size=(12, 6.5) ) ) # saving the plot # ggsave(plot=plot, filename=f'remaining_time_curve_{plot_name}.png', width=12, height=6, dpi=500) # printing the plot print(plot) ``` Now let's estimate and visualize these two curves for a "light bulb" team (i.e., a team exhibiting the NBUE characteristic)... ```python survival_function_estimation_plotting(data=data_bulb) remaining_time_bootstraping_plotting(data=data_bulb) ``` ... and now for a "wine" team (i.e., a team exhibiting, at least partially, the NWUE characteristic). ```python survival_function_estimation_plotting(data=data_wine) remaining_time_bootstraping_plotting(data=data_wine) ``` ## Figures ![](./expected-remaining-time/bulb-1.png) ![](./expected-remaining-time/bulb-2.png) ![](./expected-remaining-time/wine-5.png) ![](./expected-remaining-time/wine-6.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-02-26-expected-remaining-time/) on my blog. --- # Biases introduced by video backgrounds during video interviews URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/video-interviews-and-biases Date: 2024-02-15 Tags: employee-selection, cognitive-biases Original post: https://blog-about-people-analytics.netlify.app/posts/2024-02-15-video-interviews-and-biases/ A recent set of experimental studies conducted by [Roulin et al. (2022)](https://onlinelibrary.wiley.com/doi/full/10.1002/job.2680) sought to determine whether and how the backgrounds in video interviews (VIs) could influence evaluators' judgments by revealing potentially stigmatizing features that are typically hidden in traditional selection contexts, but can become visible during VIs. The research specifically explored the impact of parental status, sexual orientation, and political affiliation on evaluators' perceptions.
![](./video-interviews-and-biases/video_backgrounds.png)
The results? * Applicants identified as parents were perceived as warmer and received higher ratings for interview performance, yet they were not judged more negatively in terms of competence or potential work performance. * Applicants who supported the same political party as the evaluator were seen as warmer and received higher assessments of both interview performance and potential work performance. * Sexual orientation had no impact on any of the outcome variables. These results suggest that standardizing video backgrounds during virtual interviews (VIs) should be considered a best practice, which is in line with the logic behind structured interviews that try to limit the variability in interview performance to the applicants' [KSAOs](https://dictionary.apa.org/knowledge-skills-abilities-and-other-characteristics) relevant for job performance. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-02-15-video-interviews-and-biases/) on my blog. --- # Creating new candidate topic labels on the fly during topic analysis with GenAI URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/topic-analysis-with-genai Date: 2024-02-05 Tags: topic-analysis, employee-survey, generative-ai, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2024-02-05-topic-analysis-with-genai/ One way to use GenAI for topic analysis of employee or customer feedback is to use zero-shot learning and test each comment against a pre-set list of topics that you think cover all relevant themes of interest. However, as you can imagine, real people can always surprise you and come up with themes that you hadn't thought of beforehand. A simple hack to deal with this is to dynamically update the list of candidate topic labels within a loop where GenAI first tries to use the currently existing topic labels to capture the meaning of the comment being analyzed and create new topic label(s) if the existing ones don't match the meaning of the comment. These new labels are then added to the list to be available for analysis of all subsequent comments. In another higher-level loop, this analysis can be performed several times to ensure that we capture all the themes present in the corpus of available comments, and that we consider all the identified topic labels for all comments. The whole process is thus not dissimilar to what a qualitative researcher does when going through the responses of their respondents and classifying those responses using categories based on previous responses or using new categories until new themes stop appearing. A bit of a challenge may be how to enforce that GenAI comes up with topic labels at a level of abstraction appropriate for a given use case. Based on my experiments, it seems that by providing some examples and counterexamples in the prompt and/or by providing "seed" topic labels at the "right" level of abstraction, you can nudge GenAI to create new labels at the desired level of abstraction. Below is a short snippet of Python code implementing this approach to topic analysis that you can use for inspiration. Happy topic modeling and exploration 😉 ```python from openai import OpenAI client = OpenAI(api_key=myOpenAiApiKey) # function for detecting topics in comments def identify_topics(text, topic_labels, item_text): prompt = client.chat.completions.create( model="gpt-4-1106-preview", temperature=0, messages=[ {"role": "system", "content": f""" You are a first-class expert in analyzing feedback provided by employees in employee surveys. Here is a list of available topic labels for feedback categorization: {','.join(topic_labels)}"""}, {"role": "user", "content": f""" First, summarize for yourself the feedback provided by an employee. When doing so, be sure to consider the wording of the survey item on which the employee has commented to make sure you have understood her feedback correctly. Here is the wording of the survey item: {item_text} Then, based on this summarization, from the list of available topic labels, select those that accurately capture all important aspects of the employee feedback. You are forbidden to use topic labels that only indirectly or remotely relate to the meaning of employee feedback. Use the topic label only if you are sure that it accurately captures one of the important aspects of employee feedback. Don't make things up and report only what is really in the employee feedback. Important: If the topic labels available in the list don't capture some important aspects of employee feedback, you must create and use a new topic label or multiple labels to capture these aspects of employee feedback. New topic labels must be short, concise, not too general, and specific enough that the management can easily understand what the feedback is about. For example, a more specific topic label like "HR Ticketing System Problems" would be much better than a more general topic label like "HR System Inefficiencies". As a response provide only a string of relevant topic labels separated by commas, nothing more. Here is the employee's feedback: {text} """} ] ) response = prompt.choices[0].message.content return response # seed list of candidate topic labels to be enriched during the analysis topic_labels = [] # higher-level loop for repeating the analysis on all the comments for run in range(0,3): responses = [] # loop over individual comments for row in range(0, df.shape[0]): response = identify_topics(text=df.loc[row, 'feedback_text'], topic_labels=topic_labels, item_text=df.loc[row, 'question_text']) responses.append(response) # extracting identified topics identified_topic_labels = response.split(",") # removing leading or trailing white space identified_topic_labels = [item.strip() for item in identified_topic_labels] # enriching the list of candidate topic labels topic_labels = topic_labels + identified_topic_labels # deduplication of topic labels topic_labels = list(set(topic_labels)) # creating field with identified topics in the df df['topic_labels'] = responses # adding fields with flags for identified topics for easier filtering of relevant comments for topic in topic_labels: df[topic] = df['topic_labels'].str.contains(topic) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-02-05-topic-analysis-with-genai/) on my blog. --- # Fostering psychological safety in the workplace URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/psychological-safety-and-rct Date: 2024-01-21 Tags: teams, experiments, evidence-based-management, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2024-01-21-psychological-safety-and-rct/ At Sanofi, we recently welcomed [Prof. Dr. Florian Englmaier](https://www.linkedin.com/in/florian-englmaier-4759b05/?originalSubdomain=de) at our People Insights Seminar, where he shared with us his intriguing [research on interventions that enhance psychological safety](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4141538). As he pointed out at the start of his talk, while we all understand the importance of psychological safety, the specific actions that effectively increase it are less clear. His research aimed to bridge this knowledge gap. Conducted in a real-world setting, the study encompassed over 1,000 teams and more than 7,000 employees within a global healthcare company. It utilized a RCT research design, a method not commonly seen in People Analytics, but more effective for identifying causal relationships. The research team compared two types of brief 6-week email campaigns, which encouraged managers to refocus and make regular their 1:1 meetings, against a control group: 1. The first group of managers was prompted to focus on addressing employees’ individual needs and aspirations. 2. The second group was encouraged to help employees better execute tasks and eliminate obstacles impacting their performance. Below you can see the used meeting guidance corresponding to the two treatment conditions (individual needs vs. tasks focus).
![](./psychological-safety-and-rct/Meeting-guidance-for-the-treatment-needs.png)
![](./psychological-safety-and-rct/Meeting-guidance-for-the-treatment-tasks.png)
Despite their simplicity, these interventions led to a noticeable increase in psychological safety, particularly in the group focusing on employees' individual needs. Additionally, they improved the relationships to and perceptions of the managers. Other significant and instrumental findings included an increase in the frequency of 1:1 meetings, as evidenced by Microsoft Workplace Analytics and survey data, and the observation that junior managers benefited most from these interventions. For more details, see the [original paper](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4141538). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-01-21-psychological-safety-and-rct/) on my blog. --- # Fighting with faking in personality assessments URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personality-assessments-and-faking Date: 2024-01-11 Tags: personality, psychometrics, validity Original post: https://blog-about-people-analytics.netlify.app/posts/2024-01-11-personality-assessments-and-faking/ If you are using personality assessments in a high-stakes setting where faking is a more frequent problem, such as in the context of employee selection, you should pay attention to the meta-analytic study by [Speer et al. (2023)](https://www.researchgate.net/publication/371619243_Comparing_forced-choice_and_single-stimulus_personality_scores_on_a_level_playing_field_A_meta-analysis_of_psychometric_properties_and_susceptibility_to_faking) that compared forced-choice (FC) and traditional single-stimulus (SS) personality assessments in terms of criterion-related validity and susceptibility to faking in terms of mean shifts and decreases in validity.
![](./personality-assessments-and-faking/item_examples.png)
A specific feature of this study was that it compared FC and SS measures after placing them on an equal playing field by relying only on studies that examined matched assessments of each format, and thus, avoiding the extraneous confound of using comparisons from different contexts. And what were the results? * Average scores increased from honest to faked samples for both FC (d = .41) and SS scores (d = .75), though the effect was more pronounced for SS measures and with larger effects for context-desirable traits (FC d = .61 vs. SS d = .99). * Criterion-related validity was similar between matched FC and SS measures overall (r′ = .19 vs. r′ = .18), however, when in faking contexts, FC scores exhibited greater criterion-related validity than SS measures (r′ = .18 vs. r′ = .04). So, although FC measures are not completely immune to faking, they seem to show meaningful benefits over SS measures in contexts of faking. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-01-11-personality-assessments-and-faking/) on my blog. --- # How should teams be designed to be creative and innovative? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/team-design-creativity-innovation Date: 2024-01-08 Tags: teams, innovation, leadership, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2024-01-08-team-design-creativity-innovation/ If you are responsible for managing a team that is expected to be creative and come up with innovative solutions, you may be interested in the findings of [Byron et al.'s (2023)](https://onlinelibrary.wiley.com/doi/abs/10.1111/peps.12501) meta-analysis of team-design-related antecedents of team creativity and innovation. The meta-analysis aggregated results from 134 field studies (11,353 teams) and 35 student studies (2,485 teams) and revealed the following regularities, among others: * Structuring teams to work interdependently (i.e. with greater interdependence of tasks and goals) is supportive of team creativity and innovation. * Leaders providing autonomy to team members have teams that are more creative and innovative and this relationship is stronger when leaders emphasize team autonomy than when they emphasize the autonomy of individual team members. * Related to the previous point, leaders who limit follower control also limit team creativity and innovation. * Team size, demographic diversity, and job-related diversity show only a weak relationship with team creativity and innovation. * Team task interdependence and supportive leadership are positively related to team creativity and innovation via processes of team collaboration (the extent to which team members work together to share information and knowledge) and team potency (the extent to which team members believe they can be effective). * There is evidence for a curvilinear relationship between team tenure and team creativity and innovation. Specifically, when teams are relatively new (~1 yr), being together longer leads to a slight decrease in team creativity/innovation; when teams are of moderate tenure (~2.5 yrs), being together longer has no effect on team creativity/innovation; and, when teams are quite mature (~9 yrs), being together longer leads to an increase in team creativity/innovation. ```python import pandas as pd from plotnine import * # uploading table with meta-analysis results data = pd.read_excel("./metaanalysis_results.xlsx") # extracting higher and lower borders of 95% CI data[['l95ci', 'h95ci']] = data['95CI'].str.split(',', expand=True).astype(float) # sorting the df for dataviz purposes data['abs_P'] = data['P'].abs() data_sorted = data.sort_values(by=['Category', 'abs_P'], ascending=[False, True]) categories_order = data_sorted['Variable'].unique() data_sorted['Variable'] = pd.Categorical(data_sorted['Variable'], categories=categories_order, ordered=True) ``` ```r library(reticulate) library(ggplot2) colors = c( 'Team composition'='#e15759', 'Task structure'='#f28e2b', 'Organizational support'='#4e79a7' ) ggplot(py$data_sorted, aes(x=Variable, y=P, fill = Category, color = Category)) + geom_hline(yintercept=0, linetype="dashed", color="grey") + geom_point(size=3) + geom_errorbar(aes(ymin=l95ci, ymax=h95ci), width=0.3, size=1) + scale_color_manual(values=colors) + scale_fill_manual(values=colors) + coord_flip() + labs( x = "", y = "Optimally-weighted and corrected mean correlation (ρ) with 95% CI", title = "Meta-analytic results for team design and team creativity/innovation", fill = "", color = "", caption = "Data source: Byron et al. (2023)" ) + theme_bw() + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,12,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12), axis.line = element_line(colour = "#E0E1E6"), axis.ticks = element_line(color = "#E0E1E6"), strip.text.x = element_text(size = 11, face = "plain"), legend.position= "top", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./team-design-creativity-innovation/dataviz-1.png) What might be the practical implications for managers? * Focus on selecting team members who are diverse in terms of job-related factors such as educational background. * Ensure task and goal interdependence - design projects to require collaboration, provide team-level feedback, and create team accountability systems. * When leading innovative teams, adopt an approach aimed at supporting and encouraging - not controlling - the team as a whole as opposed to the individuals within the team. * Try to keep high-quality people in the team as team tenure is positively related to team creativity and innovation. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-01-08-team-design-creativity-innovation/) on my blog. --- # Do high-low range goals aid in maintaining motivation over time? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/goal-setting Date: 2024-01-02 Tags: goal-setting, motivation Original post: https://blog-about-people-analytics.netlify.app/posts/2024-01-02-goal-setting/ Being at the time of year when [the "fresh start" effect](https://faculty.wharton.upenn.edu/wp-content/uploads/2014/06/Dai_Fresh_Start_2014_Mgmt_Sci.pdf) can help with achieving goals, like many, I'm contemplating what goals to set for the upcoming year and exploring strategies to enhance their attainability. Recently, I encountered a suggestion to establish high-low range goals rather than single-number targets. For example, aiming to run 8-12 km per week instead of a fixed 10 km. This approach is thought to offer a balanced mix of attainability and challenge, potentially leading to a greater feeling of accomplishment and, more importantly, a higher likelihood of goal re-engagement, i.e. sustained motivation to continue with one's goals over time. This all makes pretty good sense, yet I was surprised to find scant research on this topic, particularly outside the context of weight loss (see, for example, [the Scott & Nowlis' 2013 study](https://psycnet.apa.org/record/2013-32845-004)). Maybe I was just looking in the wrong places, or using the wrong key terms. Does anyone have insights into relevant research from other fields, or have you experimented with this approach in your organization? However, given the low-stakes circumstances, the minimal investment required, the consistency of the technique with existing knowledge about human motivation and common sense, and the low likelihood of serious adverse effects, I think it is quite reasonable to bet on this particular technique and give it a try, even though there seems to be relatively little evidence available. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-01-02-goal-setting/) on my blog. --- # Strength-based development and power-law vs. normal distribution of performance URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/strength-based-development-and-distributions Date: 2024-01-02 Tags: performance-management, learning-and-development, python Original post: https://blog-about-people-analytics.netlify.app/posts/2024-01-02-strength-based-development-and-distributions/ I just came across the notion that power-law distribution of performance, as opposed to normal distribution, supports the concept of strength-based development. The reasoning was that developing specific strengths can lead to greater success than trying to raise all skills to at least average level. This was quite surprising to me as my impression was that it was just the opposite. Given the multiplicative nature of power-law distribution, one should - in addition to developing one's specific strengths - try to avoid having any of the contributing factors at zero or near-zero level, as anything multiplied by zero is still zero. Instead, a normal distribution with its additive nature would support the idea that one can compensate for one's weaknesses by excessively developing one's specific strengths. To validate my impression, I ran a quick and dirty sim of where a person would rank with the same skill profile combining some excessive highs and lows with the middle grounds in a normal and power-law distribution of performance, respectively. ```python import pandas as pd import numpy as np from scipy.stats import percentileofscore # creating a dataframe with 7 random variables following a normal distribution np.random.seed(0) data = pd.DataFrame(np.random.normal(0, 1, (10000, 7)), columns=[f'var{i+1}' for i in range(7)]) # transforming these variables to the percentile scale for col in data.columns: data[col] = data[col].rank(pct=True) # creating two new columns: 'Performance with normal distribution' and 'Performance with power-law distribution' data['Performance with normal distribution'] = data.sum(axis=1) data['Performance with power-law distribution'] = data.prod(axis=1) # creating a skill profile combining some excessive highs and lows with the middle grounds profile = [1, 0.95, 0.6, 0.4, 0.5, 0.05, 0.025] profile_normal = sum(profile) profile_normal_pct = np.round(percentileofscore(data['Performance with normal distribution'], profile_normal), 1) profile_powerlaw = np.prod(profile) profile_powerlaw_pct = np.round(percentileofscore(data['Performance with power-law distribution'], profile_powerlaw), 1) ``` As you can see in the charts attached, the sim seems to support the notion that excessive strengths in the presence of excessive weaknesses help much more under the assumption of normally distributed performance. ```python from plotnine import * # plotting the normal performance distribution plot = ( ggplot(data) + aes(x='Performance with normal distribution') + geom_density(fill = 'grey', color = "white", alpha = 0.7) + geom_vline(xintercept = profile_normal, linetype = "dashed") + geom_text( label=f"A skill profile combining some excessive highs\nand lows with the middle grounds\n(corresponding to {profile_normal_pct} percentile)", x=profile_normal + 0.13, y = 0.45, ha = 'left', size = 13 ) + labs( title="Performance with normal distribution", x = 'Performance', y = 'Density' ) + theme_bw()+ theme( plot_title = element_text(size=20, margin={'b': 12}, ha='left'), axis_title_x = element_text(size=15, margin={'t': 15}), axis_title_y = element_text(size=15, margin={'r': 15}), axis_text_x = element_text(size=10), axis_text_y = element_text(size=10), panel_grid_major = element_blank(), panel_grid_minor = element_blank(), figure_size=(13, 6) ) ) print(plot) ``` ![](./strength-based-development-and-distributions/normal-performance-distribution-1.png) ```python # plotting the power-law performance distribution plot = ( ggplot(data) + aes(x='Performance with power-law distribution') + geom_density(fill = 'grey', color = "white", alpha = 0.7) + geom_vline(xintercept = profile_powerlaw, linetype = "dashed") + geom_text( label=f"A skill profile combining some excessive highs and lows with the middle grounds\n(corresponding to {profile_powerlaw_pct} percentile)", x=profile_powerlaw + 0.05, y = 50, ha = 'left', size = 13 ) + labs( title="Performance with power-law distribution", x = 'Performance', y = 'Density' ) + theme_bw()+ theme( plot_title = element_text(size=20, margin={'b': 12}, ha='left'), axis_title_x = element_text(size=15, margin={'t': 15}), axis_title_y = element_text(size=15, margin={'r': 15}), axis_text_x = element_text(size=10), axis_text_y = element_text(size=10), panel_grid_major = element_blank(), panel_grid_minor = element_blank(), figure_size=(13, 6), ) ) print(plot) ``` ![](./strength-based-development-and-distributions/powerlaw-performance-distribution-3.png) Given these results, one should feel motivated to identify among the mix of factors affecting one's performance those with multiplicative effect and try to bring them at least to the average level. The question is how one can easily identify which these are. Any tips? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2024-01-02-strength-based-development-and-distributions/) on my blog. --- # Does GenAI make me a better (more rational) thinker? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/gai-simulation-work-habits Date: 2023-12-13 Tags: time-management, generative-ai, critical-thinking, decision-making Original post: https://blog-about-people-analytics.netlify.app/posts/2023-12-13-gai-simulation-work-habits/ When someone recently asked me how GenAI has changed my work habits, I realized that one of the profound changes is that I'm more often testing my ideas, assumptions, and hypotheses with various simulations, usually implemented in Python or R. I've done this also in the past, but GenAI has made it much easier and thus lowered the passing threshold. In the past, there would have had to be something really important or compelling for me to be willing to invest my time and energy into creating a simulation. Now that GenAI can take care of a lot of the technical stuff for me (but still under my supervision), I find it much easier to reach for this very useful thinking tool. Last time, for example, I had a hard time imagining clearly enough what the impact of unequal ratios of different employee segments at higher levels of the org hierarchy would be on some metric of interest, assuming no bias in people's judgment, so to compensate for my poor imagination, I used GenAI to help me build a simulation that would allow me to see the answer more clearly. Time will tell if this new habit will last long enough to have a chance to better calibrate my believes and lead me to better decisions 🤞 What about you? What work habits has GenAI changed in your case, if any? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-12-13-gai-simulation-work-habits/) on my blog. --- # Reflective and intuitive thinking styles URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/reflective-and-intuitive-thinking-styles Date: 2023-12-12 Tags: meta-analysis, performance, employee-selection, learning-and-development, psychometrics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-12-12-reflective-and-intuitive-thinking-styles/ [Alaybek et al. (2021)](https://onlinelibrary.wiley.com/doi/full/10.1111/peps.12443) quite recently conducted a meta-analysis examining the relations between individual differences in reflective (rational) and intuitive thinking styles and workplace task performance, and showed that reflective thinking style has a positive and non-zero relation with task performance (*ρ* = 0.213). This positive relation was stronger in environments characterized by higher task complexity, greater importance of creativity and innovation for work tasks, and higher time pressure associated with work tasks. In the case of intuitive thinking style, there was a very small but positive relation with task performance (*ρ* = 0.051), and this relation was stronger in environments characterized by higher task complexity. More important, incremental validity analyses revealed that reflective thinking style explains unique variance in task performance, beyond conscientiousness and intelligence. It seems that a reflective thinking style may bear some extra clues to workplace performance. Not super surprising, but still far away from “Elementary, my dear Watson!" 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-12-12-reflective-and-intuitive-thinking-styles/) on my blog. --- # Link between the Big Five personality traits and earnings URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/big-five-personality-and-earnings Date: 2023-11-29 Tags: personality, labor-economics, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-29-big-five-personality-and-earnings/ I recently stumbled upon [a meta-analysis by Alderotti, Rapallini, & Traverso (2023)](https://www.econstor.eu/bitstream/10419/237085/1/GLO-DP-0902rev.pdf) that explores the interesting topic of the link between the Big Five personality traits and earnings. With one exception (Agreeableness?) there were no big surprises, IMO: “*The association seems to be both positive and statistically significant for Openness, Conscientiousness, and Extraversion, while negative and statistically significant for Agreeableness and Neuroticism.*” The included meta-regression, which analysed the effect of differences between studies on the estimated effects, yielded some interesting but still quite expected findings and hypotheses: * Part of the positive effect of Openness on earnings is mediated by cognitive ability, probably due to the fact that one aspect of Openness is correlated with intellect which directly and indirectly (through educational attainment) influences labor market success. * Conscientiousness can affect earnings not only directly, but also through its influence on workers' educational career. * Part of Neuroticism’s negative effect on earnings can be mediated by its negative impact on individual’s cognitive performance. * The influence of Extraversion and Agreeableness may be related to the fact that people choose/are chosen for different jobs that pay differently (e.g., extroverts may seek jobs that require more social interaction and pay better on average, and more agreeable individuals may be more likely to work in helping professions that offer lower pay on average). I know of at least two aspects of my personality that are unnecessarily costing me some of my potential money 🤑 Looks like I'm going to have to find a personality coach 🤓 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-29-big-five-personality-and-earnings/) on my blog. --- # How to support the adoption of Evidence-Based Management with a specialized GPT bot URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ebm-gpt-bot Date: 2023-11-29 Tags: evidence-based-management, i-o-psychology, generative-ai, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-29-ebm-gpt-bot/ One of the reasons why managers and organizations generally don't apply the principles of Evidence-Based Management as often as they could or should is the perceived difficulty and complexity of the entire process. Hiring professional consultants is one solution, but it may be too expensive for many. However, with the introduction of customizable GPTs, there is now an affordable way to build a kind of EBM consultant that can help managers go through all the necessary steps to increase the odds of solving the right problems with the right solutions. As a first and modest step in this direction, I have created a GPT bot named [Assistant to the Evidence-Based Manager](https://chat.openai.com/g/g-SxPch3r5d-assistant-to-the-evidence-based-manager). It provides EBM-related consultancy based on general instructions and reliable materials about EBM. It will definitely not replace an experienced EBM consultant or practitioner, but it may help overcome the resistance threshold for adopting EBM principles in organizations.
![](./ebm-gpt-bot/gpt_bot.png)
Give it a try and let me know what you think about this approach. Or even better, [try building your own GPT bot](https://openai.com/blog/introducing-gpts) that will do better than mine and share it with us 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-29-ebm-gpt-bot/) on my blog. --- # Rebuilding an employee survey with the help of NLP tools URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/rebuilding-a-survey-with-the-help-of-nlp-tools Date: 2023-11-27 Tags: employee-survey, nlp, generative-ai, psychometrics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-27-rebuilding-a-survey-with-the-help-of-nlp-tools/ Sometimes you need to incorporate a new construct into an existing employee survey, but you also don't want to burden respondents with too many items. One way to avoid this is to reuse existing survey items to cover all or some of the facets of the new construct of interest. When you have many items, it can be quite challenging to figure out which items might be good candidates for such reuse. However, with the new NLP tools, this is now much easier. To achieve this, in one of my projects, I combined a prompt requesting the LLM to semantically compare all existing items and facets of a new construct with a semantic similarity comparison of the two using text embeddings. Both approaches provided me with a pre-selected list of promising candidate items, which I then evaluated myself. This way, I significantly reduced the time it takes to figure out which items I can reuse and which I'll have to write from scratch. But LLM can help with that, too - see, for example, this [article from Hernandez & Nie (2022)](https://onlinelibrary.wiley.com/doi/10.1111/peps.12543). I know this use case is not that common, but IMO still enough to make it worth knowing that this option exists. Happy prompting and cosine similarity computing 🖖 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-27-rebuilding-a-survey-with-the-help-of-nlp-tools/) on my blog. --- # Evidence on the presence of gender bias in selection settings URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/gender-gap-in-hiring-decisions Date: 2023-11-22 Tags: diversity, experiments, meta-analysis, open-science, time-series Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-22-gender-gap-in-hiring-decisions/ [Schaerer et al. (2023)](https://www.sciencedirect.com/science/article/pii/S0749597823000560) conducted a pre-registered meta-analysis of 44 years of field experiments on gender gaps in hiring decisions with quite interesting results: * Discrimination against women for male-typed and balanced jobs decreased across time. * Discrimination against men for female-typed jobs remained stable across time. * Average effect is small - the average odds of male applicants to receive a callback is 0.91 times the odds of equally qualified female applicants (with 95% CI from 0.86 to 0.97). * Heterogeneity of true effects is very high, specifically 83% of total variance across studies can be attributed to heterogeneity rather than random chance, so the average effect is not so telling. * In addition to the meta-analysis, the study also included a forecasting challenge in which researchers and laypeople attempted to accurately estimate both time-trends and the current pervasiveness of gender biases in selection settings. Forecasters expected observed decline, but overestimated the degree of remaining bias.
![](./gender-gap-in-hiring-decisions/charts.jpg)
*Note on the attached chart: In all figures, odds ratios above 1 indicate a greater preference for male applicants and odds ratios below 1 indicate greater preference for female applicants.* --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-22-gender-gap-in-hiring-decisions/) on my blog. --- # Surprising finding on the impact of job demands and control on workers’ well-being URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-demands-job-control-wellbeing Date: 2023-11-18 Tags: well-being, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-18-job-demands-job-control-wellbeing/ According to [Huth & Chung-Yan (2023)](https://psyarxiv.com/z5bdk/), contrary to the position of many theories in the field of occupational health and stress, job control does not reduce the negative impact of job demands on workers' well-being. As you can see from the table below, the data provided strong evidence for the absence of the interaction between job demands and control.
![](./job-demands-job-control-wellbeing/tab.png)
At the same time, however, the authors themselves emphasize that “*[these] findings do not suggest that job demands and job control are not important work design features when considering the well-being of workers. Their direct effects on worker well-being are well-established in past research.*” “*The important conclusion of [the] study is that increased job control cannot offset the deleterious impact that high workloads have on workers. [This means that assuming] that employee well-being is a priority, workload should be restricted irrespective of the positive benefits of increasing employee control.*” --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-18-job-demands-job-control-wellbeing/) on my blog. --- # Personas based on ML local interpretation algorithms URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/personas-based-on-ml-local-interpretation-algos Date: 2023-11-09 Tags: machine-learning, interpretability, data-visualization, r, python Original post: https://blog-about-people-analytics.netlify.app/posts/2023-11-09-personas-based-on-ml-local-interpretation-algos/ There is one very useful albeit relatively underused method of sharing insights from fitted ML models, at least in my professional bubble. It's a method of identifying personas based on outputs from ML local interpretation algorithms, which provide information about the specific drivers of predictions for individual observations. Its implementation is pretty straightforward: 1. Fit a ML model. 2. Generate predictions for each observation using the fitted model. 3. Identify drivers of predictions for individual observation units using a ML local interpretation algorithm, e.g., LIME or SHAP. 4. Use the data points from steps 2 and 3 to identify clusters with similar characteristics. 5. Name and describe personas corresponding to the identified clusters. Let's see this in action using the Python code below. First, we need to create an artificial dataset on which we will demonstrate the method described above. We'll create a classification dataset - imagine, for example, that we're trying to predict sales performance based on some collaboration metrics, but feel free to imagine any scenario you like - and prepare a training and testing set to train our ML. ```python from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split import pandas as pd # defining the number of samples and features for the dataset n_samples = 10000 n_features = 10 # creating the dataset X, y = make_classification(n_samples=n_samples, n_features=n_features, n_informative=6, n_redundant=4, n_clusters_per_class=3, flip_y=0.27, class_sep=1, random_state=1979) # creating a df from X and y df = pd.DataFrame(X, columns=['feature_{}'.format(i) for i in range(n_features)]) df['criterion'] = y # splitting the dataset into training and test sets X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1979) ``` ```r library(DT) library(tidyverse) library(reticulate) # table dataviz DT::datatable( py$df, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` Now we can fit and fine-tune our ML (XGBoost) model. ```python from xgboost import XGBClassifier from sklearn.model_selection import GridSearchCV # fitting a XGBoost model with hyperparameter tuning and 10-fold cross-validation # initializing the XGBoost classifier xgb_model = XGBClassifier(random_state=1979) # defining the parameter grid for hyperparameter tuning param_grid = { 'n_estimators': [100, 200], 'max_depth': [3, 5, 7], 'learning_rate': [0.01, 0.1, 0.2] } # setting up the grid search with 10-fold cross-validation grid_search = GridSearchCV(xgb_model, param_grid, cv=10, scoring='f1') # fitting the model with hyperparameter tuning grid_search.fit(X_train, y_train) # getting the best estimator best_xgb_model = grid_search.best_estimator_ ``` The classification performance metrics below show that the fitted model performs well on the test data, so we can safely proceed further. ```python from sklearn.metrics import classification_report, roc_auc_score import numpy as np # predictions on the test set y_pred = best_xgb_model.predict(X_test) y_pred_proba = best_xgb_model.predict_proba(X_test)[:, 1] # classification report report = classification_report(y_test, y_pred) print(report) # ROC AUC score roc_auc = roc_auc_score(y_test, y_pred_proba) print('ROC AUC score: ', np.round(roc_auc, 2)) ``` Now we will generate LIME explanations for each observation in the testing dataset and standardize them for later analysis and visualization (including the predicted probabilities of the positive class). ```python import lime.lime_tabular from sklearn.preprocessing import StandardScaler # using LIME for local interpretation # initializing the LIME explainer explainer = lime.lime_tabular.LimeTabularExplainer( training_data=X_train, feature_names=['Feature_{}'.format(i) for i in range(X_train.shape[1])], class_names=['Low Performance', 'High Performance'], mode='classification', random_state=1234 ) # df for storing the LIME explanations for all observation explanations_df = pd.DataFrame() feature_names = df.columns[:-1].tolist() # generating LIME explanations for each observation in the test set for i in range(X_test.shape[0]): # predicted probability for the positive class predicted_class_proba = y_pred_proba[i] # generating the LIME explanation exp = explainer.explain_instance(X_test[i], best_xgb_model.predict_proba, num_features=X_train.shape[1]) exp_list = exp.as_list() feature_values = {name: 0 for name in feature_names} # looping through the employee's conditions and updating feature_values accordingly for condition, value in exp_list: for feature_name in feature_names: if feature_name in condition.lower(): feature_values[feature_name] = value break # adding the predicted probability for the positive class feature_values['predicted_class_proba'] = predicted_class_proba supp_df = pd.DataFrame(feature_values, index=[0]) explanations_df = pd.concat([explanations_df, supp_df], ignore_index=True) # standardizing all features (including the probability for the positive class) scaler = StandardScaler() explanations_scaled = scaler.fit_transform(explanations_df) explanations_scaled_df = pd.DataFrame(explanations_scaled) explanations_scaled_df.columns = explanations_df.columns ``` Using the UMAP 2D projection of the prediction explanations and predicted probabilities, we can see that that are several clusters of observations with similar predicted probabilities and their drivers. ```python import umap import matplotlib.pyplot as plt import seaborn as sns sns.set_theme(style="white") # visualizing the personas using UMAP # initializing and fitting UMAP reducer = umap.UMAP(n_components=2, n_neighbors=50, min_dist=0.01, metric='euclidean', random_state=1979, n_jobs=1) embedding = reducer.fit_transform(explanations_scaled) # plotting the explanations and predicted probability in 2D scatterplot plt.close() plt.figure(figsize=(12, 8)) scatter = plt.scatter(embedding[:, 0], embedding[:, 1], c='lightblue', s=50, alpha=0.5) plt.title('People with similar predictions and similar prediction drivers\n', fontsize=24) plt.figtext(0.05, 0.05, "UMAP projection of the LIME prediction explanations and predicted probabilities.", wrap=True, horizontalalignment='left', fontsize=12) plt.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=False) plt.tick_params(axis='y', which='both', left=False, right=False, labelleft=False) plt.show() ``` ![](./personas-based-on-ml-local-interpretation-algos/unnamed-chunk-6-1.png) A clustering algorithm, such as HDBSCAN, can help us identify the clusters. ```python import hdbscan import matplotlib.patches as mpatches # HDBSCAN clustering clusterer = hdbscan.HDBSCAN(min_cluster_size=25, min_samples=10, cluster_selection_epsilon=0.3, prediction_data=True) clusterer.fit(embedding) clusters = clusterer.labels_ # adding the cluster labels to the dataframe explanations_df['cluster'] = clusters # plotting the clusters plt.close() plt.figure(figsize=(12, 8)) cmap = plt.cm.get_cmap('tab20') norm = plt.Normalize(clusters.min(), clusters.max()) scatter = plt.scatter(embedding[:, 0], embedding[:, 1], c=clusters, s=50, cmap=cmap, norm=norm, alpha=0.5) patches = [mpatches.Patch(color=cmap(norm(i)), label=f'Cluster {i}') for i in np.unique(clusters)] plt.legend(handles=patches, fontsize=12) plt.title('People with similar predictions and similar prediction drivers\n', fontsize=24) plt.figtext(0.05, 0.05, "UMAP projection of the LIME prediction explanations and predicted probabilities. The clusters were identified using HDBSCAN.", wrap=True, horizontalalignment='left', fontsize=12) plt.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=False) plt.tick_params(axis='y', which='both', left=False, right=False, labelleft=False) plt.show() ``` There seem to be about eight clusters and some outliers (cluster -1). Let's look at how they differ in terms of predicted probabilities. According to the chart below, there appear to be four clusters with increased predicted probabilities (clusters 3, 5, 6, and 7), three with decreased predicted probabilities (clusters 0, 2, and 4), and one with more mixed predictions (cluster 1). ```python import matplotlib.colorbar as colorbar # plotting the distribution of probability of positive classes plt.close() plt.figure(figsize=(12, 8)) scatter = plt.scatter(embedding[:, 0], embedding[:, 1], c=y_pred_proba, cmap='viridis', s=50, alpha = 0.5) plt.title('People with similar predictions and similar prediction drivers\n', fontsize=24) plt.figtext(0.05, 0.05, "UMAP projection of the LIME prediction explanations and predicted probabilities.", wrap=True, horizontalalignment='left', fontsize=12) plt.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=False) plt.tick_params(axis='y', which='both', left=False, right=False, labelleft=False) cbar = plt.colorbar(scatter) cbar.set_label('Predicted class probability', rotation=270, labelpad=15) plt.show() ``` ![](./personas-based-on-ml-local-interpretation-algos/unnamed-chunk-8-5.png) Now we can check for the selected clusters which features and in which direction most affect their respective predicted probabilities. For example, we can see from the table below that the clusters with lower predicted probabilities (clusters 0, 2 and 4) are driven either by the respective values in features 6 and 9 (cluster 0), or by the respective values in features 0, 3, 7 and 9 (cluster 2), or by the respective values in feature 0 (cluster 4). ```r # creating a summary for each cluster across all feature drivers of predicted probabilities of the positive class tab1 <- py$explanations_df %>% dplyr::group_by(cluster) %>% dplyr::summarise_all(~median(., na.rm = TRUE)) # tab dataviz DT::datatable( round(tab1,2), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 10, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) %>% formatStyle( names(tab1 %>% dplyr::select(-cluster, -predicted_class_proba)), background = styleColorBar(range(tab1 %>% dplyr::select(-cluster, -predicted_class_proba)), 'lightblue'), backgroundSize = '98% 90%', backgroundRepeat = 'no-repeat', backgroundPosition = 'center' ) ``` Combined with information on the median values of these specific features, we can get a good idea of the people who tend to under-perform and "why". For example, people in cluster 0 score too high in feature 6 and too low in feature 9; people in cluster 2 score too low in features 0, 3, 7 and 9; and people in cluster 4 score too low in features 0. Given these differences, it would be useful to consider different approaches to try to improve the sales performance of people based on information about which persona they belong to. We could also repeat a similar analysis for clusters with higher predicted probabilities to see which combination of features tends to be associated with higher performance. ```python # creating a df with X and y from testing part of the dataset test_df = pd.DataFrame(X_test, columns=['feature_{}'.format(i) for i in range(n_features)]) test_df['predicted_class_proba'] = y_pred_proba test_df['cluster'] = clusters ``` ```r # creating a summary for each cluster across all raw data and predicted probability of the positive class tab2 <- py$test_df %>% dplyr::group_by(cluster) %>% dplyr::summarise_all(~median(., na.rm = TRUE)) # tab dataviz DT::datatable( round(tab2,2), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 10, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) %>% formatStyle( names(tab2 %>% dplyr::select(-cluster, -predicted_class_proba)), background = styleColorBar(range(tab2 %>% dplyr::select(-cluster, -predicted_class_proba)), 'lightblue'), backgroundSize = '98% 90%', backgroundRepeat = 'no-repeat', backgroundPosition = 'center' ) ``` Maybe you'll find the method described here useful in one of your ML projects. Happy data sleuthing 🙂 ## Figures ![](./personas-based-on-ml-local-interpretation-algos/unnamed-chunk-7-3.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-11-09-personas-based-on-ml-local-interpretation-algos/) on my blog. --- # Sentiment analysis of employee survey comments using zero-shot classification URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/sentiment-analysis-validation Date: 2023-10-24 Tags: machine-learning, ai, nlp, employee-survey, python Original post: https://blog-about-people-analytics.netlify.app/posts/2023-10-24-sentiment-analysis-validation/ Recently, I experimented with [zero-shot classification](https://huggingface.co/tasks/zero-shot-classification) - a machine learning task where the model classifies data into categories it hasn't encountered during training - to determine the sentiment of comments from an employee survey (positive, negative, mixed, neutral). I chose this approach as an alternative to the traditional [lexicon and rule-based NLTK sentiment analyzer](https://realpython.com/python-nltk-sentiment-analysis/). Using the transformer architecture of the model (bart-large-mnli from Facebook), I aimed to capture context from entire comments when gauging their sentiment. I admit that I had some doubts because the model wasn't specifically trained for sentiment analysis. Instead, it generalizes its understanding from the MNLI tasks to deduce sentiments. Fortunately, each comment was tied to specific statements that also received ratings on a standard 0-10 scale. This allowed me to cross-reference the sentiment classification with the ratings people assigned to these statements. So, how did the model do? As the attached chart shows, the average sentiment classification probability is pretty compellingly consistent with the ratings on the standard scale. ```r library(tidyverse) library(readr) mydata <- readr::read_csv("./mydata.csv") label_data <- mydata %>% dplyr::group_by(sentiment_category) %>% dplyr::slice(1) mydata %>% ggplot2::ggplot(aes(x = Score, y = avg_sentiment_score, color = sentiment_category)) + ggplot2::geom_line(linewidth = 1.5) + ggplot2::geom_point(size = 3) + ggplot2::scale_y_continuous(limits = c(0,NA), breaks = seq(0,1,0.1)) + ggplot2::scale_x_continuous(limits = c(-1,10), breaks = seq(0,10,1)) + ggplot2::scale_color_manual(values = c("Mixed"="#7b00e7", "Negative"="#db370e", "Neutral"="grey", "Positive"="#208600")) + ggplot2::geom_text(data=label_data, aes(label=sentiment_category), nudge_x=-0.5, hjust=0.75, vjust = 0, size = 5, fontface = "bold") + ggplot2::labs( x = "SURVEY ITEM RATING (0-10)", y = "AVERAGE PROBABILITY OF SENTIMENT CATEGORY", title = "Validation of zero-shot sentiment classification", color = "" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line = element_line(colour = "#E0E1E6"), axis.ticks = element_line(color = "#E0E1E6"), strip.text.x = element_text(size = 11, face = "plain"), legend.position= "none", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./sentiment-analysis-validation/unnamed-chunk-1-1.png) Seems like I can add another useful tool to my toolbox. If you are interested in trying it out, you can use a short code snippet below for it. ```python import pandas as pd from transformers import pipeline # sentiment analysis with zero-shot classification using the facebook/bart-large-mnli model sentiment_classifier = pipeline("zero-shot-classification", model="facebook/bart-large-mnli") comments = df['comments'].to_list() # defining the candidate labels candidate_labels = ["positive", "negative", "neutral", "mixed"] # setting the hypothesis template hypothesis_template = "The sentiment of this employee feedback is {}." # estimating the sentiment labels prediction = sentiment_classifier(comments, candidate_labels, hypothesis_template=hypothesis_template) prediction = pd.DataFrame(prediction) # creating columns with predicted sentiment (label with the highest probability) and sentiment scores for individual labels df['sentiment_label'] = prediction['labels'].apply(lambda x: x[0]) df['sentiment_score_positive'] = prediction.apply(lambda x: x['scores'][x['labels'].index('positive')], axis=1) df['sentiment_score_negative'] = prediction.apply(lambda x: x['scores'][x['labels'].index('negative')], axis=1) df['sentiment_score_neutral'] = prediction.apply(lambda x: x['scores'][x['labels'].index('neutral')], axis=1) df['sentiment_score_mixed'] = prediction.apply(lambda x: x['scores'][x['labels'].index('mixed')], axis=1) ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-10-24-sentiment-analysis-validation/) on my blog. --- # Using NLP & LLM to combat 'tip-of-the-tongue' moments during onboarding URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/nlp-llm-and-onboarding Date: 2023-10-23 Tags: employee-experience, nlp, generative-ai, embeddings, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2023-10-23-nlp-llm-and-onboarding/ I believe that many of you have had a similar experience when you joined a new company: you have taken a ton of notes from various meetings and trainings, you know that the answer to your immediate question is in there somewhere, but it is too hard to find the right notes, so you capitulate and decide to try to find the answer in a different way, e.g. by asking a more tenured colleague. To make better use of my notes from my current onboarding, I created a quick and dirty app that uses embeddings to find the relevant files with my notes and LLM to summarise answers to my questions. So, for example, if I need to find out who owns a certain business process, I can just type my question into the app and quickly get an answer, including tips on what files to look at to verify the answer or find other related information. ![](./nlp-llm-and-onboarding/Capture.png) If you want to check out the code behind the app, you can find it in [this GitHub repo](https://github.com/lstehlik2809/searching-onboarding-notes). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-10-23-nlp-llm-and-onboarding/) on my blog. --- # Exploration vs. Exploitation trade-off in our calendars URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/exploration-vs-exploitation-tradeoff Date: 2023-09-24 Tags: time-management, decision-making Original post: https://blog-about-people-analytics.netlify.app/posts/2023-09-24-exploration-vs-exploitation-tradeoff/ As a ["maker"](http://www.paulgraham.com/makersschedule.html), I'm used to using my time in larger blocks of time to do focused work on various data products. Now, however, my time tends to be spread out between a series of 30-45 minute long meetings that help me navigate a complex organization like Sanofi, explore opportunities for collaboration, and make valuable contacts that can help me deliver on tasks in the future.
![Illustration of the Maker's vs. Manager's schedule.](./exploration-vs-exploitation-tradeoff/managerMakerCalendar.png)
I wondered if I could gain any potentially useful insights from this particular ["intuition pump"](https://en.wikipedia.org/wiki/Intuition_pump). I took the help of [the book "Algorithms to Live By"](https://algorithmstoliveby.com/) by Brian Christian and Tom Griffiths and found at least five such insights: 1. Although one has a primary mode of operation (e.g. exploitation for makers, exploration for managers), one should not completely ignore the other mode and should allocate a small, consistent portion of one's time to ensure that one does not miss valuable insights or opportunities that lie outside one's primary mode of operation (based on the Epsilon-Greedy strategy to solve [the Multi-Armed Bandit problem](https://en.wikipedia.org/wiki/Multi-armed_bandit)). 2. Over time, as one becomes more aware of the options available, the need for exploration may decrease, allowing for more targeted exploitation of known best opportunities (based on the Decaying Epsilon strategy to solve the Multi-Armed Bandit problem). 3. Although our primary focus is on delivery, we should remain open and try new options that have the highest potential upside, despite the associated high uncertainty (based on the Upper Confidence Bound strategy to solution of the Multi-Armed Bandit problem). 4. If you plan to stay with the company for a while, you should be open to further exploration, as newly found valuable opportunities can be used later in the future, and vice versa (btw, this could be a useful signal of intentions to leave, as some research suggests that people actually tend to resolve this type of trade-offs this way). 5. Be Bayesian, i.e., choose your paths based on your current beliefs, regularly check the balance between exploration and exploitation on your calendar, and update your beliefs and make new, more informed decisions as evidence of outcomes accumulates (based on the Thompson Sampling strategy to solution of the Multi-Armed Bandit problem). Maybe you'll find some of these insights helpful in finding a better balance between exploring new paths and following familiar ones, leading to more effective use of your time. If nothing else, take this as a recommendation to read the book mentioned above - there's plenty in there for inspiration. ```r library(vembedr) vembedr::embed_youtube("OwKj-wgXteo") ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-09-24-exploration-vs-exploitation-tradeoff/) on my blog. --- # Harnessing Bayesian analysis for business process simulation URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/bayesian-simulation Date: 2023-09-17 Tags: bayesian-statistics, simulation, python Original post: https://blog-about-people-analytics.netlify.app/posts/2023-09-17-bayesian-simulation/ One of the advantages of doing statistical analysis in a Bayesian framework is that its generative part makes it a natural fit for business process simulation, which incorporates all the uncertainties inherent in the statistical models we use to capture the patterns of interest. Once the parameters of the models have been estimated, their corresponding posterior distributions can be easily sampled and used in combination with a range of input values to simulate the expected outcomes, including the associated uncertainty that needs to be taken into account when making decisions. To illustrate, imagine, for example, that as a CHRO you want to estimate the number of potential new employees brought in by employees who choose to participate in a new referral program. Fortunately, you have data from a small, three-month pilot of this program in which you offered participation to a small random sample of employees. Using a combination of simple Binomial and Poisson models, you can easily arrive at a reasonable estimate of the outcome of interest when you introduce the program to a larger portion of the company. Lets' implement this simple illustrative example with [PyMC](https://www.pymc.io/welcome.html), a probabilistic programming library for Python that allows users to build Bayesian models with a simple Python API and fit them using Markov chain Monte Carlo (MCMC) methods. First, let's upload the data from the pilot program. The first table includes 150 employees who were randomly selected and offered participation in the pilot program. The second table then shows 42 employees who chose to participate in the program and the number of potential new employees they brought in. ```python import pandas as pd # table with all pilot nominees nominees=pd.read_excel("./dataBayesSim.xlsx", sheet_name="nominees") # table with all pilot participants participants=pd.read_excel("./dataBayesSim.xlsx", sheet_name="participants") # showing first few rows of the tables nominees.head(5) participants.head(5) ``` In order to estimate the expected number of new potential employees after the introduction of a new referral program to a larger part of the company, we want to model the probability that nominees actually participate in the program and the expected number of potential candidates that a participant brings in. To do this, we can fit Binomial and Poisson models to the data, respectively. As mentioned above, we will do this in a Bayesian framework that will make it easier to deal with uncertainty later in our simulation. A side note: for the sake of brevity, I omit the usual sanity checks that should be performed before drawing any conclusions from fitted models - e.g., checking for convergence of Markov chains or posterior predictive checks for how well the fitted models predict observed data. Let's start with the first model. From the summary below, we see that the estimated probability of participating in the programme is between 0.21 and 0.35. ```python import pymc as pm # estimating the participation rate nominated = nominees.shape[0] participated = nominees['participation'].sum() # setting up the model with pm.Model() as participationModel: # assigning a flat Beta prior for p p = pm.Beta("p", alpha=1, beta=1) # defining likelihood obs = pm.Binomial("obs", p=p, n=nominated, observed=participated) # running mcmc idata = pm.sample(3000, tune=500, chains=3, cores=1) # generating posterior predictive sample participationModelPosterior = pm.sample_posterior_predictive(idata, extend_inferencedata=True) ``` ```python import arviz as az # trace plot showing the evolution of parameter vector over the iterations of Markov chain(s) #az.plot_trace(idata, kind="trace", divergences="bottom", show=True) # posterior predictive check #az.plot_ppc(participationModelPosterior, num_pp_samples=500, random_seed=7, alpha=0.3, textsize=14, kind='kde', show=True) # tabular and visual summary of the posterior probability distribution of the p parameter value az.summary(idata).round(2) az.plot_posterior(idata, hdi_prob=.95, show=True) ``` The second model then suggests that program participants brought in an average of 1.7 to 2.5 referrals. ```python # setting up the Poisson model with pm.Model() as referralModel: # weakly informative exponential prior for lambda parameter with mean 3 lambda_ = pm.Exponential('lambda', 1/3) # alternative flat prior for lambda parameter #lambda_ = pm.Uniform('lambda', lower=0, upper=25) # Poisson likelihood y_obs = pm.Poisson('y_obs', mu=lambda_, observed=participants['referrals']) # running mcmc trace = pm.sample(3000, tune=500, chains=3, cores=1) # generating posterior predictive sample referralModelPosterior = pm.sample_posterior_predictive(trace, extend_inferencedata=True) ``` ```python # trace plot showing the evolution of parameter vector over the iterations of Markov chain(s) #az.plot_trace(trace, kind="trace", divergences="bottom", show=True) # posterior predictive check #az.plot_ppc(referralModelPosterior, num_pp_samples=500, random_seed=7, alpha=0.3, textsize=14, kind='kde', show=True) # tabular and visual summary of the posterior probability distribution of the p parameter value az.summary(trace).round(2) az.plot_posterior(trace, hdi_prob=.95, show=True) ``` We can now sample the posterior distributions of the parameters `p` and `lambda`, insert them into the dataframe, and for each row/combination calculate the expected number of referrals brought in by participating employees when the program is rolled out to the entire population of 1500 employees. As you can see below, using the IQR, our CHRO can expect recruiters to reach 790 to 990 potential candidates once the new company-wide referral program is in place. ```python import numpy as np import matplotlib.pyplot as plt import seaborn as sns sns.set_theme(style="white") # sampling from the posterior distribution the parameters p and lambda posterior = pd.DataFrame({ 'p': idata['posterior']['p'].values.flatten(), 'lambda': trace['posterior']['lambda'].values.flatten() }) # computing expected number of referrals with 1500 nominees posterior['expectedReferrals'] = 1500*posterior['p']*posterior['lambda'] # computing summary statistics m = posterior['expectedReferrals'].mean().round(1) Q1 = np.percentile(posterior['expectedReferrals'], 25).round(1) Q2 = np.percentile(posterior['expectedReferrals'], 50).round(1) Q3 = np.percentile(posterior['expectedReferrals'], 75).round(1) # visualizing results sns.histplot(posterior['expectedReferrals'], bins=30, kde=True, color='#5b7db6').set(xlabel ="Number of new referrals", ylabel = "Count") plt.gcf().suptitle('Expected referrals for program rollout to all 1500 employees', fontsize=13) plt.gca().set_title(f'Mean={m}, Q1={Q1}, Median={Q2}, Q3={Q3}', fontsize=10) plt.show() ``` ![](./bayesian-simulation/unnamed-chunk-6-5.png) And it doesn't have to end there. For example, this estimate can be combined with other inputs, e.g. the cost of a new referral program, the cost of an alternative solution, etc., to make a better informed decision, taking into account the existing uncertainty. ## Figures ![](./bayesian-simulation/unnamed-chunk-3-1.png) ![](./bayesian-simulation/unnamed-chunk-5-3.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-09-17-bayesian-simulation/) on my blog. --- # A plausible model of data-generating process eats ML algorithms for breakfast URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/dag-and-double-ml Date: 2023-09-03 Tags: causal-inference, double-machine-learning, python Original post: https://blog-about-people-analytics.netlify.app/posts/2023-09-03-dag-and-double-ml/ I'm just in the middle of studying the use of meta-learners for causal inference, i.e. how to repurpose conventional ML models to estimate treatment effect. One of the lessons I took away from this study so far is that no matter how fancy the ML algorithm we use, without a plausible model of the data-generating process, we cannot hope for an unbiased estimate of the treatment effect as without it, it is difficult to select relevant covariates and avoid those that introduce bias into our estimation (e.g. colliders, mediators and their descendants). To illustrate and reinforce this lesson for myself, I coded a small example of estimating the average treatment effect of training performance on productivity using synthetic data with a known data-generating process and [the Double ML](https://towardsdatascience.com/double-machine-learning-for-causal-inference-78e0c6111f9d) method. First, we create a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph) of the causal relationships behind our data. We see that, according to this DAG, employee productivity is affected by years of experience, job fit, available resources, and training performance, which is our variable of interest. Employee satisfaction is also part of the DAG, which is affected by available resources, employee productivity, and training performance. ```python import networkx as nx import matplotlib.pyplot as plt # initializing DAG G = nx.DiGraph() # adding nodes nodes = ['Years of experience', 'Resources', 'Job_fit', 'Training', 'Productivity', 'Satisfaction', 'Cognitive ability'] G.add_nodes_from(nodes) # adding edges edges = [('Years of experience', 'Productivity'), ('Cognitive ability', 'Productivity'), ('Cognitive ability', 'Training'), ('Resources', 'Productivity'), ('Resources', 'Satisfaction'), ('Job_fit', 'Productivity'), ('Training', 'Productivity'), ('Training', 'Satisfaction'), ('Productivity', 'Satisfaction')] G.add_edges_from(edges) # drawing DAG pos = nx.fruchterman_reingold_layout(G, seed=5, iterations = 500, k = 2) labels = {node: node for node in G.nodes()} nx.draw(G, pos, with_labels=True, labels=labels, node_color='lightblue', font_weight='bold', node_size=1500, font_size=10) plt.title("DAG") plt.show() ``` ![](./dag-and-double-ml/unnamed-chunk-1-1.png) Now let’s generate synthetic data corresponding to this DAG. ```python import numpy as np import pandas as pd # generating synthetic data np.random.seed(42) n = 1000 # generating features years of experience, resources, job fit, and cognitive ability X = np.random.normal(0, 1, (n, 4)) # setting the true causal effect of training performance true_causal_effect = 3.5 # training is influence by cognitive ability training = 1.8 * X[:, 3] + np.random.normal(0, 1, n) # productivity is influenced by all features productivity = 2 * X[:, 0] + 1 * X[:, 1] + 0.5 * X[:, 2] + 2.2 * X[:, 3] + true_causal_effect * training + np.random.normal(0, 1, n) # defining an employee satisfaction variable that is influenced by resources, productivity, and training satisfaction = 0.3 * X[:, 1] + 0.2 * productivity + 1.3 * training + np.random.normal(0, 1, n) # creating a final dataFrame df = pd.DataFrame(X, columns=['years_of_experience', 'resources', 'job_fit', 'cognitive_ability']) df['training'] = training df['productivity'] = productivity df['satisfaction'] = satisfaction ``` Without DAG, i.e., without understanding the data-generating process behind our data, we might be tempted to "throw" all available variables into our estimator. We can give it a try and use Double ML for that - a popular framework designed to provide unbiased and consistent estimates of treatment effects in the presence of high-dimensional controls while reducing the risk of overfitting. The common Double ML procedure looks as follows: 1. Randomly splitting the data into two parts: one for estimating the control function and the other for estimating the treatment effect. 2. Using the first part of the data to train a machine learning model to predict the outcome variable based on covariates (without the treatment variable). Similarly, training another model to predict the treatment variable based on covariates. 3. Using the second part of the data to form the residuals for outcome and treatment variable and running a simple linear regression of outcome residuals on treatment residuals. 4. Repeating steps 1-3 but switching the roles of the two data splits and averaging the estimates to get the final average treatment estimate. Let's apply this approach to our data and use XGBoost models to estimate the control function and see how successful we will be in our efforts to estimate the known causal effect of training on productivity. ```python import numpy as np from xgboost import XGBRegressor from sklearn.linear_model import LinearRegression # creating a variable for data splits np.random.seed(42) df['part'] = np.random.choice([0, 1], size=len(df)) estimates = [] for p in [0,1]: # auxiliary variables for switching the roles of the two data splits firstPart = 1-p secondPart = p-0 # used covariates covariates = ['years_of_experience', 'resources', 'job_fit', 'cognitive_ability', 'satisfaction'] # preparing datasets X_First = df.loc[df['part'] == firstPart, covariates].values y_Outcome_First = df.loc[df['part'] == firstPart,'productivity'].values y_Treatment_First = df.loc[df['part'] == firstPart,'training'].values X_Second = df.loc[df['part'] == secondPart, covariates].values y_Outcome_Second = df.loc[df['part'] == secondPart,'productivity'].values y_Treatment_Second = df.loc[df['part'] == secondPart,'training'].values # controlling for covariates for productivity using XGBoost model_Outcome = XGBRegressor(eta = 0.1, n_estimators =25) model_Outcome.fit(X_First, y_Outcome_First) residual_Outcome = y_Outcome_Second - model_Outcome.predict(X_Second) # controlling for covariates for training using XGBoost model_Treatment = XGBRegressor(eta = 0.1, n_estimators =25) model_Treatment.fit(X_First, y_Treatment_First) residual_Treatment = y_Treatment_Second - model_Treatment.predict(X_Second) # Part 3: Estimate the causal effect using the residuals with Linear Regression model_causal = LinearRegression() model_causal.fit(residual_Treatment.reshape(-1, 1), residual_Outcome) double_ml_effect = model_causal.coef_[0] estimates.append(double_ml_effect) print("The estimated Average Treatment Effect is:", sum(estimates)/len(estimates)) ``` We see that the estimated causal effect of training is approximately 2.9, quite far from the known value of 3.5. How so? Well, if you look at the DAG above, you can see that by using all the variables as covariates, not only have we correctly blocked the backdoor by controlling for employee cognitive ability, but we have also introduced bias into our estimation by controlling for satisfaction, which is a collider - a variable that is affected by both the outcome (productivity) and the treatment (training). And controlling for colliders leads to spurious correlations between variables or, as in our case, deflates the size of the estimated effect. We can easily check this using [DAGitty](https://www.dagitty.net/), which is a wonderful browser-based environment for creating and analysing causal diagrams. ![](./dag-and-double-ml/dag.png) So let's repeat the estimation, but now without satisfaction variable as covariate. As you can see below, we are now much closer to the actual causal effect. ```python # creating a variable for data splits np.random.seed(42) df['part'] = np.random.choice([0, 1], size=len(df)) estimates = [] for p in [0,1]: # auxiliary variables for switching the roles of the two data splits firstPart = 1-p secondPart = p-0 # used covariates covariates = ['years_of_experience', 'resources', 'job_fit', 'cognitive_ability'] # preparing datasets X_First = df.loc[df['part'] == firstPart, covariates].values y_Outcome_First = df.loc[df['part'] == firstPart,'productivity'].values y_Treatment_First = df.loc[df['part'] == firstPart,'training'].values X_Second = df.loc[df['part'] == secondPart, covariates].values y_Outcome_Second = df.loc[df['part'] == secondPart,'productivity'].values y_Treatment_Second = df.loc[df['part'] == secondPart,'training'].values # controlling for covariates for productivity using XGBoost model_Outcome = XGBRegressor(eta = 0.1, n_estimators =25) model_Outcome.fit(X_First, y_Outcome_First) residual_Outcome = y_Outcome_Second - model_Outcome.predict(X_Second) # controlling for covariates for training using XGBoost model_Treatment = XGBRegressor(eta = 0.1, n_estimators =25) model_Treatment.fit(X_First, y_Treatment_First) residual_Treatment = y_Treatment_Second - model_Treatment.predict(X_Second) # Part 3: Estimate the causal effect using the residuals with Linear Regression model_causal = LinearRegression() model_causal.fit(residual_Treatment.reshape(-1, 1), residual_Outcome) double_ml_effect = model_causal.coef_[0] estimates.append(double_ml_effect) print("The estimated Average Treatment Effect is:", sum(estimates)/len(estimates)) ``` Despite the simplicity of the example presented, I think it nicely demonstrates that by using fancy ML algorithms like XGBoost, we are not relieved of the need to have a plausible model of the data-generating process when estimating causal effects from observational data, and we can't just blindly rely on some magical powers of ML to squeeze what we need out of whatever input we give it. Just a side note: If you want to make your life a little bit easier when using Double ML, you can use [the DoubleML library for Python and R](https://docs.doubleml.org/stable/index.html). The code below illustrates this library in action on our synthetic data. ```python from doubleml import DoubleMLData, DoubleMLPLR np.random.seed(42) # specifying data and roles of individual variables dml_data = DoubleMLData(df, y_col='productivity', d_cols='training', x_cols=['years_of_experience', 'resources', 'job_fit', 'cognitive_ability']) # specifying ML model(s) used for estimation of the nuisance parts ml_xgb = XGBRegressor(eta = 0.1, n_estimators =25) # initializing and parametrizing the model object which will be used to perform the estimation dml_plr_xgb = DoubleMLPLR( dml_data, ml_l = ml_xgb, ml_m = ml_xgb, n_folds = 5, n_rep = 10, score = 'partialling out', dml_procedure = 'dml2') # estimation and inference dml_plr_xgb.fit() dml_plr_xgb.summary dml_plr_xgb.confint() ``` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-09-03-dag-and-double-ml/) on my blog. --- # Embedding R (or Python) ML models in Power BI dashboards URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/r-and-power-bi Date: 2023-08-22 Tags: data-visualization, r, python, machine-learning Original post: https://blog-about-people-analytics.netlify.app/posts/2023-08-22-r-and-power-bi/ Note to start: Although the example presented in this post is demonstrated in R, it could be analogously implemented in Python as well. For demonstration, I will use the well-known [IBM artificial attrition dataset](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset). First, we need to train the model. I suppose you know the drill and know all the steps to go through to get a useful and reliable model. The following R script implements the whole process of data preparation, (XGBoost) model tuning, training, and validation. After we have the prediction model ready, we have to save it, as we will use it later in the Power BI (PBI) dashboard. ```r # uploading libraries library(rsample) library(modeldata) library(tidyverse) library(recipes) library(themis) library(parsnip) library(workflows) library(tune) library(yardstick) # uploading data data("attrition") # changing the coding of the criterion variable attrition <- attrition %>% mutate(Attrition = recode(Attrition, "Yes" = "1", "No" = "0") %>% factor(levels = c("1", "0"))) # splitting data into train, validation, and test datasets set.seed(123) data_split <- rsample::initial_split(attrition, strata = Attrition, prop = 0.8) data_train <- rsample::training(data_split) data_test <- rsample::testing(data_split) data_val <- rsample::validation_split(data_train, strata = "Attrition", prop = 0.8) # defining recipe for adjusting data for fitting the model fmla <- as.formula(paste("Attrition", " ~ .")) xgb_recipe <- recipes::recipe(fmla, data = data_train) %>% recipes::step_ordinalscore(recipes::all_ordered_predictors()) %>% recipes::step_dummy(recipes::all_factor_predictors()) %>% themis::step_smote(Attrition, over_ratio = 1) # defining the model xgb_model <- parsnip::boost_tree(mtry = tune(), min_n = tune(), tree_depth = tune(), trees = 1000) %>% parsnip::set_engine("xgboost") %>% parsnip::set_mode("classification") xgb_workflow <- workflows::workflow() %>% workflows::add_model(xgb_model) %>% workflows::add_recipe(xgb_recipe) # tuning hyper-parameters set.seed(123) xgb_tuning <- xgb_workflow %>% tune::tune_grid( data_val, grid = 25, control = control_grid(save_pred = TRUE), metrics = yardstick::metric_set(roc_auc) ) # selecting the best combination of hyper-parameters xgb_best <- xgb_tuning %>% tune::select_best(metric = "roc_auc") # best model performance on validation dataset as measured by AUC ( xgb_tuning %>% tune::collect_predictions(parameters = xgb_best) %>% yardstick::roc_auc(truth = Attrition, .pred_1) ) # plotting the ROC curve xgb_tuning %>% tune::collect_predictions(parameters = xgb_best) %>% yardstick::roc_curve(truth = Attrition, .pred_1) %>% autoplot() # setting the final model final_xgb_model <- parsnip::boost_tree(mtry = xgb_best$mtry, min_n = xgb_best$min_n, tree_depth = xgb_best$tree_depth, trees = 1000) %>% parsnip::set_engine("xgboost") %>% parsnip::set_mode("classification") # updating the model training workflow final_xgb_workflow <- xgb_workflow %>% workflows::update_model(final_xgb_model) # fitting model on train set set.seed(123) xgb_fit <- final_xgb_workflow %>% parsnip::fit(data_train) # variable importance xgb_fit %>% tune::extract_fit_parsnip() %>% vip::vip(num_features = 10, geom = "col") # checking the final model's performance (AUC) using k-fold cross-validation set.seed(123) folds <- rsample::vfold_cv(data_train, v = 10) set.seed(123) xgb_fit_kf <- final_xgb_workflow %>% tune::fit_resamples(folds) ( tune::collect_metrics(xgb_fit_kf, summarize = TRUE) %>% dplyr::filter(.metric == "roc_auc") ) # checking the final model's performance (AUC) using the testing dataset xgb_testing_pred <- predict(xgb_fit, data_test) %>% bind_cols(predict(xgb_fit, data_test, type = "prob")) %>% dplyr::bind_cols(data_test %>% select(Attrition)) ( xgb_testing_pred %>% yardstick::roc_auc(truth = Attrition, .pred_1) ) # saving the final model saveRDS(xgb_fit, "./final_xgb_model.RDS") # saving testing data as a new dataset that will be used in Power BI dashboard data_test %>% dplyr::select(-Attrition) %>% writexl::write_xlsx("./newData.xlsx") ``` Now we can move on to PBI. We use the testing data as a new dataset that we will score by the trained model and show stakeholders how the predicted flight risk varies by department, job role, and gender. First, we should check in the PBI settings that PBI has access to our R (or Python) instance (`File` -> `Options and setting` -> `GLOBAL/R scripting/Python scripting`). If so, we can upload new data using the `Get data` dialog and go to the `Transform` tab in the Query editor. Here, we should first check that the data types match those in R when preparing the model, and then we can load and run our scoring algorithm using the `Run R script` dialog. The following script will do the job. ```r # 'dataset' holds the input data for this script # uploading libraries library(tidyverse) library(workflows) # uploading trained model model <- readRDS("PathTo/YourModel.RDS") # generate predictions prediction <- predict(model, dataset) %>% dplyr::bind_cols(predict(model, dataset, type = "prob")) %>% dplyr::rename( predictedCat = .pred_class, probLeave = .pred_1, probStay = .pred_0 ) # enriching original dataset prediction <- cbind(dataset, prediction) ``` After running the script, our original data will be enriched with the generated predictions. We can apply this transformation, save it and exit the Query editor using the `Close & Apply` button. We will then have flight risk predictions that we can visualize along with the original data, allowing us to see how predicted flight risk varies by department, job role, and gender, among other things. Again, we could use R or Python for this purpose, as PBI does not offer some types of data visualization by default, especially those related to visualizing data variability. For example, to create the raincloud plots below, I used the `ggplot2` and `ggdist` packages in R. Below is a script used within the R visual that implements one of the plots. To fine-tune them, I recommend using RStudio (if you have it on your computer), which is accessible directly from the R visual editor in PBI. ```r # 'dataset' holds the input data for this script # dataset <- data.frame(probLeave, Department) # uploading libraries library(tidyverse) library(ggdist) dataset %>% ggplot2::ggplot(aes(x = forcats::fct_reorder(Department, probLeave, mean, .na_rm = TRUE), y = probLeave)) + ggdist::stat_halfeye( adjust = .5, width = .6, .width = 0, justification = -.3, point_colour = NA, fill = "#bca36b", color = "#bca36b" ) + ggplot2::geom_boxplot( width = .25, outlier.shape = NA ) + ggplot2::stat_summary(fun.y=mean, geom="point", shape=20, size=5, color="#bca36b", fill="#bca36b") + geom_point( size = 1.3, alpha = .3, position = position_jitter(seed = 1, width = .09, height = .008 ) ) + ggplot2::coord_cartesian(xlim = c(1.2, NA), clip = "off") + ggplot2::coord_flip() + ggplot2::labs( x = "", y = "PROBABILITY OF LEAVING" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), strip.text.x = element_text(size = 11, face = "plain"), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_blank(), legend.position=c(.95,.88), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_blank(), plot.margin=unit(c(0,0,0,0),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./r-and-power-bi/screen1.png) We may also want to allow dashboard users to enter custom values for specific personas of interest and let the scoring algorithm predict the corresponding risk of leaving the company. To make the demonstration of this feature somewhat easier, I will use a different prediction model that uses only some of the strongest predictors. To enable this feature, we need to create several parameters that the user can set. For numeric predictors we can use the `New parameter` dialog box on the `Modeling` tab - we set the name of the parameters, their data type, their minimum, maximum, and default values, and confirm we want to show corresponding sliders in the dashboard. For categorical parameters, we need to create and then upload a table with all possible combinations of values of the categorical parameters used. When using R, we can use for example the `expand.grid` function to do this. The fields from this table are then used and displayed in the dashboard as single-selection filters. And all these parameters then serve as input to the R visual, where we load the model, change the names of some parameters to match those expected by the model, generate a prediction, and create the resulting visualization. All these steps are implemented by the following R script. ```r # 'dataset' holds the input data for this script # dataset <- data.frame(JobLevelParameter Value, YearsWithCurrManagerParameter Value, OverTime, Department, JobSatisfaction) # uploading libraries library(tidyverse) library(workflows) data <- dataset %>% dplyr::rename( JobLevel = `JobLevelParameter Value`, YearsWithCurrManager = `YearsWithCurrManagerParameter Value` ) # uploading trained model model <- readRDS("PathTo/YourModel2.RDS") # generate predictions prediction <- predict(model, data) %>% dplyr::bind_cols(predict(model, data, type = "prob")) %>% dplyr::rename( predictedCat = .pred_class, probLeave = .pred_1, probStay = .pred_0 ) # enriching original dataset prediction <- cbind(data, prediction) prediction %>% ggplot2::ggplot(aes(x = 1, y = probLeave)) + ggplot2::geom_bar(stat = "identity", fill = "#444492") + ggplot2::coord_flip() + ggplot2::geom_text(aes(label = round(probLeave, 2)), nudge_y = 0.04, color = "black", size = 7, fontface = "bold") + scale_y_continuous(limits = c(0,1.08), breaks = seq(0,1,0.1)) + labs( x = "", y = "PROBABILITY OF LEAVING" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_blank(), strip.text.x = element_text(size = 11, face = "plain"), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_blank(), legend.position=c(.95,.88), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_blank(), plot.margin=unit(c(0,0,0,0),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./r-and-power-bi/screen2.png) This completed the work on our local computer. You can download the final dashboard [here](attritionPredictionModel.pbix). If you use it, be sure to update the paths to the trained model in both the Query Editor and the R visuals. The next step is to set up the Power BI Service and install and configure the on-premises data gateway so that the R scripts will work in dashboards shared with others. More on this in a future blog post. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-08-22-r-and-power-bi/) on my blog. --- # Vocational interests don't seem so uninteresting after all URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/vocational-interests Date: 2023-07-20 Tags: career-development, meta-analysis, validity, performance Original post: https://blog-about-people-analytics.netlify.app/posts/2023-07-20-vocational-interests/ To be honest, until recently, I tended to underestimate the importance of vocational interests in job performance prediction and considered questions about them during a job interview as a formality. In my defense, this view has also been supported by the low estimates of their predictive validity reported by classics such as [Schmidt & Hunter (1998)](https://psycnet.apa.org/record/1998-10661-006). However, I adjusted my view after coming across the updated validity estimate in the [Sackett et al. meta-analysis (2022)](https://psycnet.apa.org/record/2022-17327-001) and the results of the [Nye et al. meta-analysis (2017)](https://psycnet.apa.org/record/2016-62203-013) on the validity of interests for predicting job performance. The latter study reported the following interesting findings: * Correlation between interest scores and job performance (corrected for both indirect range restriction and unreliability in the criterion) is 0.16 (SE=0.03). ![](./vocational-interests/scheme2.png) * Interest congruence/match between an individual's interests and his or her work is a much stronger predictor of performance outcomes than interest scores alone, with baseline correlations of 0.32 and 0.16, respectively. ![](./vocational-interests/scheme1.png) * Interests are significantly better predictors of organizational citizenship behavior than other criteria (job performance, task performance, OCB, persistence, CWB, and training performance) but are less valid for predicting CWB and task performance. If you tend to think about vocational interests as I have until recently, perhaps these two studies will help you update your priors a little bit 😉 Note: The attached schemes are taken from another excellent resource on this topic by [Nye et al. (2012)](https://psycnet.apa.org/record/2012-17649-006). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-07-20-vocational-interests/) on my blog. --- # Induced centralities URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/induced-centrality Date: 2023-07-14 Tags: network-analysis, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-07-14-induced-centrality/ While reading the excellent publication [Social Networks at Work](https://www.amazon.com/Social-Networks-Work-Organizational-Frontiers/dp/1138572632) from SIOP's Organizational Frontiers series (btw, highly recommended to all PA professionals), I came across the interesting and useful concept of induced centrality. It sort of reverses the logic of the [common ONA centrality measures](https://cambridge-intelligence.com/keylines-faqs-social-network-analysis/), which focus primarily on what one gets from the surrounding network of connections, and instead shows how individual nodes contribute to some global network characteristic of interest, i.e. what one does for the network as a whole. Its calculation is quite simple and straightforward - you just need to first calculate the global characteristic of the network that you are interested in as a reference point, e.g. its coherence, and then calculate how this measure changes when you remove individual nodes from the network. From this, you can deduce that the nodes that cause the most change in a specific direction contribute the most to a given measure. In addition to its versatility and the interesting angle it offers, it can also be very useful in making visible otherwise hidden and invisible "heroes" who contribute to the greater good under the radar of public recognition. What follows is a small demonstration of using induced centrality to estimate which people play the role of expressive leaders who shorten the lengths of paths in the network. It’s an implementation of the idea briefly described in the aforementioned publication Social Networks at Work: *“For example, suppose one theorizes that there are certain individuals in groups (perhaps called expressive leaders) who provide a certain social glue such that they tend to shorten the lengths of paths in the network (see, for example, the Heidi Roizen case by McGinn and Tempest, 2010). This sounds like we should use closeness centrality, since it is concerned with path lengths. But there are two problems with this. First of all, closeness centrality only counts the shortest paths, and not the circuitous paths that things such as gossip often take. Second, closeness gets at how long it takes for information to reach a given node, who is then presumed to benefit from this information. But the concept we’ve just outlined is about individuals who enable others to have short paths so that the whole group benefits. Closeness was not designed to measure this, and doesn’t. However an induced centrality measure can be created to measure exactly this: to what degree paths lengthen when you remove each node from the network.”* First, let’s upload the data used for the demonstration and create the network object. I will use a dataset that captures information-sharing links between 15 members of my friendship network. ```r # uploading libraries library(readxl) library(igraph) library(ggraph) # uploading data df <- readxl::read_excel("./friendshipNetwork.xlsx") # creating network object g <- igraph::graph_from_data_frame(df, directed=TRUE) ``` We can now iterate over all directed pairs of nodes and compute the average length of paths between nodes we will use as a reference point. We won’t use all the paths but only the three shortest paths between each pair of nodes that would enable us to capture some of the circuitous paths mentioned in the problem description above. ```r all_nodes <- V(g) total_length <- 0 total_paths <- 0 # setting the number of 3 shortest paths between pair of nodes to capture also some of the circuitous paths top_shortest_paths <- 3 # loop for directed network for (i in 1:length(all_nodes)) { for (j in 1:length(all_nodes)) { if (i != j) { lengths <- unlist(igraph::all_simple_paths(g, all_nodes[i], all_nodes[j], mode = "out")) lengths <- sort(lengths, decreasing = FALSE)[1:top_shortest_paths] total_length <- total_length + sum(lengths, na.rm = TRUE) total_paths <- total_paths + length(lengths) } } } # loop for undirected network # for (i in 1:(length(all_nodes) - 1)) { # for (j in (i + 1):length(all_nodes)) { # lengths <- all_simple_paths(g, all_nodes[i], all_nodes[j]) # lengths <- sort(lengths, decreasing = FALSE)[1:top_shortest_paths] # total_length <- total_length + sum(lengths, na.rm = TRUE) # total_paths <- total_paths + length(lengths) # } # } # computing the average length of paths average_length_ref <- total_length / total_paths ``` Now let’s remove each node one at a time from the network and calculate the average lengths of paths between pairs consisting of the remaining nodes. We also need to deal somehow with situations when node removal leads to the disconnection of previously connected nodes (in such situations, the distance between nodes is by default assumed to be infinite or undefined, which would bias our estimation). I have decided to take the three shortest paths from the complete network and add 1 (this is somewhat equivalent to the additional effort required to find a new bonding connection). After this step, we can subtract the reference point from the obtained values and get the information about the absence of which nodes lengthens the paths between other nodes and thus act as a kind of social glue that facilitates the spread of information between nodes. ```r # vector for saving average lengths of paths for individual nodes average_lengths <- numeric(length(all_nodes)) for (k in 1:length(all_nodes)) { g_new <- g g_new <- igraph::delete_vertices(g_new, all_nodes[k]) all_nodes_new <- V(g_new) total_length_new <- 0 total_paths_new <- 0 # for directed network for (i in 1:length(all_nodes_new)) { for (j in 1:length(all_nodes_new)) { if (i != j) { # lengths of paths in the network with removed node lengths_new <- unlist(igraph::all_simple_paths(g_new, all_nodes_new[i], all_nodes_new[j], mode="out")) # lengths of paths in the complete network lengths <- unlist(igraph::all_simple_paths(g, all_nodes_new[i]$name, all_nodes_new[j]$name, mode="out")) # dealing with situations when node removal leads to disconnection of previously connected nodes by taking 3 shortest paths from the full network and adding 1 if(is.null(lengths_new) & !is.null(lengths)){ lengths_new <- sort(lengths, decreasing = FALSE)[1:top_shortest_paths] lengths_new <- lengths_new + 1 } else{ lengths_new <- sort(lengths_new, decreasing = FALSE)[1:top_shortest_paths] } total_length_new <- total_length_new + sum(lengths_new, na.rm = TRUE) total_paths_new <- total_paths_new + length(lengths_new) } } } # for undirected network # for (i in 1:(length(all_nodes_new) - 1)) { # for (j in (i + 1):length(all_nodes_new)) { # lengths_new <- length_of_all_paths(g_new, all_nodes_new[i], all_nodes_new[j]) # lengths <- length_of_all_paths(g, all_nodes_new[i]$name, all_nodes_new[j]$name) # if(is.null(lengths_new) & !is.null(lengths)){ # lengths_new <- sort(lengths, decreasing = FALSE)[1:top_shortest_paths] # lengths_new <- lengths_new + 1 # } else{ # lengths_new <- sort(lengths_new, decreasing = FALSE)[1:top_shortest_paths] # } # total_length_new <- total_length_new + sum(lengths_new, na.rm = TRUE) # total_paths_new <- total_paths_new + length(lengths_new) # } # } average_lengths[k] <- total_length_new / total_paths_new } # computing the difference between average and reference point average_length_diff <- average_lengths - average_length_ref # assigning computed differences to individual nodes V(g)$avg_length_diff <- average_length_diff ``` The graph below shows that nodes P2, P8, and P4 are the most critical in this respect. ```r ggraph::ggraph(g, layout = "kk") + # other available layouts: 'star', 'circle', 'gem', 'dh', 'graphopt', 'grid', 'mds', 'randomly', 'fr', 'kk', 'drl', 'lgl' ggraph::geom_edge_link(arrow = arrow(length = unit(2.5, 'mm')), end_cap = circle(2, 'mm')) + ggraph::geom_node_point(aes(size = avg_length_diff), alpha = 1, color = ifelse(V(g)$avg_length_diff>0, "#e15759", "black")) + ggplot2::scale_size_continuous(range = c(0.1,8)) + ggraph::geom_node_label(aes(label = name), repel = TRUE) + ggplot2::labs( title = "Expressive leaders who shorten path lengths in the network", subtitle = "Demonstration of the concept of induced centrality", size = "Increase in average path length after node removal", caption = "\nNodes with an increase greater than 0 are highlighted in red." ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 21, margin=margin(0,0,9,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title = element_blank(), axis.text = element_blank(), strip.text.x = element_text(size = 11, face = "plain"), axis.line = element_blank(), legend.position="bottom", legend.key = element_rect(fill = "white"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ## Figures ![](./induced-centrality/unnamed-chunk-4-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-07-14-induced-centrality/) on my blog. --- # Searching & querying AIHR blog posts on People Analytics topics URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/searching-and-querying-aihr-blog-posts Date: 2023-07-11 Tags: people-analytics, embeddings, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2023-07-11-searching-and-querying-aihr-blog-posts/ It’s an app that allows you to quickly search for People Analytics topics in a database of almost 260 [AIHR blog posts](https://www.aihr.com/blog/) and get key ideas and insights from the posts that best match your search topic, with the possibility to go to the original post via a provided link. ![](./searching-and-querying-aihr-blog-posts/plot.png) The cluster analysis revealed that the posts cover more than 30 topics, so there's a lot to choose from. Feel free to give it a try and let me know how it works for you. Here's a [link to the app](https://lsanalytics.shinyapps.io/aihrPostsSearchInsights/#section-search-insights). Last but not least, a big "thank you" goes to AIHR and all the contributing authors for putting together such an amazing resource on People Analytics 🙏 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-07-11-searching-and-querying-aihr-blog-posts/) on my blog. --- # Using Bayesian shrinkage in reporting employee turnover URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/bayesian-shrinkage Date: 2023-07-03 Tags: employee-turnover, hr-metrics, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-07-03-bayesian-shrinkage/ It's a no-brainer that when comparing team performance on retention, the departure of one person in small teams with fewer members will have a more significant impact on turnover rates than it does in larger teams. However, this creates room for potential misinterpretation and inadequate actions. It's related to the well-known phenomenon of variation being more likely in smaller samples. Fans of Daniel Kahneman and Amos Tversky will probably recall the famous [cognitive bias of insensitivity to sample size](https://en.wikipedia.org/wiki/Insensitivity_to_sample_size) which occurs when people judge the probability of obtaining a statistic regardless of sample size. One way to reduce this effect is [Bayesian shrinkage](https://en.wikipedia.org/wiki/Shrinkage_(statistics)). This approach, which serves as a kind of [regularization](https://en.wikipedia.org/wiki/Regularization_(mathematics)), involves borrowing information from the overall company turnover rate to influence the turnover rate of smaller teams. It works by "shrinking" the turnover rate of smaller teams towards the company average, and thus creating a balance between the observed rate and the company average. It's not dissimilar to what one intuitively does when deciding what movie to watch or what restaurant to go to, when movies and restaurants vary widely in the number of ratings available. You can see this approach in action on the chart below. The smaller the team and the further its turnover rate is from the company-wide turnover rate (the vertical dashed line), the more the turnover rate estimate for that team is shifted towards the company-wide value (the distance between the red cross and the black dot) - see, for example, teams 4 and 6. For comparison, check teams 12 and 9 that don't show much of a shrinkage effect due to the big size and small distance from the company-wide turnover rate, respectively. ```r # uploading the necessary libraries library(tidyverse) # data manipulation and dataviz library(brms) # bayesian stats library(cmdstanr) # bayesian stats library(ggdist) # dataviz # creating artificial data # setting a seed for reproducibility set.seed(123) # number of teams nTeams <- 12 # generating team sizes ranging from 10 to 100 teamSizes <- sample(10:100, nTeams, replace = TRUE) # generating 'true' turnover rates from a beta distribution trueRates <- rbeta(nTeams, 2, 10) # for each team, simulating the number of employees who left numberLeft <- rbinom(nTeams, teamSizes, trueRates) # generating team IDs teamId <- as.character(1:nTeams) # creating the data frame teamsData <- data.frame(teamId, teamSizes, numberLeft) # fitting multilevel Bayesian logistic regression with wide, uninformative priors model <- brms::brm( numberLeft | trials(teamSizes) ~ 1 + (1 | teamId), data = teamsData, family = binomial(link = "logit"), prior = prior(normal(0, 10), class = "Intercept"), chains = 4, iter = 5000, control = list(adapt_delta = 0.95), seed = 123, backend = "cmdstanr", refresh = 0, silent = 2 ) # model summary #summary(model) #fixef(model) #ranef(model) # extracting the posterior samples #parnames(model) posteriorDraws <- as_draws_df(model, variable = "Intercept", regex = TRUE) %>% dplyr::select(-.draw, -.chain, -.iteration, -sd_teamId__Intercept) %>% tidyr::pivot_longer(cols = -b_Intercept, names_to = "teamId", values_to = "paramValues") %>% dplyr::mutate( teamId = stringr::str_extract(teamId, "\\d+"), team = stringr::str_glue("Team {teamId}"), logOdds = paramValues + b_Intercept, estimatedTR = exp(logOdds) / (1 + exp(logOdds)) ) %>% dplyr::left_join(teamsData %>% dplyr::select(teamId, teamSizes), by = "teamId") %>% dplyr::mutate( team = stringr::str_glue("{team} (n={teamSizes})"), team = forcats::fct_reorder(factor(team), as.numeric(teamId)) ) # computing fixed, population-level effect estimate fixedEffect <- 1 / (1 + exp(-1*fixef(model)[1])) # computing observed turnover rate by team observedRT <- teamsData %>% dplyr::mutate(team = stringr::str_glue("Team {teamId}")) %>% dplyr::mutate( observedRT = numberLeft/teamSizes, team = stringr::str_glue("{team} (n={teamSizes})"), team = forcats::fct_reorder(factor(team), as.numeric(teamId)) ) ``` ```r # dataviz ggplot2::ggplot(data = posteriorDraws, aes(x=estimatedTR, group = team)) + ggdist::stat_halfeye(.width = c(0.8, 0.95), point_interval = "median_hdi", fill = "skyblue", normalize = "groups") + ggplot2::geom_point(data = observedRT, aes(x = observedRT, y = 0, group = team), color = "red", inherit.aes = F, size = 2.5, shape=3, stroke = 1.5) + ggplot2::geom_vline(xintercept = fixedEffect, linetype = "dashed", color = "#2C2F46") + ggplot2::facet_wrap(~team, ncol = 2) + ggplot2::scale_x_continuous(breaks = seq(0,1,0.1)) + ggplot2::labs( y = "NORMALIZED DENSITY", x = "ESTIMATED TURNOVER RATE", title = "Using Bayesian shrinkage in reporting employee turnover", caption = "\nThe black solid lines represent the 80% and 95% credibility intervals, respectively. The black dot represents the median of the Highest Confidence Interval.\nThe vertical dashed line represents the fixed, population-level effect estimate. The red cross represents the observed turnover rate for a given team." ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 21, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 9), strip.text.x = element_text(size = 11, face = "plain"), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position="", panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./bayesian-shrinkage/unnamed-chunk-2-1.png) If you are trying to deal with this effect in your reporting practice, can you share the approach you use and serves you well? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-07-03-bayesian-shrinkage/) on my blog. --- # A bet on a new job URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-comparator Date: 2023-06-28 Tags: career-development, bayesian-statistics, r, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-28-job-comparator/ Being in the final phase of my new job search, I wanted to be able to aggregate the information and impressions I gathered during the hiring process, with all the uncertainties, to make the best decision possible. To do this, I put together a “back-of-the-envelope” calculation that combines, in a Bayesian way, the impressions one has of various aspects of the jobs one is applying for. ![](./job-comparator/dashboard.png) It works with several factors that research suggests are related to job satisfaction and that a person has the chance to estimate subjectively to some degree during the hiring process from job ads, interviews, sample tasks, company reviews from current or former employees on Glassdoor, etc. Specifically, it takes into account the following factors: * Salary * Job security * Work-life balance * Career progression * Organizational culture * Job content * Benefits * Relationships with supervisors * Relationships with colleagues One's task is to simply determine, based on the available information, the range of how much he or she can expect to be satisfied with these factors in a given job. The app then aggregates the evidence and estimates the expected overall level of job satisfaction, including a level of uncertainty that can provide a guide as to where the person should try to obtain some additional information to reduce this uncertainty. One can also adjust the weights of each factor based on one's personal preferences. If you have at least two job offers to choose from, you may find the app as useful as I did. Here’s a [link to the app](https://lsanalytics.shinyapps.io/jobComparator/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-28-job-comparator/) on my blog. --- # Themes in Deloitte's Global Human Capital Trends between 2011 and 2023 URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/deloitte-hc-trends-themes Date: 2023-06-27 Tags: future-of-work, generative-ai, r, python Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-27-deloitte-hc-trends-themes/ Aside from satisfying a simple curiosity, it was also a good opportunity to try out a nerdy combination of various cool DS tools: openAI’s embeddings for determining trends similarity, UMAP for dimensionality reduction, DBSCAN for cluster analysis, openAI’s chat completion for cluster summarization and naming, Plotly for interactive dataviz, Shiny for dashboarding, and Python and R for orchestrating it all. The result? The analysis revealed 13 distinct themes among the 118 specific trends: 1. Global Talent Management Strategies (23) 2. Leadership Development and Talent Management (16) 3. HR Transformation and Innovation (15) 4. Human Capital and Workforce Strategies (12) 5. Workforce Data and Analytics (10) 6. Cognitive Technologies and Workforce (8) 7. Employee-Centric Learning and Development (7) 8. Performance Management and Compensation (7) 9. Improving Employee Experience and Well-being (6) 10. Cloud Computing and HR Transformation (4) 11. Employee Engagement and Retention (4) 12. Diversity in Business Strategy (3) 13. Workplace Flexibility Strategies (3) ![](./deloitte-hc-trends-themes/plot.png) It's no wonder I've had dejavu feelings about some trends over the years, but that's why they are called trends, because they persist over time, right? 😉 If you would like to check the analysis output interactively and in greater detail, you can use [this simple dashboard](https://lsanalytics.shinyapps.io/trendsApp/). ![](./deloitte-hc-trends-themes/dashboard.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-27-deloitte-hc-trends-themes/) on my blog. --- # Team-level predictors of innovation at work URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/team-level-predictors-of-innovation Date: 2023-06-22 Tags: innovation, teams, meta-analysis, people-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-22-team-level-predictors-of-innovation/ At least this is suggested by an interesting [meta-analysis of team-level predictors of innovation at work](https://www.researchgate.net/publication/26762638_Team-Level_Predictors_of_Innovation_at_Work_A_Comprehensive_Meta-Analysis_Spanning_Three_Decades_of_Research) by Hülsheger, Anderson, & Salgado (2009). ```r # uploading library library(tidyverse) # uploading data with the results of the meta-analysis data <- readxl::read_xlsx("./metaAnalysisResults.xlsx") #dplyr::glimpse(data) data %>% ggplot2::ggplot(aes(x = forcats::fct_reorder(variable, rho), y = rho, group = area, color = area)) + ggplot2::geom_point(size = 3) + ggplot2::geom_errorbar(aes(ymin=l95, ymax=h95), width=.2, position=position_dodge(0.05), linewidth = 1) + ggplot2::geom_hline(yintercept = 0, linetype = "dashed", color = "grey") + ggplot2::coord_flip() + ggplot2::scale_color_manual(values = c("Team composition and structure"="#4e79a7", "Team process" = "#f28e2b")) + ggplot2::labs( x = "", y = "AVERAGE CORRECTED CORRELATION", title = "Team-Level Predictors of Innovation at Work", caption = "\nThe bars around the point estimates represent the 2.5% lower and 97.5% upper limits of the 95% confidence interval.\nSource: Hülsheger, U. R., Anderson, N., & Salgado, J. F. (2009). Team-level predictors of innovation at work: A comprehensive meta-analysis spanning three decades\nof research. Journal of Applied Psychology, 94(5), 1128–1145. https://doi.org/10.1037/a0015978", color = "" ) + ggplot2::theme( plot.title = ggtext::element_markdown(face = "bold", size = 18, margin=margin(0,0,10,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 10, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), strip.text.x = element_text(size = 13, face = "bold"), axis.line = element_line(colour = "#E0E1E6"), legend.position="top", legend.key = element_rect(fill = "white"), legend.text = element_text(size = 12), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./team-level-predictors-of-innovation/unnamed-chunk-1-1.png) If that's true, not sure whether it's good news or bad news for companies' innovation initiatives. Is it easier to change processes or team composition? I expect there will be a lot of "it depends" 😉 What do you think? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-22-team-level-predictors-of-innovation/) on my blog. --- # Latent Class Analysis of responses from employee surveys URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/latent-class-analysis Date: 2023-06-19 Tags: psychometrics, employee-survey, people-analytics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-19-latent-class-analysis/ I recently listened to a podcast about a very interesting study about [conspiracies and disinformation in Czech society](https://www.investigace.cz/pocitani-neduvery/), and one of the authors of the study, [Matous Pilnacek](https://www.linkedin.com/in/matous-pilnacek-218b1a91), a sociologist, spoke very enthusiastically and positively during the interview about the analytical possibilities offered by [Latent Class Analysis](https://en.wikipedia.org/wiki/Latent_class_model) (LCA). Not being a sociologist, among whom this tool is well-known, I was quite easily impressed and hooked 🙂 LCA allows probabilistic modeling of multivariate categorical data with the assumption that there are latent classes of people who are characterized by a common pattern of probabilities of responses to a set of questions on some categorical, e.g. Likert scale. LCA is thus a natural fit for identifying subgroups of people with similar work views. Compared to other methods used in this context, such as Factor Analysis, k-means, or hierarchical clustering, it has several advantages: * LCA can handle well categorical observed variables. * LCA estimates probabilities of class membership, so it inherently incorporates uncertainty about which class each individual belongs to. * LCA allows for meaningful analysis of missing answers or non-answers together with proper answers on a Likert scale. * LCA provides a framework (via information criteria like BIC, AIC, or likelihood ratio tests) for comparing models with different numbers of classes. * LCA’s output is intuitive and easy to interpret. What follows, is a a small demonstration of this tool on artificial employee survey data accompanying the book [Predictive HR Analytics: Mastering the HR Metric](https://www.amazon.com/Predictive-HR-Analytics-Mastering-Metric/dp/0749484446) by Edwards & Edwards (2019). It contains the survey responses of 832 employees on a 1 ‘strongly disagree’ to 5 ‘strongly agree’ response scale for a following set of statements. ```r # uploading libraries library(readxl) library(DT) library(tidyverse) # uploading legend to the data legend <- readxl::read_excel("./surveyResults.xls", sheet = "Legend") # user-friendly table with individual survey items DT::datatable( legend %>% dplyr::mutate(Scale = as.factor(Scale)), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` Before moving on to modeling, we first need to wrangle the data a bit - select the relevant variables, change their data type, and replace missing values with "Prefer Not to Say" reply category. ```r # uploading data data <- readxl::read_excel("./surveyResults.xls", sheet = "Data") # preparing the data for modeling mydata <- data %>% dplyr::select(-sex:-ethnicity) %>% dplyr::mutate_all(as.character) %>% replace(is.na(.), "Prefer Not to Say") %>% dplyr::mutate_all(factor, levels = c("1", "2", "3", "4", "5", "Prefer Not to Say")) ``` Now we can proceed with the modeling. For this we will use `poLCA` R package. One of the parameters to be set is the expected number of classes. To choose the right number, we need to fit several LCA models with different numbers of classes and, based on information criteria such as BIC or AIC, choose the model with the best balance between model complexity and good fit to the data. Here, I set the parameter to the best value I determined earlier. ```r # uploading library library(poLCA) # specifying and running the model set.seed(1234) lca_model <- poLCA::poLCA( cbind(ManMot1, ManMot2, ManMot3, ManMot4, ocb1, ocb2, ocb3, ocb4, aut1, aut2, aut3, Justice1, Justice2, Justice3, JobSat1, JobSat2, Quit1, Quit2, Quit3, Man1, Man2, Man3, Eng1, Eng2, Eng3, Eng4, pos1, pos2, pos3)~1, data = mydata, nclass = 4, nrep = 3, verbose = FALSE, graphs = FALSE ) ``` Let's check some of the outputs of the analysis: 1) probability of responses to individual items by people from different classes, ```r # looking at the model output lca_model ``` 2) probabilities of people belonging to individual classes, ```r # probabilities of belonging to individual classes (first 10 rows) head(lca_model$posterior, n = 10) ``` and 3) predicted belongings of people to the classes. ```r # predicted belongings to the classes lca_model$predclass # checking the size of the classes table(lca_model$predclass) ``` If we wanted to visualize the results using our own charts, for example, with full-stacked bar charts showing average probability of responses per scale and class, we need to do some data wrangling of information extracted from the fitted model. ```r # extracting information from the model for dataviz scaleNames <- mydata %>% names() classes <- 4 responsesDf <- data.frame() for(s in scaleNames){ df <- data.frame() counter <- 1 for(v in 1:length(lca_model$probs[[s]])){ p <- lca_model$probs[[s]][v] cl <- paste0("Class ", counter) supp <- data.frame(item = s, class = cl, p = p) df <- rbind(df, supp) if(counter < classes){ counter <- counter + 1 } else{ counter <- 1 } } df <- df %>% dplyr::mutate(choice = rep(c("Strongly Disagree","Disagree","Neither Agree nor Disagree","Agree","Strongly Agree","Prefer Not to Say"), each=classes)) responsesDf <- rbind(responsesDf, df) } # computing size of individual classes classCnts <- table(lca_model$predclass) %>% as.data.frame() %>% dplyr::mutate( Var1 = as.character(Var1), Var1 = paste0("Class ", Var1) ) %>% dplyr::rename( class = Var1, freq = Freq ) # final df for dataviz datavizDf <- responsesDf %>% dplyr::mutate( scale = stringr::str_remove_all(item, "\\d"), choice = factor(choice, levels = c("Strongly Disagree","Disagree","Neither Agree nor Disagree","Agree","Strongly Agree","Prefer Not to Say"), ordered = TRUE), scale = case_when( scale == "aut" ~ "Autonomy", scale == "Eng" ~ "Engagement", scale == "JobSat" ~ "Job Satisfaction", scale == "Justice" ~ "Proc.Justice", scale == "Man" ~ "Management", scale == "ManMot" ~ "Mng.Motivation", scale == "ocb" ~ "OCB", scale == "pos" ~ "Org.Support", scale == "Quit" ~ "Quitting Intentions" ) ) %>% dplyr::group_by(scale, class, choice) %>% dplyr::summarise(p = mean(p)) %>% dplyr::ungroup() %>% dplyr::left_join(classCnts, by = "class") %>% dplyr::mutate(class = stringr::str_glue("{class} (n = {freq})")) ``` In the resulting graphs, we quickly see that there is one class of people who often prefer not to give their opinion in an employee survey (Class 2), one class of people with a strongly negative view of their employment experience (Class 4), one class of people with a neutral to slightly negative view of work (Class 1), and one class of people with a neutral to positive view of work (Class 3). ```r # dataviz datavizDf %>% ggplot2::ggplot(aes(x = scale, y = p, fill = choice)) + ggplot2::scale_x_discrete(limits = rev) + ggplot2::geom_bar(stat = "identity", position = position_fill(reverse = TRUE)) + ggplot2::scale_fill_manual(values = c("Strongly Disagree"="#c00000","Disagree"="#ed7d31","Neither Agree nor Disagree"="#ffc000","Agree"="#00b050","Strongly Agree"="#4472c4","Prefer Not to Say"="#d9d4d4")) + ggplot2::coord_flip() + ggplot2::facet_wrap(~class,nrow = 2) + ggplot2::labs( x = "", y = "AVERAGE PROBABILITY OF RESPONSE", fill = "", title = "Latent Class Analysis of responses from the employee survey" ) + ggplot2::theme_bw() + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), strip.text = element_text(size = 12, face = "bold"), strip.background = element_rect(fill = "white"), axis.ticks = element_line(color = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.box.margin=margin(0,0,0,0), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot", ) + ggplot2::guides(fill = guide_legend(nrow = 1)) ``` ![](./latent-class-analysis/unnamed-chunk-8-1.png) It would certainly be possible to delve deeper into the results, but for a basic overview of the process of LCA and its outputs, this might be sufficient. If interested, a more detailed introduction to LCA can be found, for example, in [this practitioner's guide](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7746621/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-19-latent-class-analysis/) on my blog. --- # Use of Bayesian networks in people analytics? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/bayesian-networks-in-people-analytics Date: 2023-06-05 Tags: bayesian-statistics, people-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-05-bayesian-networks-in-people-analytics/ In a people analytics project I'm involved in, we were asked to come up with a prediction model that would perform reasonably well while being very easy to understand and interpret for non-technical users. In considering various options, we also came across [Bayesian networks](https://en.wikipedia.org/wiki/Bayesian_network) (BNs), [probabilistic graphical models](https://en.wikipedia.org/wiki/Graphical_model) consisting of nodes and directed edges that represent conditional (and under certain assumptions, causal) relationships between random variables. They seem to have several interesting properties that would suit our needs, namely: * BN models are not "black boxes", but all their parameters have a comprehensible interpretation, which may reduce users' algorithm aversion and increase their willingness to take algorithm outputs into account in their decision-making. * Expert knowledge can easily be used in the development of BN models by building the network structure using expert knowledge of im/plausible (causal) relationships between the variables under study. * The model lends itself to a user-friendly visualization of its structure, helping to design new models that take into account both data-based constraints and the knowledge of experts in the field. * It is possible to use the data itself to estimate the possible network topology and thus gain preliminary insight into the structure of the problem domain defined by the available variables. * After fitting the model, it can be used for reasoning, i.e., calculating probabilities of interest conditional on the available evidence. This reasoning can be done even with incomplete data, based only on the known values of any combination of the available variables. ![](./bayesian-networks-in-people-analytics/plot.png) *The attached graph is for illustrative purposes only - the output from early experiments with BNs on [artificial IBM employee attrition data](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset).* However, when searching for information about this method, we found that it is not particularly popular among people analytics and I/O psychology practitioners. Would anyone of the readers happen to have a good or bad experience using this tool and would also be willing to share it? 🙏 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-05-bayesian-networks-in-people-analytics/) on my blog. --- # Another positive effect of mindfulness meditation on the horizon? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/mindfulness-and-objectivity Date: 2023-06-05 Tags: decision-making, well-being, experiments Original post: https://blog-about-people-analytics.netlify.app/posts/2023-06-05-mindfulness-and-objectivity/ Maybe, at least this is suggested by a pre-registered field experiment by [Ash et al. (2023)](https://www.sciencedirect.com/science/article/pii/S0165176523000228?via%3Dihub+), where people practicing [mindfulness meditation](https://en.wikipedia.org/wiki/Mindfulness) for 15 minutes a day for 2 weeks compared to an active control (listening to relaxing music) showed a reduced tendency to avoid painful information that may trigger worry or regret, with a likely mechanism for this effect being improved emotion regulation. If this were the case, this would be great news for our decision making, as the ability to impartially evaluate all relevant information is one of the keys to good decision making. It's true that the observed effect was rather small and barely distinguishable from noise and we may be concerned about its reproducibility, but as a Trekkie and a big fan of Mr. Spock, I like the idea that there might be a method that could make us a little more like this cool half-Vulcan and half-human 😉🖖 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-06-05-mindfulness-and-objectivity/) on my blog. --- # Visualizing shifts in validity estimates for selection procedures URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/selection-procedures-validity-update Date: 2023-05-26 Tags: i-o-psychology, validity, employee-selection, meta-analysis, data-visualization Original post: https://blog-about-people-analytics.netlify.app/posts/2023-05-26-selection-procedures-validity-update/ I suppose that many, if not the majority, of I/O psychology and people analytics folks have already heard about a new meta-analytic estimation of validity for selection procedures that is based on a more realistic range-restriction correction performed by [Sackett et al. (2022)](https://psycnet.apa.org/record/2022-17327-001). Numerous articles have explored the results of this meta-analysis and its presumed implications for the hiring process. However, what I found lacking was a clear visual representation of the changes in the validity estimates, including the original article. Because I needed one for a training I was conducting on people analytics and EB-HRM, I created one. Given the nature of the change to be shown, I chose a slopegraph that nicely and intuitively illustrates a two-point change. ```r # uploading the necessary libraries library(tidyverse) library(readxl) library(ggrepel) # uploading data data <- readxl::read_xlsx("./data.xlsx") #glimpse(data) # transforming data dataLong <- data %>% tidyr::drop_na() %>% tidyr::pivot_longer(Hunter:Sackett, names_to = "analysis", values_to = "validity") %>% dplyr::mutate(analysis = case_when( analysis == "Hunter" ~ "Schmidt & Hunter (1998)", analysis == "Sackett" ~ "Sackett et al. (2022)", TRUE ~ "unknown" ), analysis = factor(analysis, levels = c("Schmidt & Hunter (1998)", "Sackett et al. (2022)")) ) # creating custom color palette based on Tableau colors my_palette <- c( "#4E79A7", "#F28E2C", "#E15759", "#76B7B2", "#59A14F", "#EDC949", "#B07AA2", "#FF9DA7", "#9C755F", "#BAB0AB", "#2F8AC4" # an additional distinct color ) # creating the slopegraph dataLong %>% ggplot2::ggplot(aes(x = analysis, y = validity, group = SelectionProcedure)) + ggplot2::geom_line(aes(color = SelectionProcedure), linewidth = 1) + ggplot2::geom_point(aes(color = SelectionProcedure), size = 3) + ggrepel::geom_text_repel(data = dataLong %>% filter(analysis == "Schmidt & Hunter (1998)"), aes(label = SelectionProcedure, color = SelectionProcedure), size = 4.5, hjust = 1.2, vjust = 0.5, direction = "y", force = 1) + ggrepel::geom_text_repel(data = dataLong %>% filter(analysis == "Sackett et al. (2022)"), aes(label = SelectionProcedure, color = SelectionProcedure), size = 4.5, hjust = -0.2, vjust = 0.5, direction = "y", force = 1) + ggplot2::scale_color_manual(values = my_palette) + ggplot2::labs( title = "Comparison of employee selection procedures validity estimates", y = "VALIDITY ESTIMATES", x = "") + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 22, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_blank(), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 13, lineheight = 16), axis.line = element_line(colour = "#E0E1E6"), axis.ticks = element_line(color = "#E0E1E6"), strip.text.x = element_text(size = 11, face = "plain"), legend.position= "none", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./selection-procedures-validity-update/unnamed-chunk-1-1.png) Maybe the visualization will come in handy for you as well when trying to "rewire" your long-held beliefs and assumptions. It should make clearer in what direction and to what extent to do so 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-05-26-selection-procedures-validity-update/) on my blog. --- # Psychometric network analysis & employee survey data URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/psychometric-network-analysis Date: 2023-05-16 Tags: network-analysis, psychometrics, employee-survey, employee-engagement, employee-satisfaction, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-05-16-psychometric-network-analysis/ If you're looking for an alternative to [factor analysis](https://en.wikipedia.org/wiki/Factor_analysis) for processing employee survey data, consider using psychometric network analysis (hereafter PNA). * It provides insight into the interdependencies between different topics related to employee experience, and the resulting network diagrams make these insights easily accessible even to non-experts. * It helps to identify key topics (nodes) that have the most connections or influence within the network, which can be valuable for identifying areas of focus. * Although PNA doesn’t work with latent factors, common community detection algorithms (e.g., [Louvain's method](https://en.wikipedia.org/wiki/Louvain_method)) can be used to identify clusters of more densely interconnected topics. * In interpreting the PNA outputs, one can rely on the apparatus of bootstrapping statistics to distinguish signal from noise. What follows is a small demonstration of the use of PNA on employee survey data. For this purpose, I used a sample dataset that accompanies the book [Predictive HR Analytics: Mastering the HR Metric](https://www.amazon.com/Predictive-HR-Analytics-Mastering-Metric/dp/0749484446) by Edwards & Edwards (2019). It contains the survey responses of 832 employees on a 1 ‘strongly disagree’ to 5 ‘strongly agree’ response scale for a following set of statements. ```r # uploading libraries library(readxl) library(DT) library(tidyverse) # uploading legend to the data legend <- readxl::read_excel("./surveyResults.xls", sheet = "Legend") # user-friendly table with individual survey items DT::datatable( legend %>% dplyr::mutate(Scale = as.factor(Scale)), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` And here is a table with the survey responses we will analyse. ```r # uploading data data <- readxl::read_excel("./surveyResults.xls", sheet = "Data") # selecting relevant data mydata <- data %>% dplyr::select(ManMot1:pos3) # user-friendly table with the data used in the analysis DT::datatable( mydata, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` We will use the `bootnet` R package to estimate the regularized partial correlation network using the Spearman correlation matrix and LASSO regularization, and then select the final network using the [Extended Bayesian Information Criterion](https://academic.oup.com/biomet/article-abstract/95/3/759/217626) (with *γ* parameter set to 0.5). ```r # estimating a regularized partial correlation network network <- bootnet::estimateNetwork( mydata, default = "EBICglasso", corMethod = "spearman", threshold = FALSE # when TRUE, enforces higher specificity, at the cost of sensitivity ) print(network) ``` The above brief summary of the estimated network shows that it is a relatively dense network with 158 out of 406 possible connections (39%). Now let's plot the network. ```r # plotting the estimated network plot( network, layout = "spring", groups = legend %>% dplyr::pull(Scale), nodeNames = names(mydata), weighted = TRUE, directed = FALSE, label.cex = 0.7, label.color = 'black', label.prop = 0.9, negDashed = TRUE, legend.cex = 0.27, legend.mode = 'style2', font = 2, theme = "classic" ) ``` ![](./psychometric-network-analysis/unnamed-chunk-4-1.png) We can also plot the network in `ggplot` style, which can be useful when we need more control over the chart, e.g. when we want to plot identified communities/clusters instead of theoretical scales. To do this, we just need to get the necessary information from the `network` object and do a few data manipulations. ```r # uploading library(igraph) library(ggraph) # extracting information about the connections form the network object ngMatrix <- network$graph # inputting to upper part of the matrix zeroes (the edges between items are symmetrical, so we need just a half of the matrix) ngMatrix[upper.tri(ngMatrix)] <- 0 # transforming matrix into dataframe ngDf <- ngMatrix %>% as.data.frame() %>% tibble::rownames_to_column() %>% dplyr::rename(itemID = rowname) # creating a dataframe with information about connections between items fromToList <- data.frame() for(i in unique(ngDf$itemID)){ suppDf <- ngDf %>% dplyr::filter(itemID == i) %>% tidyr::pivot_longer(-itemID, names_to = "to", values_to = "weight") %>% dplyr::filter(weight != 0) %>% dplyr::rename(from = itemID) %>% dplyr::mutate( sign = case_when( weight < 0 ~ "negative", weight > 0 ~ "positive", TRUE ~ "zero" ), weight = abs(weight) ) fromToList <- dplyr::bind_rows(fromToList, suppDf) } # creating a dataframe with information about individual items items <- data.frame( item = names(mydata), scale = legend %>% pull(Scale) ) # creating igraph object igraph_graph <- igraph::graph_from_data_frame(fromToList, directed=FALSE, vertices = items) # visualizing the network set.seed(123) ggraph::ggraph(igraph_graph, layout = "fr", maxiter = 500) + # fr, kk, drl, mds, maxiter = 500 is default ggraph::geom_edge_link(aes(edge_width = weight, color = sign), alpha = 0.05) + ggraph::geom_node_point(aes(color = scale), size = 5) + ggraph::geom_node_text(aes(label = name), repel = TRUE, size = 3) + ggraph::theme_graph(background = "white") + ggraph::scale_edge_color_manual(values = c("negative" = "red", "positive" = "blue")) + ggplot2::scale_color_brewer(palette="Set1") ``` ![](./psychometric-network-analysis/unnamed-chunk-5-1.png) From the charts we can quickly gain a basic idea of the internal structure of the data. For example, we can notice that: * items from the same scales tend to cluster close to each other; * there are items from different scales that appear to measure motivational aspects of employee attitudes (engagement, organizational citizenship behavior, and quitting intentions), and satisfaction with the "external forces" that affect employee experience (management, justice, and perceived organizational support); * the topic of autonomy is closely related to the topic of managerial motivation; * most of the negative partial correlations are between items measuring quitting intentions and other survey items. To identify most influential topics (nodes) within the network, we can use [centrality measures](https://en.wikipedia.org/wiki/Centrality) that quantify the relative importance of a node within a network based on different aspects of a node's role in the network. ```r # uploading library library(qgraph) # computing centrality measures for individual survey items qgraph::centralityPlot(network, include = "all", orderBy = "ExpectedInfluence") ``` ![](./psychometric-network-analysis/unnamed-chunk-6-1.png) Based on the expected influence centrality measure, which takes into account the presence of both negative and positive edges, the following items appear to be among the most influential ones: * Man3: `r legend %>% dplyr::filter(Item == "Man3") %>% dplyr::pull(Text)` * ManMot3: `r legend %>% dplyr::filter(Item == "ManMot3") %>% dplyr::pull(Text)` * Justice2: `r legend %>% dplyr::filter(Item == "Justice2") %>% dplyr::pull(Text)` * aut3: `r legend %>% dplyr::filter(Item == "aut3") %>% dplyr::pull(Text)` * ManMot1: `r legend %>% dplyr::filter(Item == "ManMot1") %>% dplyr::pull(Text)` But to know how much we can rely on these measures, we must first test their stability using correlation stability analysis that repeatedly computes centrality indices of subsets of the data and correlates these with the centrality indices of the full data. This process generates a correlation stability coefficient (CS-Coefficient) for each centrality measure. The CS-Coefficient corresponds to the proportion of cases that can be dropped while retaining with 95% certainty a certain level of correlation (e.g., 0.7) between the original centrality measure and the centrality measure of the subset. The higher the CS-Coefficient, the more robust the centrality measure is to the reduction of cases. According to [Epskamp & Fried (2018)](https://arxiv.org/pdf/1607.01367.pdf), the CS-coefficient should be above 0.5, and should be at least above 0.25. In our case, we can see the that we can make solid interpretations based on centrality measures of strength (CS(cor = 0.7) = 0.75) and expected influence (CS(cor = 0.7) = 0.75). ```r # estimating the stability of centrality measures bootnet_case_dropping <- bootnet::bootnet( network, nBoots = 2500, type = "case", nCores = 6, statistics = c('strength', 'expectedInfluence', 'betweenness', 'closeness') ) # plotting the results plot(bootnet_case_dropping, 'all') # listing the results bootnet::corStability(bootnet_case_dropping) ``` In a similar way, i.e. using a bootstrapping, we can estimate the stability of the edge weights. This gives us information on how much the edge weights for individual connections vary with 95% confidence intervals. From the chart below, it's apparent that some edge weights are more accurate than others, as they show a narrower band. Simultaneously, we observe that the majority of edges closer to zero appear to be non-significant, as they intersect with zero in the bootstrapped samples. ```r # estimating the stability of edge weights bootnet_nonpar <- bootnet::bootnet( network, nBoots = 1000, nCores = 6 ) # plotting the results plot(bootnet_nonpar, labels = FALSE, order = "sample") ``` ![](./psychometric-network-analysis/unnamed-chunk-8-1.png) To find internal structure in the estimated network, we need not rely solely on the visual inspection of the network diagram, but we can use some of the common community detection algorithms that can be used to identify clusters of more densely connected topics. In the example below, we have merely " replicated" an existing clustering by scale, which is an indication that the authors have managed to construct a survey that measures distinct constructs that they originally intended to measure, but in practice you are likely to observe less distinct patterns more often, which can give you clues about how to improve the construction of the employee survey. ```r # identifying communities/clusters clu <- igraph::cluster_louvain(igraph_graph, weights = fromToList$weight) # cluster_optimal, cluster_louvain, cluster_leading_eigen, cluster_fast_greedy, cluster_walktrap, cluster_edge_betweenness, cluster_spinglass # assigning communities/clusters to nodes member <- membership(clu) V(igraph_graph)$cluster <- as.character(member) # visualizing the network with identified communities/clusters set.seed(123) ggraph::ggraph(igraph_graph, layout = "fr", maxiter = 500) + # fr, kk, drl, mds, maxiter = 500 is default ggraph::geom_edge_link(aes(edge_width = weight, color = sign), alpha = 0.05) + ggraph::geom_node_point(aes(color = cluster), size = 5) + ggraph::geom_node_text(aes(label = name), repel = TRUE, size = 3) + ggraph::theme_graph(background = "white") + ggraph::scale_edge_color_manual(values = c("negative" = "red", "positive" = "blue")) + ggplot2::scale_color_brewer(palette="Set1") ``` ![](./psychometric-network-analysis/unnamed-chunk-9-1.png) Another use case would be to test for differences in job attitudes' connectivity and centrality across different groups of employees. To do this, we can use the `NetworkComparisonTest` R package that implements permutation based hypothesis testing of differences between two networks. Let's illustrate it with differences between networks estimated on data coming from females and males. We first filter data for both genders and than estimate and visualize their respective attitudinal networks. ```r # filtering data for females and males mydataFemale <- data %>% dplyr::filter(sex == 2) %>% dplyr::select(ManMot1:pos3) mydataMale <- data %>% dplyr::filter(sex == 1) %>% dplyr::select(ManMot1:pos3) # estimating a regularized partial correlation network for females and males networkFemale <- bootnet::estimateNetwork( mydataFemale, default = "EBICglasso", corMethod = "spearman", threshold = FALSE ) networkMale <- bootnet::estimateNetwork( mydataMale, default = "EBICglasso", corMethod = "spearman", threshold = FALSE ) # plotting the estimated networks plot( networkFemale, layout = "spring", groups = legend %>% dplyr::pull(Scale), nodeNames = names(mydataFemale), weighted = TRUE, directed = FALSE, label.cex = 0.7, label.color = 'black', label.prop = 0.9, negDashed = TRUE, legend.cex = 0.27, legend.mode = 'style2', font = 2, theme = "classic", title = "Females" ) plot( networkMale, layout = "spring", groups = legend %>% dplyr::pull(Scale), nodeNames = names(mydataMale), weighted = TRUE, directed = FALSE, label.cex = 0.7, label.color = 'black', label.prop = 0.9, negDashed = TRUE, legend.cex = 0.27, legend.mode = 'style2', font = 2, theme = "classic", title = "Males" ) ``` We can check four basic types of differences between the networks: * The maximum difference in edge weights (M statistic) that tells us whether the structure of the network is identical across two compared groups, and thus whether the groups differ in the overall "shape" or "architecture" of their attitudes. * The difference in global strength (S statistic) that tells us whether the density of the network is identical across the groups, and thus whether they differ in their openness to change of their attitudes and behaviors (see, for example, [Zwicker et al. (2020)](https://www.sciencedirect.com/science/article/pii/S0272494419308783?via%3Dihub)). * The difference in the centrality measures across the groups that tell us whether the groups differ in topics that are most important/influential in their attitudinal network. * The difference between groups in specific edges. According to [van Borkulo et al. (2017)](https://pubmed.ncbi.nlm.nih.gov/35404628/), when the M statistic is not statistically significant, it is recommended not to test group-level differences for specific edges as it increases the likelihood of Type 1 error. As you can see below, (almost) all test results are statistically non-significant, so we don't have sufficiently strong evidence for claiming that there are substantive differences between females and males in their respective attitudinal networks. Only in the case of the Eng1 item (*I share the values of this organization.*), females show a statistically significantly smaller strength centrality measure compared to males. Given that M statistic is statistically non-significant, we don't test statistical significance of differences for specific edges. ```r # uploading library library(NetworkComparisonTest) # testing the differences set.seed(123) testGenderDiff <- NetworkComparisonTest::NCT( networkFemale, networkMale, binary.data=FALSE, paired=FALSE, weighted=TRUE, test.edges=FALSE, edges="all", p.adjust.methods="holm", test.centrality=TRUE, centrality=c("strength","expectedInfluence"), nodes="all", progressbar=FALSE ) # printing test results print(testGenderDiff) # checking observed differences in centrality measures # testGenderDiff$diffcen.real # plotting results of the network structure invariance test # plot(testGenderDiff,what="network") # plotting results of global strength invariance test # plot(testGenderDiff,what="strength") # plotting results of the edge invariance test # plot(testGenderDiff,what="edge") ``` I hope you find this post useful and that it inspires you to try PNA on your own data. If you were looking for more authoritative sources on PNA, [Epskamp & Fried's (2018) tutorial](https://psycnet.apa.org/doiLanding?doi=10.1037%2Fmet0000167) is a good place to start. An excellent introduction to the topic can also be found in [Letouche & Wille (2022)](https://www.frontiersin.org/articles/10.3389/fpsyg.2022.838093/full) and [Dalege et al. (2017)](https://journals.sagepub.com/doi/pdf/10.1177/1948550617709827), respectively. ## Figures ![](./psychometric-network-analysis/unnamed-chunk-7-1.png) ![](./psychometric-network-analysis/unnamed-chunk-10-1.png) ![](./psychometric-network-analysis/unnamed-chunk-10-2.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-05-16-psychometric-network-analysis/) on my blog. --- # Employee commitment over time & regression to the mean URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/regression-to-the-mean Date: 2023-05-10 Tags: people-analytics, critical-thinking, statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-05-10-regression-to-the-mean/ A vendor specializing in employee engagement measurements recently [presented their findings](https://www.cultureamp.com/blog/company-layoffs-myths) that companies with the highest and lowest levels of commitment prior to a layoff compared to the benchmark, experienced the largest decreases and largest increases in commitment post-layoff, respectively. They post-hoc-hypothesized that highly committed employees feel especially hurt and betrayed when layoffs occur. However, when a similar pattern emerges, a warning light should always flash that regression to the mean (RTM) might actually be behind it. When a variable is imperfectly correlated with another variable, extreme values tend to gravitate towards the mean in subsequent measurements, which can make natural variations in repeated data appear like genuine change. This doesn't mean that RTM was the sole factor in the aforementioned case. However, to know better, it's necessary to control for it. What follows is a simple simulation to illustrate how the reported finding could occur purely or partially due to RTM, and how one might control for it. First, let's create correlated employee commitment observations per company from time 1 (T1) and time 2 (T2). ```r # uploading necessary libraries library(tidyverse) set.seed(1) # seed for reproducibility n <- 2000 # number of observations T1 <- rnorm(n) # generating observations at time 1 T2 <- 0.7*T1 + rnorm(n)*sqrt(1-0.7^2) # generating correlated observations at time 2 # cor(T1, T2) # checking the correlation df <- data.frame(T1=T1, T2=T2) # putting created variables into dataframe ``` We also need a benchmark for T1 so that we can calculate the difference between T1 values and T1 benchmark. In addition, we also need to calculate the difference between T2 and T1. ```r # computing benchmark for pre-layoff period (T1) benchmark <- df %>% dplyr::summarise( benchmark = mean(T1) ) %>% dplyr::pull(benchmark) # computing differences between T2 and T1 and between T1 and T1 benchmark (average) df <- df %>% dplyr::mutate( timeDiff = T2-T1, benchmarkDiff = T1-benchmark ) ``` Then all we have to do is randomly assign individual observations to the group of companies that made layoffs and those that did not. ```r # assigning each observation randomly one of two labels - Layoffs/NoLayoffs df1 <- df %>% dplyr::mutate( Layoffs = sample(c("Layoffs", "NoLayoffs"), size = n(), replace = TRUE, prob = c(0.15, 0.85)) ) ``` Now we can contrast the differences between T1 values and T1 benchmark on the one hand and the differences between T2 and T1 on the other. As we can see in the chart below, the pattern matches well with the originally reported finding - companies with the highest and lowest levels of commitment prior to a layoff compared to the benchmark, experienced the largest decreases and largest increases in commitment post-layoff, respectively, but now purely as a result of RTM. ```r # visualizing relationship between the df1 %>% dplyr::filter(Layoffs == "Layoffs") %>% ggplot2::ggplot(aes(x = benchmarkDiff, y = timeDiff)) + ggplot2::geom_point(size = 3, alpha = 0.8) + ggplot2::geom_smooth(method = "lm", se = F) + ggplot2::scale_x_continuous(breaks = seq(-2,4,1)) + labs( x = "DIFFERENCE BETWEEN T1 AND T1 BENCHMARK", y = "DIFFERENCE BETWEEN T2 AND T1", title = "Changes in employee commitment purely due to regression to the mean", caption = "\nA replication of the original finding that companies with the highest and lowest levels of commitment prior to a layoff compared to the benchmark, experienced\nthe largest decreases and largest increases in commitment post-layoff, respectively, relying only on a regression to the mean phenomenon.\nT1 and T2 refer to commitment measurements at time 1 (prior to a layoff) and time 2 (after a layoff), respectively." ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line = element_line(colour = "#E0E1E6"), axis.ticks = element_line(color = "#E0E1E6"), strip.text.x = element_text(size = 11, face = "plain"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + ggplot2::guides(color = guide_legend(nrow = 1)) ``` ![](./regression-to-the-mean/unnamed-chunk-4-1.png) In general, in order to make a valid estimate of the effect of layoffs on employee commitment when RTM is at play, we need to control for its effect. [One way to do this](https://academic.oup.com/ije/article/34/1/215/638499) is to include the difference between T1 values and T1 benchmark in the linear regression model as illustrated below. ```r # modeling T2 while controlling for the effect of regression to the mean model1 <- glm(T2 ~ benchmarkDiff + Layoffs, family = gaussian(link = "identity"), data = df1) summary(model1) ``` It is clear from the estimated model that there is not much evidence in favor of the existence of a layoff effect, which should not be surprising since individual observations were purely randomly assigned to groups of companies with and without layoffs. However, when we adjusted the data to better reflect the hypothesized causal mechanism behind the observed pattern, the effect of layoffs was detected as statistically significant. ```r # creating a new dataset that better reflects the hypothesized causal mechanism behind the observed pattern df2 <- df %>% dplyr::rowwise() %>% dplyr::mutate( Layoffs = sample(c("Layoffs", "NoLayoffs"), size = 1, replace = TRUE, prob = c(0.15, 0.85)), T2 = ifelse(Layoffs == "Layoffs" & T1 >= 0.5, T2 - runif(min = 0.3, max = 1.5, n = 1), T2), T2 = ifelse(Layoffs == "Layoffs" & T1 <= -0.5 , T2 - runif(min = -0.75, max = 0.2, n = 1), T2) ) %>% dplyr::ungroup() %>% dplyr::mutate(timeDiff = T2-T1) # recomputing timeDiff # modeling T2 while controlling for the effect of regression to the mean model2 <- glm(T2 ~ benchmarkDiff + Layoffs, family = gaussian(link = "identity"), data = df2) summary(model2) ``` Feel free to share your own experiences and encounters with the phenomena of regression to the mean in your people analytics practice. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-05-10-regression-to-the-mean/) on my blog. --- # Improving a company career site with tools from OpenAI URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/cv-job-match-career-site Date: 2023-05-04 Tags: recruitment, generative-ai, embeddings, python, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2023-05-04-cv-job-match-career-site/ My recent exploration of new job opportunities has inspired me to consider what I would appreciate as a job candidate when visiting the career site of a company I'd like to work for. Specifically, I would appreciate the following flow: 1. Uploading my CV on the company's career site. 2. Receiving a list of jobs sorted by the degree of match to my CV. 3. Obtaining an overview of my major mis/matches for the selected job. 4. Having the option to ask specific questions about the job (e.g. What could be the biggest challenge for me?) 5. Not/Applying for the job after considering the provided information. It's clear that a process like this would make life easier not only for job candidates but also for companies, as more relevant candidates would likely apply for posted vacancies on average (as far as people are looking for similar jobs they have done in the past). I tested the feasibility of this idea by creating a functional POC career site, with OpenAI tools working behind the scenes, that supports this exact flow for 20 people-analytics-related job ads taken from [One Model's website with open roles in the people analytics space](https://www.onemodel.co/roles-in-people-analytics-hr-technology) (by the way, kudos for that, One Model team 👏). You can try it for yourself with your own or sample CV on [this webpage](https://aanalytics.shinyapps.io/cv-job-fit-analysis/). ![](./cv-job-match-career-site/screenshot.png) Let me know in the comments what you think about the flow and/or how you would improve it to make it even more useful for job candidates and companies. P.S. It was also a good opportunity to try out [Shiny for Python by Posit](https://shiny.rstudio.com/py/). Being a regular user of the R version of Shiny, I may be a little bit biased, but IMO it's more user-friendly compared to Dash, so if you need to present the results of your Python code in an interactive web application, definitely give it a try. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-05-04-cv-job-match-career-site/) on my blog. --- # GPT-4's performance in the knowledge test of evidence-based HRM practices URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/gpt4-and-e-b-hrm-practices Date: 2023-05-01 Tags: generative-ai, ai, evidence-based-management, hr-management, people-management Original post: https://blog-about-people-analytics.netlify.app/posts/2023-05-01-gpt4-and-e-b-hrm-practices/ Some time ago, I "replicated" [Rynes, Colbert, and Brown's 2002 study](https://psycnet.apa.org/record/2003-99255-001) on HR practitioners' beliefs about effective HR practices on a convenience sample of more than 140 LinkedIn users. [The results of this "replication"](https://blog-about-people-analytics.netlify.app/posts/2023-01-08-evidence-based-hrm-knowledge-test-results/) closely resembled the results of the original study. On average, respondents correctly answered 19.4 out of 35 items, achieving a 55% success rate, which was very close to the 57% average success rate in the original study (and also quite close to the 50% success rate that corresponds to random choice, given the TRUE/FALSE response format). I was curious to see how GPT-4 would perform in this test, as it had been evaluated on various standardized tests such as the SAT, GRE, Bar Exam, and AP. The prompts used had the following form: *Read the following statement and indicate whether it is true or false. Keep in mind that the statement refers to general trends and patterns that apply on average but not necessarily to all cases. When evaluating the statement, ensure that you correctly interpret the words used in the statement and take into account existing scientific evidence. Give me the answer either true or false, without intermediate values, in a boolean way. Finally, briefly explain your reasoning behind your answer. The statement is as follows:...* So, what were the results? GPT-4 answered 29 out of 35 items right, i.e., it achieved an 83% success rate, which corresponds to the 99th and 97th percentiles in the original and "replicated" studies, respectively. GPT-4's results were thus superior to majority of people who took the test. ![](./gpt4-and-e-b-hrm-practices/plot.png) However, even when it gave a correct answer, it did not always rely on correct facts and/or valid reasoning, which could be a problem if management decided to act on the answers provided. See the table below to check the details of its responses. ```r # uploading libraries library(tidyverse) library(readxl) library(DT) # uploading data mydata <- readxl::read_xlsx("./gpt4Responses.xlsx") # creating user-friendly table DT::datatable( mydata %>% dplyr::select(itemId, item, gpt4Response, correctAnswer, gpt4Reasoning, researchEvidence, possibleContingencies) %>% dplyr::rename("Item ID"=itemId, Item=item, "GPT-4 response"=gpt4Response, "Correct answer"=correctAnswer, "GPT-4 reasoning"=gpt4Reasoning, "Research evidence"=researchEvidence, "Possible contingencies"=possibleContingencies), class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 3, autoWidth = TRUE, columnDefs = list(list(width = '500px', targets = c("Item", "GPT-4 reasoning", "Research evidence", "Possible contingencies"))), dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) %>% DT::formatStyle(1:7, 'vertical-align'='top') ``` The possible takeaway from this finding? Although GPT-4 can be a handy tool for exploring possible solutions to specific HR-related problems, on its own and in its current form it cannot replace the good old systematic search for and retrieval of evidence, critical evaluation of its reliability and relevance, and its weighing and synthesis as conducted and/or supervised by human experts. P.S. I didn't test the reliability of GPT-4's responses, nor did I set its temperature to 0, so it's possible that you might obtain somewhat different results if you decide to replicate the test. In addition, please keep in mind that the comparison presented here is not entirely an apples-to-apples comparison, mainly due to the fact that new evidence may have emerged that does not match the correct answers in the original study conducted more than 20 years ago. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-05-01-gpt4-and-e-b-hrm-practices/) on my blog. --- # Employee feedback analysis using tools from OpenAI URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/employee-feedback-analysis-using-openai Date: 2023-04-28 Tags: generative-ai, embeddings, employee-survey, topic-analysis, python, r, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-28-employee-feedback-analysis-using-openai/ A while ago, I posted about the [potential of using GPT for processing open-ended feedback from employees](https://blog-about-people-analytics.netlify.app/posts/2023-02-26-chatgpt-and-employee-feedback/). I simply inputted a block of text into GPT and asked for a summary of the major topics found in the feedback. Although the output was quite accurate and the information compression achieved was very useful, this approach was somewhat limited in terms of scalability and granularity of the information provided. To address these limitations, I experimented with another approach that includes the following steps: 1. Looping over feedback from individual employees and sending them one by one to GPT. 2. Prompting GPT to identify all present topics in each feedback, determining their respective sentiments (positive, negative, mixed, or neutral), and extracting the relevant parts of the feedback based on which the topic was identified. 3. Prompting GPT to categorize identified topics using a provided list of topic categories (e.g., compensation and benefits, work-life balance, collaboration and teamwork, etc.), while taking into account contextual information in the relevant parts of the feedback. Alternatively, categorizing by matching embeddings of identified topics, contextual information, and topic categories. 4. Plotting the topic categories by the number of their occurrences and type of associated sentiment. 5. Interactive exploration of specific topics clustered by their semantic similarity based on their respective embeddings and visualized with the help of t-SNE dimensionality reduction technique. 6. Creating a filterable table with identified topics and all original and extracted information that may be useful for further exploration of the feedback and for checking the precision of the topic identification. I had to experiment a bit with the prompts and include some data-munging inter-steps to get useful outputs, however, it now works relatively smoothly and provides pretty good results. To test the plausibility of this approach, I tried it on publicly available feedback from more than 300 current and former employees of an unnamed company published on Glassdoor and shared on [Kaggle](https://www.kaggle.com/datasets/davidgauthier/glassdoor-job-reviews/code). You can check the results of the analysis yourself in [this simple dashboard](https://aanalytics.shinyapps.io/employeeFeedback/). ![](./employee-feedback-analysis-using-openai/Screenshot.png) In my opinion, it works quite well and could represent a very time- and cost-effective way to gain useful insights from employee open-ended feedback at scale, with the caveat that one has to ensure the security of the processed data, for example, by using a local LLM. Let me know what you think about this approach. And if you are interested in the Python code behind the analysis so you can play with it on your own data, here's the [link to the GitHub page](https://github.com/lstehlik2809/Employee-Feedback-Analysis-Using-OpenAI) with the Python code. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-28-employee-feedback-analysis-using-openai/) on my blog. --- # When flawed statistical & causal reasoning leads to a valid conclusion anyway URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/glassdoor Date: 2023-04-24 Tags: glassdoor, employee-experience, employee-satisfaction, employee-turnover Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-24-glassdoor/ One simple lesson from the observation that former employees tend to rate their employers more harshly on Glassdoor compared to current employees: **Strive to retain your employees, and you'll likely have a more satisfied workforce and better Glassdoor ratings** 😁 ```r # uploading library library(tidyverse) # uploading data (link to the original dataset: https://www.kaggle.com/datasets/davidgauthier/glassdoor-job-reviews/code) # data <- readr::read_csv("./glassdoor_reviews.csv") # # # preparing data # mydata <- data %>% # # selecting relevant vars # dplyr::select(firm, current, overall_rating, work_life_balance, culture_values, career_opp, comp_benefits, senior_mgmt) %>% # # keeping companies with at least 300 records # dplyr::group_by(firm) %>% # dplyr::mutate(n = n()) %>% # dplyr::ungroup() %>% # dplyr::filter(n >= 500) %>% # dplyr::select(-n) %>% # # renaming employee status and keeping only current and former employees # dplyr::mutate( # status = tolower(current), # status = case_when( # stringr::str_detect(status, "\\bcurrent\\b") ~ "Current employee", # stringr::str_detect(status, "\\bformer\\b") ~ "Former employee", # TRUE ~ "Unknown" # ) # ) %>% # dplyr::filter(status != "Unknown") %>% # dplyr::select(-current) %>% # # changing wide format to long one # tidyr::pivot_longer(overall_rating:senior_mgmt, names_to = "rating_dimension", values_to = "value") %>% # # removing missing values # dplyr::filter(!is.na(value)) %>% # # renaming rating dimensions # dplyr::mutate(rating_dimension = case_when( # rating_dimension == "overall_rating" ~ "Overall rating", # rating_dimension == "work_life_balance" ~ "Work-life balance", # rating_dimension == "culture_values" ~ "Culture values", # rating_dimension == "career_opp" ~ "Career opportunities", # rating_dimension == "comp_benefits" ~ "Compensation & benefits", # rating_dimension == "senior_mgmt" ~ "Senior management", # TRUE ~ "Unknown" # ) # ) %>% # # removing unknown rating dimensions # dplyr::filter(rating_dimension != "Unknown") # to save space in my GitHub repo, I will upload already filtered dataset saved as .RDS file mydata <- readRDS("./glassdoor_reviews_filtered.RDS") # dataviz # computing weighted average probability of a given rating for companies in the sample vizData <- mydata %>% dplyr::group_by(firm, status, rating_dimension, value) %>% dplyr::summarise( n = n() ) %>% dplyr::ungroup() %>% dplyr::group_by(firm, status, rating_dimension) %>% dplyr::mutate(nAll = sum(n)) %>% dplyr::ungroup() %>% dplyr::mutate( prop = n/nAll, wprop = prop*nAll ) %>% dplyr::group_by(status, rating_dimension, value) %>% dplyr::summarise( wpropsum = sum(wprop), w = sum(nAll) ) %>% dplyr::ungroup() %>% dplyr::mutate(fprop = wpropsum/w) # chart vizData %>% dplyr::mutate(rating_dimension = factor(rating_dimension, levels = c("Overall rating", "Work-life balance", "Culture values", "Career opportunities", "Compensation & benefits", "Senior management"))) %>% ggplot2::ggplot(aes(x = value, y = fprop, fill = forcats::fct_rev(status))) + ggplot2::geom_bar(stat = "identity", position="dodge") + ggplot2::coord_flip() + ggplot2::scale_fill_manual(values = c("Current employee" = "#4E79A7", "Former employee" = "gray")) + ggplot2::facet_wrap(~rating_dimension, ncol = 3, scales = "fixed") + ggplot2::labs( title = "**Comparison of Glassdoor ratings from** **current** **and** **former employees** ", caption = "\nBased on a sample of ratings from 792,390 individuals across 165 companies with more than 500 records each.\nThe values represent the weighted average probability of a given rating for companies in the sample.", x = "RATING", y = "PROBABILITY OF RATING" ) + ggplot2::theme( plot.title = ggtext::element_markdown(face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), strip.text.x = element_text(size = 13, face = "bold"), axis.line = element_line(colour = "#E0E1E6"), legend.position="", panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./glassdoor/unnamed-chunk-1-1.png) On a more serious note, it may be quite interesting and potentially useful to examine the order of estimated differences in specific areas between current and former employees as it may provide some insights on which areas to focus on when trying to retain employees within the company. We can use a multilevel ordered regression analysis on a random sample of 300 ratings per company for this purpose. ```r # uploading libraries library(ordinal) library(broom.mixed) library(parameters) # modeling responses using multilevel ordered regression analysis fits <- data.frame() # looping over individual dimensions for(scale in c("Overall rating", "Work-life balance", "Culture values", "Career opportunities", "Compensation & benefits", "Senior management")){ #print(scale) set.seed(1234) model <- ordinal::clmm("value ~ status + (1 | firm)", data = mydata %>% dplyr::filter(rating_dimension == scale) %>% dplyr::mutate(value = factor(value,ordered = TRUE)) %>% dplyr::group_by(firm) %>% dplyr::sample_n(300) %>% dplyr::ungroup()) #summary(model) # extracting information about fitted models supp <- broom.mixed::tidy(model) %>% dplyr::filter(term == "statusFormer employee") %>% dplyr::bind_cols(parameters::ci(model) %>% filter(Parameter == "statusFormer employee") %>% select(CI_low, CI_high)) %>% dplyr::select(-coef.type) %>% dplyr::mutate(scale = scale) %>% dplyr::select(scale, everything()) fits <- dplyr::bind_rows(fits, supp) } fits %>% arrange(estimate) ``` Caveat: As the title of this post implies, readers should be aware that numerous biases can distort the portrayal of employee experiences reflected in Glassdoor ratings. Some of the most significant biases include survivorship bias, social desirability, non-response bias, self-selection, and motivated reasoning. [Dr. Paul De Young's](https://www.linkedin.com/in/pauldeyoungphd) personal experience in this regard is quite telling: *"There is often a high preponderance of phony ratings among so-called current employees on Glassdoor. Beware of bogus “part time” current employees giving high ratings, especially if the company does not employ a lot of part-time employees.Also, I learned from an HR executive that if you want to get ratings up on Glassdoor, encourage ALL your employees to rate the company. Most often it is the mistreated employees who post because this is an outlet for their misfortune. By getting more employees to rate, chances are your ratings will increase. Watch for actively monitored employers on Glassdoor. You can usually tell a bogus rating because there is a high rating with very few comments in jobs that do not exist. The first thing I do when looking at a company is to filter out the part time employees and look at the impact on the overall scores. If they jump down, you have to wonder about the validity of the ratings. Read the comments, they are more telling. There are all kinds of ways to game the system. Glassdoor is helpful, but doesn’t always give you a valid picture without looking at the details, which is where the devil lives."* However, it doesn't mean that there is no signal in Glassdoor ratings. For example, [behavioral scientists at Culture Amp](https://www.cultureamp.com/blog/improving-glassdoor-scores-engagement-data) investigated the relationship between Glassdoor ratings and employee engagement data collected by Culture Amp. The findings suggested a strong correlation between employee engagement and Glassdoor ratings, particularly as the number of reviews increases (r = 0.69 for 100+ reviews). Companies with higher engagement scores tended to have better Glassdoor ratings, including higher CEO approval percentages and a greater likelihood of being recommended as a workplace. The study also identified the five factors with the largest relationship to Glassdoor scores, which included Learning and Development, Service and Quality Focus, Decision-making, Leadership, and Collaboration. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-24-glassdoor/) on my blog. --- # In need of multilevel correlations? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/multilevel-correlation Date: 2023-04-18 Tags: statistics, multilevel-modeling, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-18-multilevel-correlation/ I am currently in the middle of a project where I am working with nested data and need to report multilevel correlations. To my surprise, for quite a long time I couldn't find any libraries in the R or Python ecosystems that provided an easy-to-use implementation of this type of analysis. I thought I would have to code it from scratch using Stan or JAGS. Fortunately, I discovered a fantastic correlation package (part of the easystats universe) that can compute various types of correlations, including multilevel correlations, partial correlations, Bayesian correlations, polychoric correlations, biweight correlations, distance correlations, and more. Since nested data is (almost) everywhere, consider trying this package as it can make your life as an analyst a little bit easier 😉 Check out the code below to see it in action. ```r # Uploading libraries and creating custom functions library(tidyverse) library(correlation) library(ggsci) library(MASS) # Creating simulated dataset with nested data # Setting some basic parameters of the dataset num_teams <- 7 team_ids <- LETTERS[1:num_teams] min_rows <- 35 # Defining function to generate data for a team with specified correlation generate_team_data <- function(team_id, correlation, job_sat_mean, agility_maturity_mean) { # Creating covariance matrix covariance <- correlation * (20 * 50) means <- c(job_sat_mean, agility_maturity_mean) cov_matrix <- matrix(c(100, covariance, covariance, 2500), nrow = 2) # Generating correlated data data <- MASS::mvrnorm(n = min_rows, mu = means, Sigma = cov_matrix) # Scaling the data data[, 1] <- scale(data[, 1],center = FALSE,scale = TRUE) data[, 2] <- scale(data[, 2],center = FALSE,scale = TRUE) # Putting data into dataframe df <- data.frame( TeamID = team_id, JobSatisfaction = data[, 1], AgilityMaturity = data[, 2]) return(df) } # Generating random means for job satisfaction and agility maturity for each of the teams within some range set.seed(42) job_sat_means <- runif(num_teams, min = -5, max = 5) agility_maturity_means <- runif(num_teams, min = 40, max = 60) # Generating random correlations for each of the teams team within some range set.seed(421) correlations <- runif(num_teams, min = -0.3, max = 0.4) # Generating data for each team and store in a list set.seed(123) team_data <- mapply(generate_team_data, team_id = team_ids, correlation = correlations, job_sat_mean = job_sat_means, agility_maturity_mean = agility_maturity_means, SIMPLIFY = FALSE) # Combining team data into a single data frame simulated_data <- do.call(rbind, team_data) # Computing multilevel Bayesian Pearson correlation analysis c <- correlation::correlation( simulated_data, method = "pearson", multilevel = TRUE, bayesian = TRUE ) # Extracting results of the analysis to be included in the chart defined below Pearson_r = c[1,3] CI95L = c[1,4] CI95H = c[1,5] # Plotting the chart ggplot2::ggplot(simulated_data, aes(y = JobSatisfaction, x = AgilityMaturity, color = TeamID)) + ggplot2::geom_point(size = 3) + ggplot2::geom_smooth(method = "lm", se = FALSE, size = 1.5) + ggsci::scale_color_tron() + ggplot2::labs( y = "JOB SATISFACTION", x = "PERCEIVED ORGANIZATIONAL AGILITY MATURITY", title = "Is organizational agility related to job satisfaction?", subtitle = stringr::str_glue("Bayesian Pearson r = {round(Pearson_r,2)}; 95% CrI: [{round(CI95L,2)}, {round(CI95H,2)}]") ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 15, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line = element_line(colour = "#E0E1E6"), axis.ticks = element_line(color = "#E0E1E6"), strip.text.x = element_text(size = 11, face = "plain"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + ggplot2::guides(color = guide_legend(nrow = 1)) ``` ![](./multilevel-correlation/unnamed-chunk-1-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-18-multilevel-correlation/) on my blog. --- # Consequences of time management in the workplace URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/time-management Date: 2023-04-17 Tags: time-management, meta-analysis, employee-satisfaction, performance, well-being Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-17-time-management/ [Bedi & Sass (2022)](https://www.tandfonline.com/doi/abs/10.1080/00224545.2022.2159302?journalCode=vsoc20) conducted a meta-analytic review of the consequences of employee time management behaviors on several employee outcomes. What are the main insights? * It may not come as a big surprise, but it is encouraging that **data support the association between time management and various beneficial employee outcomes, such as increased job satisfaction, job performance, and lower levels of stress and burnout**. Unfortunately, the "proven" association is not causal, as the majority of studies were cross-sectional. In fact, there are not many studies on the causal effects of time management. The exception to this is procrastination, for which there is evidence that time management can help - see, for example, the meta-analysis by [Van Eerde & Klingsieck (2018)](https://www.sciencedirect.com/science/article/abs/pii/S1747938X18300472) on this topic. * **The relationship between time management and employee outcomes is not only direct but also partially mediated by work-family conflict**. This finding underscores the importance of work-life balance and highlights the need for organizations to help employees better address this specific issue, as it may positively affect a variety of employee outcomes. ![](./time-management/semChart.png) * **Perceived control over time**, achieved through the use of time management, **shows incremental validity in predicting job satisfaction, job performance, and stress with respect to the personality trait of conscientiousness**. This suggests that regardless of an individual's innate level of prudence, they may benefit from adopting time management in their professional lives. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-17-time-management/) on my blog. --- # Interpretable machine learning with modelStudio URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/interpretable-ml Date: 2023-04-13 Tags: interpretability, machine-learning, predictive-analytics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-13-interpretable-ml/ There's a great new R package, [modelStudio](https://cran.r-project.org/web/packages/modelStudio/index.html), that makes it much easier for analysts to create both global and local interpretations of predictive models using an interactive interface. Once you've trained the model, you just get the [DALEX](https://cran.r-project.org/web/packages/DALEX/index.html) explainer object ready and start up modelStudio that will run the following analyses (among others) and show the corresponding plots: * **Feature Importance:** A visual representation that ranks and displays the significance of each input variable in a predictive model, helping to identify the most influential features for model predictions. * **Partial Dependence:** A visualization that shows the relationship between a feature and the predicted outcome while averaging out the effects of all other features, to understand the marginal impact of a specific feature on the model's predictions. * **Accumulated Dependence:** Similar to the previous method, but reducing the influence of the assumption of uncorrelated features, providing a more robust and reliable representation of the feature's impact on the model's predictions. * **Break Down Plot:** A graphical explanation tool that demonstrates the contribution of each feature to a specific instance's prediction, allowing for individual-level interpretation of model outcomes. * **Shapley Values:** A cooperative game theory-based approach for fairly attributing each feature's contribution to a specific prediction, providing interpretable and consistent explanations for machine learning models. * **Ceteris Paribus:** A method that helps with understanding the influence of individual features on specific predictions by isolating the effect of a single variable while holding all other variables constant. If you use ML in HR or any other field where it's crucial to explain why you make specific predictions, classifications, and the resulting recommendations or decisions, definitely give it a try. What follows is a short demonstration of the tool using the well-known [artificial IBM attrition dataset](https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset). First, let's import the attrition dataset from the `modeldata` library and change the coding of the criterion variable, which will later make it easier to set up the DALEX explainer, which requires numerical data type for criterion variable even in classification tasks. ```r # uploading libraries library(modeldata) library(tidyverse) # uploading data data("attrition") # changing the coding of the criterion variable attrition <- attrition %>% mutate(Attrition = recode(Attrition, "Yes" = "1", "No" = "0") %>% factor(levels = c("1", "0"))) ``` We now split the data into training, test, and validation datasets so that we can tune the prediction model, fit the model, and test its performance on new, previously unseen data. ```r # uploading libraries library(rsample) # splitting data into train, validation, and test datasets set.seed(123) data_split <- rsample::initial_split(attrition, strata = Attrition, prop = 0.8) data_train <- rsample::training(data_split) data_test <- rsample::testing(data_split) data_val <- rsample::validation_split(data_train, strata = "Attrition", prop = 0.8) ``` Now let's define the whole model training workflow, which includes the data adjustment pipeline and the specification of the model used. We will use XGBoost, presumably the best ML algorithm for tabular type of data. ```r # uploading libraries library(recipes) library(parsnip) library(workflows) fmla <- as.formula(paste("Attrition", " ~ .")) # defining recipe for adjusting data for fitting the model xgb_recipe <- recipes::recipe(fmla, data = data_train) %>% recipes::step_ordinalscore(recipes::all_ordered_predictors()) %>% recipes::step_dummy(recipes::all_factor_predictors()) # defining the model xgb_model <- parsnip::boost_tree(mtry = tune(), min_n = tune(), tree_depth = tune(), trees = 1000) %>% parsnip::set_engine("xgboost") %>% parsnip::set_mode("classification") xgb_workflow <- workflows::workflow() %>% workflows::add_model(xgb_model) %>% workflows::add_recipe(xgb_recipe) ``` Although the XGBoost algorithm works quite well with the default hyper-parameters, we will use the validation dataset to tune some of its hyper-parameters to get the best performance out of it. As can be seen below, after tuning the best model performs quite well in terms of AUC, which has a value of around 0.8. ```r # uploading libraries library(tune) library(yardstick) # tuning hyper-parameters set.seed(123) xgb_tuning <- xgb_workflow %>% tune::tune_grid( data_val, grid = 25, control = control_grid(save_pred = TRUE), metrics = yardstick::metric_set(roc_auc) ) # selecting the best combination of hyper-parameters xgb_best <- xgb_tuning %>% tune::select_best(metric = "roc_auc") # best model performance on validation dataset as measured by AUC ( xgb_tuning %>% tune::collect_predictions(parameters = xgb_best) %>% yardstick::roc_auc(truth = Attrition, .pred_1) ) # plotting the ROC curve xgb_tuning %>% tune::collect_predictions(parameters = xgb_best) %>% yardstick::roc_curve(truth = Attrition, .pred_1) %>% autoplot() ``` Now we can set up the final model training workflow and fit the model to the entire training dataset and check its performance on out-of-sample data using k-fold cross-validation and testing dataset. As we see below, in both cases the model performance as measured by AUC is around the value of 0.8. ```r # setting the final model final_xgb_model <- parsnip::boost_tree(mtry = xgb_best$mtry, min_n = xgb_best$min_n, tree_depth = xgb_best$tree_depth, trees = 1000) %>% parsnip::set_engine("xgboost") %>% parsnip::set_mode("classification") # updating the model training workflow final_xgb_workflow <- xgb_workflow %>% workflows::update_model(final_xgb_model) # fitting model on train set set.seed(123) xgb_fit <- final_xgb_workflow %>% fit(data_train) # checking the final model's performance (AUC) using k-fold cross-validation set.seed(123) folds <- rsample::vfold_cv(data_train, v = 10) set.seed(123) xgb_fit_kf <- final_xgb_workflow %>% tune::fit_resamples(folds) ( tune::collect_metrics(xgb_fit_kf, summarize = TRUE) %>% dplyr::filter(.metric == "roc_auc") ) # checking the final model's performance (AUC) using the testing dataset xgb_testing_pred <- predict(xgb_fit, data_test) %>% bind_cols(predict(xgb_fit, data_test, type = "prob")) %>% dplyr::bind_cols(data_test %>% select(Attrition)) ( xgb_testing_pred %>% yardstick::roc_auc(truth = Attrition, .pred_1) ) ``` Now we're ready to explore the inner workings of our trained model. After setting up the explainer from the DALEX package, we simply insert this object into the modelStudio function and run it. We then get an interactive interface in our browser that we can use to easily check what our model is doing to make its predictions. You can try it out for yourself using the interactive interface below. ```r # uploading libraries library(DALEX) library(modelStudio) # setting up the DALEX explainer object # creating predict function pred <- function(model, newdata) { results <- (predict(model, newdata, type = "prob")[[1]]) return(results) } explainer <- DALEX::explain( model = xgb_fit, data = data_test %>% mutate(Attrition = as.integer(Attrition)), y = data_test %>% mutate(Attrition = as.integer(Attrition)) %>% pull(Attrition), predict_function = pred, type = "classification", verbose = FALSE ) # running ModelStudio modelStudio::modelStudio( explainer, max_features = 100, # Maximum number of features to be included in BD, SV, and FI plots N = 300, # Number of observations used for the calculation of PD and AD new_observation_n = 3, #Number of observations to be taken from the data show_info = TRUE, viewer = "browser", facet_dim = c(2,2) # layout of the resultiing charts ) ``` ## Figures ![](./interpretable-ml/unnamed-chunk-4-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-13-interpretable-ml/) on my blog. --- # Estimating the impact of a new business app by piloting & method of difference-in-differences URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/app-piloting-and-dif Date: 2023-04-11 Tags: experiments, difference-in-differences, people-analytics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-app-piloting-and-dif/ When considering the introduction of a new business app, one of the benefits of piloting it is that it provides a great opportunity to test its causal impact on business processes or outcomes of interest. For example, in our pilots, apps are typically implemented for 3 or 6 months only in some teams and not in others. Such a situation creates ideal conditions for applying the difference-in-differences (DiD) method, which is used to approximate an experimental research design with observational data only. To use one specific example, one of the problems addressed by our Sales Analytics app is that sales reps spend more time collaborating internally instead of communicating with prospects. The premise may be that the better visibility into time spent that the app enables will help sales reps and their managers better plan activities during their regular weekly 1-on-1 meetings, all with (hopefully) a positive impact on time spent with prospects. By piloting the app in just one team and finding another team with a similar, parallel trend in the selected criterion, we can try to estimate its effectiveness. As shown in the attached chart, the fitted DiD model in this particular case slightly supports the effectiveness of the app, at least in terms of the amount of time spent with prospects, but can easily be switched to another criterion that is closer to the company’s bottom line, e.g., the number of closed deals. Moreover, if we are aware of certain systematic differences between the teams, such as the experience level of the sales reps, we can include relevant control variables in the model to achieve a more accurate estimation of the app's effectiveness. ![](./app-piloting-and-dif/plots.png) So, next time you consider introducing a new business app, consider piloting it in combination with the DiD method to better understand its impact on your organization's goals. Happy piloting! ✌️ --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-app-piloting-and-dif/) on my blog. --- # Impact of employee satisfaction at work on a company's bottom line URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/employee-satisfaction-and-company-bottom-line Date: 2023-04-11 Tags: performance, employee-satisfaction, meta-analysis, i-o-psychology Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-employee-satisfaction-and-company-bottom-line/ There is now little doubt that employee attitudes, such as employee satisfaction or employee engagement, are to some extent related to performance - for some evidence in support of this claim, see, for example, the meta-analysis of the relationships between employee satisfaction, employee engagement and business unit-level performance conducted by [Harter, Schmidt, & Hayes (2002)](https://psycnet.apa.org/doiLanding?doi=10.1037%2F0021-9010.87.2.268). But does it mean that higher employee satisfaction causes higher performance? As any statistically savvy person knows, not necessarily. Fortunately, there are research designs that can help us untangle this conundrum a bit. One of these designs is path analysis of longitudinal/time-series data. This approach was also taken by [Harter et al. (2010)](https://journals.sagepub.com/doi/abs/10.1177/1745691610374589) in their meta-analysis “Causal Impact of Employee Work Perceptions on the Bottom Line of Organizations” with the following results: “*Using a massive longitudinal database that included 2,178 business units in 10 large organizations, we found evidence supporting the causal impact of employee perceptions on [...] bottom-line measures [customer loyalty, employee retention, revenue, and profit]; reverse causality of bottom-line measures on employee perceptions existed but was weaker.*” The attached figure with the two alternative path models clearly shows that the causal path from employee perceptions to outcomes is stronger than the other way around, especially when it comes to theoretically more proximal outcomes such as employee retention and customer loyalty. ![](./employee-satisfaction-and-company-bottom-line/path_charts.png) Despite not being free of potential biases, these results add more weight to the argument that investing in improving employee job satisfaction also makes sense for improving a company's bottom line, rather than "only" improving employee well-being. The question now is what interventions to improve employee satisfaction might work. Let’s review the available evidence on this issue in one of the next posts. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-employee-satisfaction-and-company-bottom-line/) on my blog. --- # Effectiveness of interventions for encreasing employee engagement URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/engagement-interventions Date: 2023-04-11 Tags: employee-engagement, causal-inference, meta-analysis, i-o-psychology Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-engagement-interventions/ There is some evidence that engagement of employees has positive causal impact on the bottom line of organizations (see, for example, the meta-analysis by [Harter et al. (2010)](https://journals.sagepub.com/doi/abs/10.1177/1745691610374589), however, be aware of the specific definition of engagement used there, which focuses more on the contextual factors and conditions enabling engagement and less on the psychological states of engagement). Consequently, we might be naturally interested in whether we can positively influence engagement of employees through the use of certain interventions. Based on a systematic review and meta-analysis of studies with controlled workplace interventions by [Knight, Patterson, and Dawson (2017)](https://pubmed.ncbi.nlm.nih.gov/28781428/), it seems the answer might be yes. The authors found a small positive effect on work engagement and each of its three sub-components: vigor, dedication, and absorption, as measured by the [Utrecht Work Engagement Scale](https://www.wilmarschaufeli.nl/publications/Schaufeli/Test%20Manuals/Test_manual_UWES_English.pdf) (UWES) from Bakker and Schaufeli. When it comes to the types of intervention (personal resource building, job resource building, leadership training, and health promotion), a moderator analysis did not find evidence for their differing effectiveness. However, there was evidence for a medium to strong effect of intervention style in favor of group interventions (vs. individual), with the possible explanation being that group interventions effectively influence certain work engagement antecedents, such as social support and influence in decision-making. ![](./engagement-interventions/metaanalysis_results.png) Regarding the sustainability of effects, there was no significant effect of time in the case of overall work engagement. However, for the vigor sub-component, there were stronger effects immediately post-intervention than at follow-up, with the opposite being true for dedication and absorption sub-components. Have you implemented any interventions to boost engagement of employees in your organization? Did you measure their effectiveness? What were your experiences and results? Feel free to share your thoughts in the comments below. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-engagement-interventions/) on my blog. --- # Excel + Python = Word Document URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/excel-and-python Date: 2023-04-11 Tags: data-science, python, ai Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-excel-and-python/ In the spirit of "Excel isn't dead and is actually doing well" posts, I'm sharing one practical example of combining Excel and Python to do one specific job. It's not as exciting and sexy as ChatGPT, but it may still come in handy for someone, as it did for one of my friends. A friend of mine who works in psychological counselling has to write a large number of reports which, among other things, contain many recommendations for various compensations and interventions depending on established diagnosis. To make his job easier, he created a simple Excel spreadsheet with a list of diagnoses and corresponding recommendations. He needed to generate a simple Word document listing and describing the appropriate compensations and interventions after he had marked the appropriate diagnoses for the client in Excel. I originally wanted to do it all in Excel, but since I'm not the best friend with VBA, I couldn't get rid of the various text formatting issues. So I reached for Python and one of its document-related libraries and linked it via macro to Excel, which acts only as a source of input data, based on which Python generates a simple report when a button is pressed in Excel. Once generated, the document is ready for further editing and tuning. If you are interested in technical details, you can check [my GitHub page](https://github.com/lstehlik2809/Excel-and-Python-Combination-for-Document-Generation.git). May the Excel be with you 🙂 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-excel-and-python/) on my blog. --- # Employee outcomes & employees' job attitudes URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/job-attitudes-and-employee-outcomes Date: 2023-04-11 Tags: employee-satisfaction, performance, employee-turnover, i-o-psychology Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-job-attitudes-and-employee-outcomes/ If you've ever measured employee job attitudes (i.e. constructs such as organizational commitment, procedural justice, distributive justice, job involvement, job engagement, job satisfaction, etc.), you probably won't be surprised to learn that job attitudes are usually quite strongly correlated. This is also the conclusion of a meta-analytic review of job attitudes by [Woznyj et al. (2022)](https://www.researchgate.net/publication/357568433_Job_Attitudes_A_Meta-Analytic_Review_and_an_Agenda_for_Future_Research), which showed that job attitudes are moderately to strongly correlated with each other, with most relations falling between ρ = .50 and .69. Despite this, relative weights and incremental validity analyses revealed that some attitudes have greater validity in predicting key employee outcomes. As shown in the table attached, * performance is most strongly predicted by job satisfaction, job engagement, and distributive justice (an employee's perceived fairness of outcomes), * turnover intentions is most strongly predicted by job satisfaction, perceived organizational support (a general evaluation regarding the extent to which employees feel their organization values their contribution and cares about their well-being), and distributive justice, and * organizational citizenship behaviors is most strongly predicted by job engagement, procedural justice (perceived fairness of the means, or procedures, used to determine outcomes), and job involvement (the degree to which a person identifies psychologically with his or her work, or the importance of work in his or her total self-image). ![](./job-attitudes-and-employee-outcomes/table.png) IMO, knowing this can be quite useful in planning what candidate constructs to measure in your company in an effort to support specific employee outcomes. What job attitudes do you regularly measure in your company? And does it pay off in any way, i.e. does it have any tangible impact? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-job-attitudes-and-employee-outcomes/) on my blog. --- # Where do managers put on their rose-tinted glasses the most? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/managers-overconfidence Date: 2023-04-11 Tags: people-management, personality, teams Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-managers-overconfidence/ The "illusory superiority" or "better-than-average effect" causes individuals to overestimate their abilities compared to others. Unsurprisingly, even managers and leaders fall for this trap. After all, they're human too, right? 😉 For a review of studies on this topic, see, for example, the systematic review by [Heavey et al. (2022)](https://journals.sagepub.com/doi/full/10.1177/01492063211062566). To pinpoint particular areas where managers and leaders are prone to overconfidence, data from the [Team Assessment Survey](https://www.therocketmodel.com/team-assessment-survey) (TAS), a team assessment tool rooted in the [Rocket Model of team performance](https://www.therocketmodel.com/what-is-rocket-model), can be valuable as it enables comparisons between how team leaders and team members perceive their team's functioning. For example, based on data from a Slovak sample of 85 teams with 835 members, it seems that team leaders rate their team's effectiveness way better than team members in the following five areas in descending order: 1. **Resources:** Does the team have the budget, equipment, authority, and political capital it needs to accomplish its goals? 2. **Talent:** Is the team sized correctly? Are team members’ roles clear? Does the team have the right skills to succeed? Are people developing new skills? Do rewards encourage or discourage teamwork? 3. **Context:** Are team members in agreement about the team’s political and economic realities, customers, competitors, suppliers, and key assumptions and challenges? 4. **Mission:** What is the team’s purpose? What are its goals? How does the team define winning? What are its strategies and plans for accomplishing its goals? 5. **Courage:** Do team members trust each other? Is there an optimal level of tension and collaboration on this team? Do team members challenge each other in a constructive manner? ![](./managers-overconfidence/plot.png) When managers get overconfident in these areas, it can lead to all sorts of issues like underinvestment in critical resources, inefficient resource allocation, ignoring skill gaps, insufficient role clarity, making bad decisions, disjointed strategic planning, confusion, misaligned priorities, uncoordinated efforts, blocking innovation, etc. What's the fix? Among other things, managers need to be aware of their own biases and work on open communication, feedback, and collaboration with team members. Easier said than done, but it's crucial to avoid hurting the performance of the team. Obviously, the size and demographics of the sample used are limited and conclusions are therefore difficult to generalize, but I may try to check with [Dr. Gordon Curphy](https://www.linkedin.com/in/gordoncurphy/), the author of the Rocket Model of team performance and TAS, to see if this pattern is also replicated in a larger, international sample. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-managers-overconfidence/) on my blog. --- # Are there meeting “yes-men”? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/meeting-yes-men Date: 2023-04-11 Tags: meetings Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-meeting-yes-men/ To test this hypothesis, they looked at the monthly number of meetings people attended and the relative frequency of their responses to meeting invitations. Here's what they found: * People seemed to be similarly explicit in signaling their intentions about the meetings they were invited to, regardless of how many meetings they had on their plate. * People accepted fewer meeting invites the more meetings they attended. * Those who went to a bunch of meetings were more likely to say they're not sure if they can make it or not. * People who were busy with meetings declined more meeting invites than those who had fewer meetings. ![](./meeting-yes-men/plots.png) Thus, contrary to initial expectations, the data showed that people who attended more meetings, on average, tended to accept fewer invites, were more likely to be unsure about their availability, and actually declined more invites than those with fewer meetings. While the client couldn’t rule out that there might be some individuals fitting "yes-men" description in their company (in fact, one can easily spot a few people in the corresponding chart who attended many meetings and at the same time underutilized the option of declining the meeting invites), these results suggested that there isn't a systematic problem in this specific area. Time for our client to explore other avenues through which the problem with meeting overload could be addressed. More on that in some of the next posts. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-meeting-yes-men/) on my blog. --- # Ask your personality using GPT URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/openai-personality-interpretation Date: 2023-04-11 Tags: generative-ai, ai, personality, shiny-app, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-04-11-openai-personality-interpretation/ One of my friends recently asked me if I could provide him with an interpretation of a free personality test he took on the internet. As a joke, I asked him if he had already tried using GPT for this. This sparked my interest in how GPT would actually handle this kind of task. So, I provided it with my Hogan Personality Inventory (HPI) profile (in percentile scores, as requested), and to my surprise, it performed quite well - even when asked about more complex questions like interactions between my scores on selected scales or my strengths and weaknesses for specific jobs and tasks. Based on this experience, I created a simple POC app where users can input their HPI profile and some contextual information, such as their current or desired job, and ask GPT predefined or their own questions about their personality. Link to the app: https://aanalytics.shinyapps.io/ask_your_personality/ ![](./openai-personality-interpretation/appScreenshot.png) I've only tested it on a few profiles, so if you know your HPI profile (or your Big Five traits that are behind HPI), I would be happy to hear how you perceive the face validity and potential usefulness of the generated interpretations. Perhaps I have just become a victim of the well-known Barnum effect 😉 I am well aware that there are clear risks associated with using a generic GPT for such a task. However, I believe that with proper fine-tuning, an explicit disclaimer, and access to a qualified professional for possible consultation, it could be a useful tool for helping people gain self-awareness more easily - in many situations a crucial prerequisite for high-quality decisions. What do you think? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-04-11-openai-personality-interpretation/) on my blog. --- # Effectiveness of micro-breaks at work URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/micro-breaks Date: 2023-03-05 Tags: well-being, performance, meta-analysis Original post: https://blog-about-people-analytics.netlify.app/posts/2023-03-05-micro-breaks/ If you feel you need a short break, take one - even if it is no longer than 10 minutes, it can increase your perceived well-being and some types of performance. As the authors of the study, [Albulescu et al. (2022)](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9432722/#!po=23.4300), conclude: “*Our results revealed that micro-breaks are efficient in preserving high levels of vigor and alleviating fatigue. It seems that the effects are univocal and generalizable for the well-being outcomes. These were relatively homogeneous, and none of the included moderators were significant. Hence, the data suggest that micro-breaks may be a panacea for fostering well-being during worktime.* *When it comes to performance, the data revealed some nuances. The break duration was a significant covariate of the effect of micro-breaks: the longer the break, the better the performance. Moreover, the type of task from which participants were taking the break also emerged as a significant moderator. Micro-breaks could significantly increase performance for clerical work or creative exercises and not for a cognitively demanding task.*” There is nothing like having a meta-analysis to back up your habits, or better yet, initially bad habits 😉 ![](./micro-breaks/plots.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-03-05-micro-breaks/) on my blog. --- # Not-so-hidden cost of working in an office URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/distracted-time Date: 2023-03-01 Tags: remote-work, collaboration, time-management Original post: https://blog-about-people-analytics.netlify.app/posts/2023-03-01-distracted-time/ We have only recently started to measure the **allocation of work time to collaborative and non-collaborative activities** at [Time is Ltd.](https://www.timeisltd.com/). ![](./distracted-time/distracted_time_WFH.png) When checking the resulting numbers in the context of **where people work from**, it wasn't that much of a surprise that people spend less time (on average ~37 minutes less per person per day) on collaborative activities when working remotely vs. in the office. Such a pattern is probably a good thing in many cases as it means that people have more time for focused work when working remotely, and use their time for intensive collaboration when in the office. What was quite surprising to me, however, was the rather large difference in the amount of **distracted time**. When working in the office there was much more distracted time, i.e. time when people were working on their tasks while being distracted by various collaborative activities. Therefore, they didn't have enough time to get into the flow. On average, the difference was a staggering ~69 minutes per person per day. However, when I thought about it a bit more (and also after I realized how we actually calculate distraction time️), it started to make more sense to me. After all, collaborative activities don't just "rob" us of time per se, they also fragment our available time into smaller chunks, which carries a cost in the form of **cognitive overhead** associated with **task switching**. I suppose it's a **trade-off** that can't be completely solved in principle, but can only be mitigated with tools like **batching** or **timeboxing**. What is your own experience with this phenomenon and what tools do you use to deal with it? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-03-01-distracted-time/) on my blog. --- # Using ChatGPT to summarize and explore employee feedback? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/chatgpt-and-employee-feedback Date: 2023-02-26 Tags: generative-ai, ai, hr-management, people-analytics, employee-engagement, employee-survey Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-26-chatgpt-and-employee-feedback/ IMHO, ChatGPT and similar tools may be quite useful in the near future (and probably to some extent even today) to quickly summarize and explore the feedback provided by employees through open-ended questions in employee engagement and satisfaction surveys. I just experimented with a small sample of anonymized employee feedback (just dozens of lines of text from a question about what employees would change in the company) and asked ChatGPT to summarize the feedback, identify the main topics covered in the feedback, and provide me with details about the selected topics - see the attached image of the conversation for illustration. ![](./chatgpt-and-employee-feedback/chatgpt_employee_feedback.png) Just based on my very limited sample, I found that there was a pretty good balance between information compression and accuracy, while the interaction was very natural, similar to asking HRBP what the results of the last ESS were for my department/team. Certainly, there are still too many known and unknown risks associated with these tools to rely blindly on them alone, but I can imagine that in the foreseeable future, when many of these risks are successfully mitigated, this will be one of the ways managers will listen to the voice of their people. Has anyone experimented with ChatGPT on similar kinds of HR data? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-26-chatgpt-and-employee-feedback/) on my blog. --- # Where to look first when considering meeting reset? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/large-and-recurring-meetings Date: 2023-02-26 Tags: meetings Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-26-large-and-recurring-meetings/ When thinking about the meeting reset like they did in [Asana](https://hbr.org/2022/10/meeting-overload-is-a-fixable-problem), IMHO, it's not a bad idea to focus on one specific category of meetings first. I am thinking of **large recurring meetings** that combine two big time wasters, and thus hide a greater opportunity for time savings: 1. **Large meetings** are quite often a waste of people’s time as they don’t allow everyone to meaningfully contribute. These meetings often serve only to disseminate information and can therefore be safely replaced by less intrusive asynchronous collaboration tools such as email, instant messaging or some kind of knowledge management tool. 2. **Recurring meetings** have their place, but often tend to outlive their purpose over time and waste the time of everyone involved. One should therefore regularly ask oneself the following questions in this context and act accordingly: Are meetings being planned automatically, rather than out of necessity? Are attendees invited as a formality, or will they bring value? Is there still clear agenda for these meetings? Are we already sufficiently aligned or do we need some additional platform for doing so? Does the current frequency of our meetings meet our needs? etc. As illustrated in the attached chart, large and recurring meetings can represent a relatively large chunk of time in an employee's work month. Large meetings here account for approximately 37% of all meeting time and more than 80% of large meetings are recurring. This represents a relatively large room for optimizing the time spent in meetings. ![](./large-and-recurring-meetings/meetingSizeRecurrenceFrequency.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-26-large-and-recurring-meetings/) on my blog. --- # Attaching numbers to best practices for instant messaging URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/slack-best-practices Date: 2023-02-09 Tags: communication, evidence-based-management Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-09-slack-best-practices/ As many of you can probably confess, Slack and other instant messaging (IM) platforms can be both a blessing and a curse. To support the former and suppress the latter, authors of these tools and their users themselves have come up with several recommendations on how to use them effectively. Specific numbers can be attached to some of these best practices to help teams and entire companies systematically shape their behavior on IM platforms in the desired direction. At [Time Is Ltd.](https://www.timeisltd.com/), we currently measure the following seven best practices: 1. **Thread use:** It helps create organized discussions around specific messages, and they let users discuss a topic in more detail without adding clutter to a channel or direct message conversation. 2. **Mention use:** Mentioning specific people in messages in both public and private channels is one effective way to avoid overwhelming users with a large number of messages that are not relevant to them. 3. **Short messages use:** Shorter messages often mean more messages, more messages mean more notifications, and more notifications mean more distractions, more frequent context switching, and decreased productivity. 4. **Emoji use:** People should use emojis instead of short messages as they are less distracting and more friendly to other people's attention. 5. **Batching:** Responding to chat messages round the clock can be detrimental to employees' productivity as it can distract them from focused work. Better strategy is checking messages every one or two hours instead of continuous handling of all incoming messages. 6. **Inactive channels:** Non-archived channels that show no activity are just clutter that makes it difficult to navigate and collaborate on the chat platform. 7. **Transparency:** Direct and group messages have their place in chat, especially when discussing sensitive issues or when trying to avoid spamming other employees. However, when majority of chat communication occurs in direct and group messages, there is a higher risk that information important for task alignment, problem-solving, or decision will be hidden in them and out of view from relevant people. ![](./slack-best-practices/metrics.png) Would you add some other best practices for IM that have worked well for you and that would make sense to measure? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-09-slack-best-practices/) on my blog. --- # HRM value chain and structural equation modeling - Moneyball case URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/hrm-value-chain-and-sem Date: 2023-02-08 Tags: hr-management, people-analytics, structural-equation-modeling, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-hrm-value-chain-and-sem/ As the economy cooled, more posts started appearing in my social bubble about how people analytics can prove its value, justify its existence, and increase its resilience to layoffs. One of the often-mentioned conceptual tools in this context is the **HRM value chain** which shows how people processes lead to achieving companies’ desired business outcomes. There are more versions of this tool, e.g. one from [Jaap Paauwe and Ray Richardson](https://www.tandfonline.com/doi/abs/10.1080/095851997341621?journalCode=rijh20) or another one from [Max Blumberg](https://www.hrzone.com/lead/strategy/why-hr-needs-to-up-its-game-in-strategic-people-analytics), but what they have in common is a kind of causal flow from HRM activities to HRM outcomes to business outcomes. ![](./hrm-value-chain-and-sem/hrmValueChain1.png) *Example of the HRM value chain model from [Paauwe & Richardson (1997)](https://www.tandfonline.com/doi/abs/10.1080/095851997341621?journalCode=rijh20).*
![](./hrm-value-chain-and-sem/hrmValueChain2.png) *Example of the HRM value chain model from [Blumberg (2018)](https://www.hrzone.com/lead/strategy/why-hr-needs-to-up-its-game-in-strategic-people-analytics).* However, as potentially useful as this metaphor of the organization as a kind of "machine" with certain inputs, processes, and outputs is, it is still only a conceptual tool that may or may not correspond to the reality of a particular organization. In this regard, it may help if we try to operationalize this metaphor. In this effort, **[structural equation modeling](https://en.wikipedia.org/wiki/Structural_equation_modeling)** can be very handy, as it allows us to formalize our ideas about the relationships between several different variables and to assess the extent to which these ideas are consistent with the available data. After all, no one wants to make decisions based on false assumptions. To illustrate this with a more tangible example, let’s use [sabermetric](https://en.wikipedia.org/wiki/Sabermetrics) data from the famous **[Moneyball](https://en.wikipedia.org/wiki/Moneyball)** case and let’s try to formally model the [Oakland A's](https://en.wikipedia.org/wiki/Oakland_Athletics) (OAK) as a “machine” that produces playoffs by trying to win more games or score more points than opposing teams, using inputs in the form of players' ability to play well at bat and in the field. Based on our expert knowledge of the game of baseball, our working hypotheses, and the results of some previous analyses, we can construct a conceptual model of how a baseball team functions, as outlined below. ![](./hrm-value-chain-and-sem/conceptualModel.png) *Conceptual model of how a baseball team works.* The diagram shows clearly how this “machine” works: Its outputs are qualifications for the playoffs, which it achieves by trying to win more games or score more points than the opposing teams; to do this, it uses inputs in the form of the players' ability to play well at bat and in the field; the inputs affecting the “machine's” operation are also the similar abilities of the opposing teams' players. This is, of course, a very simplistic causal model of how the OAK team operates, but as the famous statistical aphorism states, [all models are wrong, but some are useful](https://en.wikipedia.org/wiki/All_models_are_wrong). However incomplete our models of how an organization works will always be, it is essential to check that these models sufficiently reflect reality as conveyed by the available data. Only after such an assessment of the plausibility of the model it is reasonable to base further, e.g. hiring or L&D decisions on it. And, as hinted at the beginning of this post, we will use the statistical method of structural equation modeling to do this. So let's apply this method to our model of the OAK and test its plausibility. We will use data from a publicly available [database of MLB historical statistics](https://drive.google.com/file/d/0B5YUO0Asd9KfMG1UaXpEVjJqQWc/view). Specifically, we will use the following variables: * qualification for the playoffs in a given season (*Playoffs*), * number of wins in a given season (*W*), * number of points won in a given season (*RS*), * number of points lost in a given season (*RA*), * average frequency with which a player reaches base per plate appearance in a given season (*OBP* - On-Base Percentage) and analogous statistics for opponent teams (*OOBP*), * average number of bases players earn per at bat in a given season (*SLG* - Slugging Percentage) and analogous statistics for opponent teams (*OSLG*). In terms of time, we will work with data from the years 1996-2001, which precede 2002, when the story of Moneyball mostly takes place. ```r # uploading set of libraries for data manipulation and visualization library(tidyverse) # uploading data baseball <- readr::read_csv("./baseball.csv") # filtering data used for the analysis myData <- baseball %>% dplyr::filter( Year < 2002, Year > 1995 ) %>% dplyr::select(Team, OBP, SLG, OOBP, OSLG, RS, RA, W, Playoffs) # user-friendly table with data used for the analysis DT::datatable( myData, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` Now let's define a formal model of the OAK and fit it to the data using the [brms R package](https://cran.r-project.org/web/packages/brms/index.html) which allows us to make inferences about the model parameters within a [Bayesian inferential framework](https://nyu-cdsc.github.io/learningr/assets/kruschke_bayesian_in_R.pdf). Specifically, we will build and fit a so-called [path analysis](https://en.wikipedia.org/wiki/Path_analysis_(statistics)) model, which is a special type of structural equation modeling used to describe directed dependencies among a set of variables. Given that we have data for a group of the same teams over several seasons, we must also incorporate the hierarchical nature of the data into the model. ```r # uploading libraries for fitting Bayesian generalized (non-)linear multivariate multilevel models using Stan for full Bayesian inference library(brms) library(cmdstanr) # specifying individual parts of the SEM (without modeling the correlation between response variables and using Student's t distribution instead of the Gaussian distribution to make the model more robust) a <- brms::bf(W ~ 1 + RS + RA + (1 + RS + RA | Team), family = student()) b <- brms::bf(RA ~ 1 + OOBP + OSLG + (1 + OOBP + OSLG | Team), family = student()) c <- brms::bf(RS ~ 1 + OBP + SLG + (1 + OBP + SLG | Team), family = student()) d <- brms::bf(Playoffs ~ 1 + W + (1 + W | Team), family = bernoulli()) # fitting the model fit <- brms::brm( a + b + c + d + set_rescor(FALSE), data = myData, iter = 3000, chains = 3, warmup = 500, thin = 1, seed = 123, cores = 5, backend = "cmdstanr", refresh = 0, silent = 2 ) # checking MCMC convergence # plot(fit) # summary(fit) ``` After verifying that the mechanics of the MCMC algorithm work well (not shown here for brevity reasons), we should also verify how well the model fits the data by using the posterior predictive check for each of the observed response variables in our model. ```r # uploading library for arbitrarily complex composition of ggplot plots library(patchwork) # posterior predictive check for all predicted observed variables playoffsPpc <- brms::pp_check(fit, resp = "Playoffs", ndraws = 100) + ggplot2::labs(title = "Playoffs variable") wPpc <- brms::pp_check(fit, resp = "W", ndraws = 100) + ggplot2::labs(title = "W variable") rsPpc <- brms::pp_check(fit, resp = "RS", ndraws = 100) + ggplot2::labs(title = "RS variable") raPpc <- brms::pp_check(fit, resp = "RA", ndraws = 100) + ggplot2::labs(title = "RA variable") ppc <- (playoffsPpc + wPpc) / (rsPpc + raPpc) print(ppc) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-3-1.png) As you can see, the model fits the data pretty well. Now we can look at the coefficients in the individual parts of the model. All of them show non-zero values and are in directions that are consistent with our expectations embodied in our conceptual model, i.e., all are positive except for the coefficient of the *RA* variable (number of points lost) as a predictor of the number of games won (*W*). These results thus give us greater confidence in following our conceptual model of team functioning when making decisions about allocating our limited resources. ```r # uploading library for plotting functions for posterior analysis, MCMC diagnostics, prior and posterior predictive checks, and other visualizations to support the applied Bayesian workflow library(bayesplot) # getting overview of all parameters # get_variables(fit) # relevant parameters: "b_RS_OBP", "b_RS_SLG", "b_RA_OOBP", "b_RA_OSLG", "b_W_RS", "b_W_RA", "b_Playoffs_W" b_RS_OBP <- bayesplot::mcmc_areas( fit, pars = "b_RS_OBP", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "OBP variable in the RS ~ OBP part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_RS_SLG <- bayesplot::mcmc_areas( fit, pars = "b_RS_SLG", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "SLG variable in the RS ~ SLG part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_RA_OOBP <- bayesplot::mcmc_areas( fit, pars = "b_RA_OOBP", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "OOBP variable in the RA ~ OOBP part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_RA_OSLG <- bayesplot::mcmc_areas( fit, pars = "b_RA_OSLG", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "OSLG variable in the RA ~ OSLG part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_W_RS <- bayesplot::mcmc_areas( fit, pars = "b_W_RS", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "RS variable in the W ~ RS part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_W_RA <- bayesplot::mcmc_areas( fit, pars = "b_W_RA", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "RA variable in the W ~ RA part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) b_Playoffs_W <- bayesplot::mcmc_areas( fit, pars = "b_Playoffs_W", prob = 0.95, point_est = "median" ) + ggplot2::labs( title = "W variable in the Playoffs ~ W part" ) + ggplot2::theme( axis.text.y = element_blank(), axis.ticks.y = element_blank() ) coefCharts <- (b_RS_OBP + b_RS_SLG + b_RA_OOBP) / (b_RA_OSLG + b_W_RS + b_W_RA) / (b_Playoffs_W + patchwork::plot_spacer() + patchwork::plot_spacer()) + plot_annotation( title = 'Posterior interval estimations', caption = "The solid vertical lines represent the point (median) estimates and the shaded areas represent the 95% credible interval.", theme = theme( plot.title = element_text(size = 18), plot.caption = element_text(size = 11) ) ) print(coefCharts) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-4-1.png) ```r # summary of the fitted model summary(fit) ``` However, the main advantage of such a formalized model is that we can now make specific predictions or simulations that will help us make more granular decisions about where and how much to invest our limited resources, e.g. in terms of hiring and/or L&D activities. For example, we know from the available data that teams need to win approximately 95 games to have a high chance of making the playoffs - see the two graphs below. ```r # wins to make playoffs - chart 1 set.seed(1234) myData %>% # creating random numbers for dispersing points across the y axis of the graph dplyr::mutate(rnd = runif(176,0,1)) %>% ggplot2::ggplot(aes(x = W, y = rnd, color = as.factor(Playoffs))) + ggplot2::geom_point(size = 3, alpha = 0.8) + ggplot2::scale_x_continuous(limits=c(50,120), breaks = seq(50,120,5)) + ggplot2::scale_color_manual(values = c("#9e9e9e", "#ff1919"), labels = c("The team has not made it to the playoffs", "The team has made it to the playoffs")) + ggplot2::labs( title = "Teams that didn't/made the playoffs from 1996-2001", x = "NUMBER OF GAMES WON IN THE REGULAR SEASON", y = "", color = "" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_blank(), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_blank(), legend.position="top", legend.key = element_rect(fill = "white"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major.x = element_line(color = "#E0E1E6", linetype = "dashed"), panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_blank(), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-6-1.png) ```r # wins to make playoffs - chart 2 myData %>% # dividing W variable into intervals dplyr::mutate( WCat = cut(W, breaks = 21, right = TRUE, include.lowest = TRUE, ordered_result = TRUE) ) %>% dplyr::group_by(WCat) %>% dplyr::summarise( PlayoffsProb = mean(Playoffs) ) %>% ggplot2::ggplot(aes(x = WCat, y = PlayoffsProb)) + ggplot2::geom_bar(stat = "identity", color = NA, fill = "lightblue") + ggplot2::scale_y_continuous(limits=c(0,1), breaks = seq(0,1,0.1), labels = scales::percent_format()) + ggplot2::labs( x = "NUMBER OF GAMES WON IN THE REGULAR SEASON", y = "PROBABILITY OF MAKING IT TO THE PLAYOFFS", title = "Relation between the number of wins in the regular season and the probability\nof advancing to the playoffs (1996-2001)" ) + ggplot2::coord_flip() + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position="top", legend.key = element_rect(fill = "white"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major.x = element_blank(), panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-7-1.png) Considering the last known OAK performance statistics from 2001, what number of games can OAK expect to win and what is the likelihood of making the playoffs next year? Let's plug the 2001 OAK numbers into the model and check the prediction including all uncertainties. ```r # summary prediction of the number of matches won by OAK in 2002 using OAK's statistics from 2001 as input # set.seed(123) # predict( # fit, # resp = "W", # newdata = baseball %>% dplyr::filter(Year == 2001, Team == "OAK") %>% dplyr::select(Team, RS, RA, W, Playoffs, OBP, SLG, OOBP, OSLG) # ) # generating predictions using OAK's statistics from 2001 as input set.seed(123) wp <- brms::posterior_predict( fit, resp = "W", newdata = baseball %>% dplyr::filter(Year == 2001, Team == "OAK") %>% dplyr::select(Team, RS, RA, W, Playoffs, OBP, SLG, OOBP, OSLG) ) # actual number of matches won by OAK in 2002 realNWins <- baseball %>% filter(Year == 2002, Team == "OAK") %>% pull(W) # uploading library for visualizations of distributions and uncertainty library(ggdist) # creating the graph wp %>% as.data.frame() %>% ggplot2::ggplot(aes(x = V1)) + ggdist::stat_halfeye( fill = "lightblue", .width = c(0.80, 0.95), ) + ggplot2::geom_vline(xintercept = realNWins, linetype = "dashed") + ggplot2::geom_text(x = realNWins+8.3, y = 1, label = "Actual number of matches won by OAK in 2002") + ggplot2::scale_x_continuous(breaks = seq(80,120,5)) + ggplot2::labs( x = "PREDICTED NUMBER OF MATCHES WON (W)", y = "DENSITY", linetype = "", title = "Predicted number of matches won by OAK in 2002", caption = "\nThe black horizontal lines at the bottom of the graph represent the 80% and 95% probability intervals, respectively." ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "none", legend.key = element_rect(fill = "white"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-8-1.png) We can see that the 95% probability interval of the predicted number of games won by the OAK in 2002 is safely above the 95-point threshold. And when we compare the prediction to the 2002 reality, we see that they are pretty close. Also, the projected probability of OAK advancing to the playoffs is very high, which is consistent with the fact that OAK successfully qualified for the playoffs in 2002. ```r # prediction of OAK making the playoffs in 2002 using OAK's statistics from 2001 as input set.seed(123) predict( fit, resp = "Playoffs", newdata = baseball %>% dplyr::filter(Year == 2001, Team == "OAK") %>% dplyr::select(Team, RS, RA, W, Playoffs, OBP, SLG, OOBP, OSLG), probs = c(0.025, 0.5, 0.975) ) ``` Based on this information, we may conclude that there is no urgent need to invest heavily now in increasing the current quality of the team's play at bat and in the field (variables *OBP* and *SLG*), assuming that the opposing teams do not significantly increase the quality of their play next year (variables *OOBP* and *OSLG*). Using the model, we can also obtain a specific range of the quality of the OAK’s play at bat and in the field that would still allow it to reach at least 95 games won. Such information could be useful, for example, in deciding which players can be safely traded without having a detrimental effect on the likelihood of making the playoffs. ```r # simulation of the effect of SLG and OBP on the number of OAK games won in 2002 # getting plausible range of values of OBP and SLG variables stats <- baseball %>% dplyr::filter( Year < 2002, Year > 1995, Team == "OAK" ) %>% dplyr::summarise( dplyr::across(c(OBP, SLG), list(mean = mean, sd = sd)) ) # creating a sequence of plausible values for OBP and SLG variables (3 SDs around the mean value) OBPSeq <- seq(from = stats$OBP_mean + (3*stats$OBP_sd), to = stats$OBP_mean - (3*stats$OBP_sd), length.out = 20) SLGSeq <- seq(from = stats$SLG_mean + (3*stats$SLG_sd), to = stats$SLG_mean - (3*stats$SLG_sd), length.out = 20) # creating df with all possible combinations of plausible values of OBP and SLG variables simDf <- expand.grid(OBP = OBPSeq, SLG = SLGSeq) # getting variables necessary for prediction (statistics of OAK's opponent teams from 2001) statsO <- baseball %>% dplyr::filter(Year == 2001, Team == "OAK") # adding Team and W variables simDf <- simDf %>% dplyr::mutate( Team = "OAK", W = NaN ) # running the simulation for(i in 1:nrow(simDf)){ #print(i) # prediction of the RS response variable RSPred <- predict( fit, resp = "RS", newdata = simDf[i,] )[1] # prediction of the W response variable WPred <- predict( fit, resp = "W", newdata = data.frame(Team = "OAK", RS = RSPred, RA = statsO$RA) )[1] simDf[i,"W"] <- WPred } # creating the graph simDf %>% ggplot2::ggplot(aes(x = OBP, y = SLG, color = W)) + ggplot2::geom_point(alpha = 18, size = 3) + ggplot2::scale_y_continuous(breaks = seq(0.36, 0.52, 0.02)) + ggplot2::scale_x_continuous(breaks = seq(0.32, 0.38, 0.01)) + ggplot2::scale_colour_gradient2( low = "blue", mid = "white", high = "red", midpoint = 95, space = "Lab", na.value = "grey50", guide = "colourbar", aesthetics = "colour" ) + ggplot2::labs( title = "Simulation of the effect of SLG and OBP on the number of OAK games won in 2002", caption = "The white color corresponds to the 95-point threshold to qualify for the playoffs." ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-10-1.png) As many of you know, thanks to similar analyses, the OAK management began to select players for their team who, although they did not meet the traditional criteria by which scouts judged the quality of baseball players, exhibited exactly those characteristics that, according to conducted analyses, predicted the number of points won and lost, and thus the likelihood of advancing to the playoffs, which was the management's main goal. Because competing teams underestimated the importance of these player statistics and overestimated other, less important variables (e.g., batting average), OAK management was able to purchase players relatively inexpensively who enabled them to achieve their goals. As a result, the OAK won 20 more games per season than similarly "poor" teams and about the same number of games as 2 to 3 times richer competition. The power of data in practice! ```r # salaries vs number of matches won # uploading data from the Lahman's Baseball Database, which is publicly available at https://www.seanlahman.com/baseball-archive/statistics/ playersSalaries <- readr::read_csv("./salaries.csv") teamsWins <- readr::read_csv("./teams.csv") # computing average sum of salaries paid by each team to its players in 1998-2001 playersSalariesAvg <- playersSalaries %>% dplyr::filter( yearID > 1997, yearID < 2002 ) %>% dplyr::group_by(teamID) %>% dplyr::summarise(playersSalariesAvg = sum(salary)/length(unique(yearID))) # calculating the average number of wins per season for each team from 1998-2001 teamsWinsAvg <- teamsWins %>% dplyr::filter( yearID > 1997, yearID < 2002 ) %>% dplyr::group_by(teamID) %>% dplyr::summarise(teamsWinsAvg = sum(W)/length(unique(yearID))) playersSalariesAvg %>% dplyr::left_join(teamsWinsAvg , "teamID") %>% dplyr::mutate(OAK = ifelse(teamID == "OAK", "yes", "no")) %>% ggplot2::ggplot(aes(x= playersSalariesAvg, y = teamsWinsAvg, fill = OAK)) + ggplot2::geom_point()+ ggplot2::labs( title = "Player salaries and number of wins in 1998-2001", x = "AVERAGE SUM OF PLAYERS' SALARIES (USD)", y = "AVERAGE NUMBER OF WINS PER SEASON" ) + ggrepel::geom_label_repel( aes(label = teamID), box.padding = 0.25, point.padding = 0.25, segment.color = 'grey50') + ggplot2::scale_fill_manual( values = c("#ffffff", "#ffd400"), labels = c("no","yes") ) + ggplot2::scale_y_continuous(limits=c(65,100), breaks = seq(65,100,5)) + ggplot2::scale_x_continuous(limits=c(2e+07,9e+07), breaks = seq(2e+07,9e+07,1e+07), labels = scales::number_format(scale = 0.000001, suffix = "M")) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 18, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.text.x = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.y = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position="node", legend.key = element_rect(fill = "white"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 12), panel.background = element_blank(), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./hrm-value-chain-and-sem/unnamed-chunk-11-1.png) Finally, let's breathe some life into the dry numbers by watching a short clip from the [Moneyball film](https://www.imdb.com/title/tt1210166/), which nicely summarizes some of the ideas presented in this blog post. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-hrm-value-chain-and-sem/) on my blog. --- # Why are meetings in/effective? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/ineffective-meetings Date: 2023-02-08 Tags: meetings, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-ineffective-meetings/ The list below is based on [Geimer et al.’s 2015](https://www.sciencedirect.com/science/article/abs/pii/S0148296315000879) paper “Meetings at work: Perceived effectiveness and recommended improvements”. **PEOPLE SIDE:** 🚫 Late arrive 🚫 Unprepared attendees 🚫 One-way (top-down) communication 🚫 Lack of open-mindedness & empathy 🚫 Self-promotion, people talk just to appear to add value, and hidden agenda 🚫 People interrupt/talk during meeting 🚫 Interpersonal conflicts, incivility, and disrespect **MEETING ORGANIZATION SIDE:** ✅ Agenda ✅ Distribution of agenda in advance 🚫 Lack of direction/goals ✅ Chaired effectively 🚫 Meetings held just to have them, just a routine with no real purpose 🚫 Appropriate parties are not invited and inappropriate parties are invited 🚫 Too many attendees 🚫 Time conflicts 🚫 Meet at inappropriate intervals 🚫 Meetings take too long 🚫 Takes time to travel to meeting **MEETING ACTIVITIES SIDE:** 🚫 Insufficient interaction, meeting activities are monotonous and boring 🚫 No new information 🚫 Discussion gets off target 🚫 Core issues not discussed 🚫 Lack of clarity about what the attendee is supposed to do **MEETING OUTCOMES SIDE:** 🚫 Inaction post-meeting 🚫 Decisions have already been made, just a rubber stamp Do you identify with the above reasons? Is there anything that you think is missing from the list, particularly as the workplaces have been operating more remotely/hybrid way since 2015? P.S. In the next post let’s check what suggestions people have for improving the effectiveness of meetings. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-ineffective-meetings/) on my blog. --- # How to improve effectiveness of meetings? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/meetings-improvement Date: 2023-02-08 Tags: meetings, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-meetings-improvement/ In my [last post](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-ineffective-meetings/) I outlined the reasons why people think the meetings they attend are in/effective. Let's now look at what they suggest can be done to improve the effectiveness of meetings. Who knows, maybe in these recommendations you'll find an alternative to canceling most internal meetings like they did at [Shopify](https://fortune.com/2023/01/03/shopify-cutting-meetings-worker-productivity/). The list below is again based on [Geimer et al.’s 2015 paper](https://www.sciencedirect.com/science/article/abs/pii/S0148296315000879) “Meetings at work: Perceived effectiveness and recommended improvements”. **PEOPLE SIDE:** 👉 Come prepared 👉 Arrive on time 👉 Open to change 👉 Actively listen to what others are saying 👉 Display professionalism during the meeting **MEETING STRUCTURE & ORGANIZATION SIDE:** 👉 Distribute appropriate information via e-mail instead of in meeting 👉 Allow time to prepare for meetings 👉 Provide meaningful agenda 👉 Clarify plan of action 👉 Use or rotate a facilitator/chair 👉 Invite appropriate attendees 👉 Pay attention to timing limit, start/end on time 👉 Shorten meetings 👉 Hold meetings at appropriate intervals & meet only when necessary 👉 Make the meeting environment more comfortable **MEETING ACTIVITIES SIDE:** 👉 Make meetings more interactive & seek input from all attendees 👉 Stay focused on the topic 👉 Prioritize items 👉 Break into smaller groups (brainstorming, etc.) 👉 Delegate responsibilities and set deadlines for assigned tasks **MEETING OUTCOMES SIDE:** 👉 Record and distribute meeting minutes 👉 Follow up with proposed solutions Is there anything that you think is missing in the list, especially in the context of the fact that since 2015 the workplaces have been operating more in remote/hybrid mode? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-meetings-improvement/) on my blog. --- # Org chart and collaboration URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/org-chart-and-collaboration Date: 2023-02-08 Tags: organizational-design, collaboration, data-visualization Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-org-chart-and-collaboration/ How to effectively combine information about the formal organizational structure of a company and the actual collaborative activities of its employees? At [Time is Ltd.](https://www.timeisltd.com/) we are primarily focused on collaboration data analytics, whether it's meetings, emails, chat, CRM, project management or version control systems, but besides that we also have a product that helps companies map their formal organisational structure. Btw, you can give it a try because many of its features are available for free on the [Google Workspace Marketplace](https://orgchart.timeisltd.com). We are currently trying to connect these two "worlds" because in situations of organizational transformation it can be very useful to have information about the relationship between the current and/or intended formal structure of the organization on the one hand and the actual patterns of collaboration on the other. One option we're considering is using a kind of heatmap, which you may be familiar with from eye-tracking studies used in marketing, to see where people focus their attention when interacting with products and making purchasing decisions. Now imagine if we overlayed a similar heatmap showing the intensity of collaboration between a selected unit and the rest of the organization over an org chart - see the figure below for illustration. ![](./org-chart-and-collaboration/collaborationOrgChartOverlay.png) Would you consider such a visualization useful if you were engaged in organizational transformation? Would you suggest any other kind of visualization or specific metric related to collaboration? Thank you in advance for any input you may have - it will help us broaden the range of perspectives we are currently considering. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-org-chart-and-collaboration/) on my blog. --- # Warning system for overloaded employees URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/overloaded-employees Date: 2023-02-08 Tags: collaboration, layoffs, employee-turnover, employee-engagement Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-overloaded-employees/ One of the negative side-effects of layoffs and efforts to achieve the same (or ideally more) with fewer employees can be an increased workload for those who stay because they have to do the work of those who have left, which may lead to an increased risk of overload, disengagement, and voluntary quits. To prevent this from happening, it is useful to combine active listening (through engagement surveys, pulse surveys, stay interviews, simple chat, etc. ) with signals that can be obtained from the traces left by people in various digital workplace tools, such as project management systems (ClickUp, Jira, Asana, etc.), version control systems (GitLab, GitHub, etc.), calendars, instant messaging, or emails. At [Time is Ltd.](https://www.timeisltd.com/), we are currently focusing on the following metrics that could be useful in this respect: * Number of assigned tasks * Task close rate * Number of assigned tasks that other people's tasks depend on * Number of commits * Number of code reviews * Response time to received messages and e-mails * Amount of focus time available * Amount of distracted time * Amount of time spent working after hours or on weekends By checking the distribution of these metrics across individual team members and their changes over time, it is possible to identify employees at higher risk of overload, as well as opportunities for a more even distribution of the workload. What tools and/or signals do you use in your company to identify employees at increased risk of overload? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-overloaded-employees/) on my blog. --- # Always messaging URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/slack-batching Date: 2023-02-08 Tags: communication, collaboration, time-management, psychometrics Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-slack-batching/ When dealing with a large number of tasks and frequent task switching, two related concepts originating from computer science can be used: **[batch processing](https://en.wikipedia.org/wiki/Batch_processing)** and **[interrupt coalescing](https://en.wikipedia.org/wiki/Interrupt_coalescing)**. In computing, these terms refer to a situation where computers wait until a fixed interval and check everything, rather than contextually switching and processing separate, uncoordinated interrupts from their various sub-components. When transposed into the world of human workers, this design principle can manifest in checking emails or instant messages every one or two hours instead of continuously handling all incoming emails and messages. Such an arrangement prevents fragmentation of people’s time and provides them with more focus time they need for deep work and experiencing flow. But how to measure this behavior so that a number can be put on it to enable people to better shape their behavior in this regard? At Time is Ltd. we are currently experimenting with two different approaches: 1. Clustering of sent emails/messages using [K-Means](https://cs.wikipedia.org/wiki/K-means) or [PAM](https://en.wikipedia.org/wiki/K-medoids) and calculation of time gaps between start/end points of identified clusters, including the start and end of the working day. The larger the gaps, the stronger the signal of batch behavior. 2. Percentage of emails/messages sent during the 3 busiest working hours (defined by the number of emails/messages sent) during a given day. The higher the proportion, the stronger the signal of batch behavior. This approach is inspired by the [2016 study by Mark et al.](https://www.researchgate.net/publication/301935517_Email_Duration_Batching_and_Self-interruption_Patterns_of_Email_Use_on_Productivity_and_Stress) "Email Duration, Batching and Self-interruption: Patterns of Email Use on Productivity and Stress", where the authors used similar approach in the domain of email communication. ![](./slack-batching/methodsComparison.png) There are advantages and disadvantages to both methods (face validity, accuracy, sensitivity to edge cases, computational complexity, etc.), but irrespective of these, which one would you prefer to see in your collaboration report? To get a better idea of what outputs both of the above approaches generate, you can take a look at the attached graphs showing the prevalence of batch behavior during one of my work weeks on Slack according to these two approaches. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-slack-batching/) on my blog. --- # Span of control and collaboration data URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/span-of-control Date: 2023-02-08 Tags: organizational-design, collaboration, meetings Original post: https://blog-about-people-analytics.netlify.app/posts/2023-02-08-span-of-control/ In determining the "optimal" span of control (SOC) for the company and specific departments and teams, it is always advisable to consider the context and strategy of the company, the way in which individual departments and teams should perform their work, and the level of competence of individual managers. For example, [McKinsey](https://lnkd.in/e8_Gq6z4) suggested the following four specific aspects of managerial complexity that should be considered in this endeavor: * The time a manager spends doing her or his own work vs. managing others. * The extent to which the work process is not standardized and formally structured. * The variety of work of the manager's direct reports. * The amount of experience and training that team members need to do their jobs. The more of the above, the smaller the SOC should be. In addition to these factors, we can use as other useful inputs some measures of collaboration that can reasonably be expected to be related both to SOC and to the obligations that managers have to their direct reports. A good example is the metric of the number of 1-on-1 meetings that managers have with their direct reports. As the attached chart illustrates, when pitted against each other, we can look for points on the SOC scale where managers start to fall short of the goal of a certain minimum number of 1:1s they have with their people, e.g. 2 meetings per month. ![](./span-of-control/oneOnOnesSpanOfControl.png) What factors do you typically consider when determining the optimal span of control in your company? And do you regularly reassess the adequacy of the current SOC in the context of your current situation? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-02-08-span-of-control/) on my blog. --- # Evaluation of the results of the evidence-based HRM knowledge test URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/evidence-based-hrm-knowledge-test-results Date: 2023-01-08 Tags: evidence-based-management, hr-management, people-management Original post: https://blog-about-people-analytics.netlify.app/posts/2023-01-08-evidence-based-hrm-knowledge-test-results/ In one of my [previous posts](https://blog-about-people-analytics.netlify.app/posts/2022-12-17-evidence-based-hrm-knowledge-test/), I shared an [app](https://peopleanalyticsblog.shinyapps.io/evidence_based_hrm_test/) that tests knowledge of evidence-based HRM practices using items from [Rynes, Colbert, and Brown's 2002 study](https://psycnet.apa.org/record/2003-99255-001) on HR practitioners' beliefs about effective HR practices. The fact that more than 140 people completed the test allowed me to compare our current results with those of the participants in the original study (959 HR practitioners, mostly HR managers, with an average of 13.8 years of HR experience). **So how did we do overall?** On average, we had 19.4 items out of 35 correct, i.e., we had a 55% success rate, which is very close to the results of the original study where respondents had an average 57% success rate (and also pretty close to the 50% success rate corresponding to random choice). So these results suggest that we have not progressed much as a group over the last 20 years, however, see the disclaimer at the very end of the post. ![](./evidence-based-hrm-knowledge-test-results/overallResultsComparison.png) **In which HRM area did we have the largest & smallest knowledge gaps?** The biggest gap was in the staffing area (44% success rate) and the smallest was in the training & employee development area (67% success rate). **In which items did we do best?** * Leadership training is effective because good leaders are made, not born (95% success rate). * Lecture-based training is not generally superior to other forms of training delivery (92% success rate). * When pay must be reduced or frozen, a company can do something to reduce employee dissatisfaction and dysfunctional behaviors (90% success rate). **In which items did we do the worst?** * Scoring positive on drug tests doesn’t mean one will be any less reliable or productive employee (11.6% success rate). * Setting performance goals is, on average, more effective for improving organizational performance than encouraging employees to participate in decision-making (12.3% success rate). * Most errors in performance appraisals cannot be eliminated by providing training that describes the kinds of errors managers tend to make and suggesting ways to avoid them (15% success rate). **In which items were we most unsure?** * Older adults don’t learn more from training than younger adults (38% uncertain). * Integrity tests that try to predict whether someone will steal, be absent, or otherwise take advantage of an employer work well in practice (34% uncertain). * Companies with merit pay systems tend to have higher performance than companies without them (17% uncertain). Please keep in mind that the comparison presented here is not entirely an apples-to-apples comparison due to several limitations of the data collection method (e.g., we don’t know the sociodemographics of the new participants, new evidence may have emerged that does not match the correct answers in the original study, some people may have taken the test multiple times, etc.). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2023-01-08-evidence-based-hrm-knowledge-test-results/) on my blog. --- # Can you really unplug? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/collaboration-during-vacations Date: 2022-12-17 Tags: work-life-balance, well-being, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2022-12-17-collaboration-during-vacations/ Although most people know they need time off to stay mentally sharp, productive, and resilient over the long term, many confess they work when they're on vacation. The data we collect and analyze at [Time is Ltd.](https://www.timeisltd.com/) confirms this sad truth. As the first chart illustrates, on more than 75% of vacation days people show some collaborative activity, here measured by attending non-recurring meetings, sending emails, and editing shared files. ![](./collaboration-during-vacations/collaborationVacationProportion.png) However, the same data also suggests what could be part of the remedy for this unsatisfactory state of affairs. The second chart shows that there is a small but not insignificant positive relationship between the time that managers and their direct reports spend collaborating during vacation. The chart also shows that managers tend to collaborate more intensely during vacation than their direct reports, which is probably not too much of a surprise. ![](./collaboration-during-vacations/collaborationVacationMngReports.png) The data is thus in line with the quite often mentioned suggestion that managers should be better role models for their direct reports on how to behave during vacation. As in other areas of people management, it is not enough to lay down the rules - the "playing captain" must play by those rules as well. How are you doing in this respect? And do you have any tricks that help you unplug from work during vacations? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-12-17-collaboration-during-vacations/) on my blog. --- # Evidence-based HRM knowledge test URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/evidence-based-hrm-knowledge-test Date: 2022-12-17 Tags: evidence-based-management, hr-management, people-management, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2022-12-17-evidence-based-hrm-knowledge-test/ ```r # uploading library for emojis library(emoji) ``` If you are interested in testing your knowledge of evidence-based HRM practices in the following five areas... * Management practices * General employment practices * Training & Employee development * Staffing * Compensation & Benefits ... then give a try on the test, which is based on the items used in Rynes, Colbert, and Brown's 2002 study ["HR practitioners' beliefs about effective HR practices: a comparison of research and practice"](https://psycnet.apa.org/record/2003-99255-001). I built a simple shiny app that administers you the test, scores your answers, and compares your results to the results of the participants in the original study (959 HR professionals, mostly HR managers, with an average of 13.8 years of experience in HR). You can also use it to check the accuracy of your answers at the item level to fill in specific gaps in your knowledge. Here is [the link to the app](https://peopleanalyticsblog.shinyapps.io/evidence_based_hrm_test/). Feel free to share your results in the comments. Think of it as a form of public commitment to making some progress in evidence-based HRM in the coming year `r emoji::emoji("wink")` To walk the talk, I attached my own results. As you can see, it's not bad, but there is still room for improvement, especially in the general employment practices area `r emoji::emoji("smiley")` ![](./evidence-based-hrm-knowledge-test/results.png) P.S. Keep in mind that the test is based on evidence available up to 2002, so it is possible that some correct answers or their contingencies may have changed in that time. For all of them, consider, for example, the adjustment of the estimate of the magnitude of the predictive validity of personnel selection procedures in their most recent [meta-analysis by Sackett et al. (2022)](https://psycnet.apa.org/record/2022-17327-001). If you come across any such discrepancy, it would be great if you share it with others in the comments. Update: With more than 140 people completing the test, I was able to compare our current results with those of the participants in the original study. You can see the results of the comparison in the post [Evaluation of the results of the evidence-based HRM knowledge test](https://blog-about-people-analytics.netlify.app/posts/2023-01-08-evidence-based-hrm-knowledge-test-results/). Spoiler: It’s not very good `r emoji::emoji("hushed")` --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-12-17-evidence-based-hrm-knowledge-test/) on my blog. --- # Signals of a good manager URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/good-manager Date: 2022-12-17 Tags: people-management, collaboration, performance-management Original post: https://blog-about-people-analytics.netlify.app/posts/2022-12-17-good-manager/ Many of you have probably already heard about [Google's Oxygen project](https://rework.withgoogle.com/blog/the-evolution-of-project-oxygen/) and its findings on the factors differentiating the highest and lowest-rated managers based on performance reviews, employee engagement surveys, interviews, and other sources of employee feedback. The final list included the following eight characteristics: 1. Is a good coach. 2. Empowers the team and does not micromanage. 3. Expresses interest in and concern for team members’ success and personal well-being. 4. Is productive and results-oriented. 5. Is a good communicator - listens and shares information. 6. Helps with career development. 7. Has a clear vision and strategy for the team. 8. Has key technical skills that help him or her advise the team. Do you think it would be possible to find any signals, however weak, of the presence of some of these managers' characteristics in the collaboration (meta)data? Of the collaboration metrics we currently work with at [Time is Ltd.](https://www.timeisltd.com/), I would bet on the following: * Number of 1-on-1 meetings managers have with their direct reports and new hires [concern for employees' success and personal well-being] * Number of team meetings managers have with their teams [information sharing, alignment on vision, strategy, and tactics) * After-hours or weekend work and workday length of managers’ direct reports [well-being & work-life balance] * Managers’ presence at meetings of their direct reports, excluding 1-on-1s and team meetings [micromanagement] * Managers being in CC/BCC of emails sent by their direct reports [micromanagement] * Time it takes managers to respond to emails from their direct reports [interest in and concern for team members] * Direct reports having skip-level meetings [career development & new career opportunities] Would you agree? Or what other signals of a good manager would you look for in collaboration (meta)data? P.S. In a [later update](https://rework.withgoogle.com/blog/the-evolution-of-project-oxygen/), the list of top manager characteristics at Google also includes the characteristic "Collaborates across Google", which is relatively straightforward to measure using collaboration data. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-12-17-good-manager/) on my blog. --- # One does not simply do a business without getting lost URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/mission-scale-from-tas Date: 2022-12-07 Tags: teams, organizational-culture, motivation, goal-setting, hr-metrics Original post: https://blog-about-people-analytics.netlify.app/posts/2022-12-07-mission-scale-from-tas/ ```r # uploading library for emojis library(emoji) ``` As I was scrolling through one of my feeds over the weekend, I came across a funny meme that resonated with my recent experience on a family trip that expressed the deep truth that “One does not simply do a road trip without getting lost.” `r emoji::emoji("smile")` ![](./mission-scale-from-tas/road_trip_meme_joke.jpg) Besides that, and that's why I'm writing about it here on my blog, it also reminded me of the results of a study I did together with [Rastislav Duris](https://www.linkedin.com/in/rastislavduris/) and Slavka Silberg, on the characteristics of more than 80 teams from different industries and composed of more than 800 people using the [Team Assessment Survey](http://www.therocketmodel.com/team-assessment-survey), [Dr. Gordon Curphy's](https://www.linkedin.com/in/gordoncurphy/) survey that measures some of the basic factors that determine team performance. Specifically, I was reminded of the results on the Mission scale, which consists of the following four items: 1. **Purpose:** Team members are clear about the team's purpose. 2. **Goals:** The team has a set of overall goals. 3. **Metrics:** Metrics and benchmarks have been identified for each team goal. 4. **Reviews:** The team regularly reviews progress on team goals. As you can see in the attached chart, the results of the “Heartbeat analysis” can be briefly summarized as "We know where we're going, at least some of us know the points to get there, but we're not sure if we're on the right track and if we should change our original plans." ![](./mission-scale-from-tas/mission_scale_results.png) That sounds a lot like the description of our last family trip. `r emoji::emoji("sweat_smile")` Do you think you're better off in this regard in your team or company? And if so, what tips would you give others on how to improve in this respect? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-12-07-mission-scale-from-tas/) on my blog. --- # How to quickly navigate dashboard users to what they need to know? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/change-detection Date: 2022-12-02 Tags: data-visualization, people-analytics, time-series, python Original post: https://blog-about-people-analytics.netlify.app/posts/2022-12-02-change-detection/ Since the beginning of our efforts at [Time is Ltd.](https://www.timeisltd.com/) to develop a comprehensive collaboration analytics platform, we have created several hundred collaboration metrics. As you can probably imagine, with such a huge amount of metrics, it was quite difficult for users to get something useful out of the platform. To make the metrics provided more digestible, we've enhanced the platform using several approaches, from simply selecting metrics with the most straightforward call to action and creating apps for very specific use cases to providing users with comparative, historical, intuitively scaled, and equivalent information. [Brent Dykes](https://www.linkedin.com/in/brentdykes/) described many of these sense-making methods very neatly in his article [Contextualized Insights: Six Ways To Put Your Numbers In Context](https://www.effectivedatastorytelling.com/post/contextualized-insights-six-ways-to-put-your-numbers-in-context). However, even after several rounds of these improvements, there was still a need to help users quickly find areas that might be worth their attention and deeper exploration. One approach we took was based on the (validated) assumption that collaboration metrics are relatively stable over time and that the intentional and unintentional behavioral changes behind these metrics evolve rather slowly. We therefore decided to detect the most significant changes over the last 3 months. For this purpose, we chose a method that is related to the [Bollinger Bands](https://en.wikipedia.org/wiki/Bollinger_Bands) method used in time series analysis or "Heartbeat Analysis" used in survey response analysis. Specifically, we look at the standard deviation and average of the month-to-month changes for each metric, scale the changes to z-scores, and then identify the metrics with the highest absolute value of average change over the last 3 months. To illustrate, see the attached chart where the metrics are sorted by magnitude of their change in descending order from left to right and top to bottom. ![](./change-detection/highlights.png) If you want to try this method on your own data, you can use its Python implementation, which is on [my GitHub page](https://github.com/lstehlik2809/Change-detection-in-time-series-data/blob/main/standardizedChangeSizeComputation.py). What other methods do you find useful in identifying values or changes that might be worth users' attention? Feel free to share them in the comments for inspiration. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-12-02-change-detection/) on my blog. --- # Eisenhower matrix for meetings URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/meeting-matrix Date: 2022-11-26 Tags: meetings, time-management, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2022-11-26-meeting-matrix/ I assume many, if not all, of you are familiar with [the Eisenhower matrix](https://en.wikipedia.org/wiki/Time_management#The_Eisenhower_Method), a simple tool for prioritizing tasks based on a combination of their importance and urgency. It assumes 4 types of tasks - important & urgent, important & not urgent, not important & urgent, not important & not urgent - and for each of them offers a simple recommendation on what to do with them - do it, schedule it, delegate it, and delete it. By following these rules, people should be able to more successfully combat the “mere-urgency” effect, eliminate time-wasters in their lives, and create more space to make progress toward their goals. ![](./meeting-matrix/eisenhowerMatrix.png) Something similar can be created also for meetings, which are big time and money guzzlers and deserve to be treated accordingly. Only instead of the dimensions of importance and urgency, we will use the size and length of meetings. The resulting matrix assumes 4 types of meetings with corresponding recommendations on what to do with them: 1. **Small & Short:** These meetings are a bit tricky because they often involve useful meetings, e.g., short syncs of teams working on some specific task or project or one-on-one meetings between managers and their direct reports, however, when there is a lot of them, they may cause calendar fragmentation and drop in available focus time; to avoid this, one can think of batching such meetings into larger blocks of two or three meetings with appropriate small breaks in between to avoid meeting fatigue and late arrivals. 2. **Small & Long:** These meetings are great for brainstorming, problem-solving, or decision-making, so make sure you use them primarily for that purpose and don't waste them on low-value-added activities. 3. **Large & Short:** These meetings often serve only to disseminate information and can therefore be safely replaced by less intrusive asynchronous collaboration tools such as email, instant messaging or some kind of knowledge management tool. 4. **Large & Long:** These meetings are quite often a waste of people’s time by not allowing everyone to meaningfully contribute and by making them both physically and mentally exhausted, so try to move these meetings into the third quadrant if your goal is simple information dissemination or into the second quadrant if the goal is to solve some problem or to make some important decision. The meeting matrix is by no means a panacea for meeting overload, but by following the rules above, people should be able to better protect their time for focused work and make meetings more efficient, meaningful, and valuable for themselves and the company. Just be aware that unlike the Eisenhower matrix, in the case of the meeting matrix, people need to rely more on others to follow these rules in order for its positive effect to materialize. So get ready to update your (hopefully already existing) team agreement on your collaboration culture. Btw, what is your guess as to how much time you spend in each cell of the meeting matrix? For comparison I attach my monthly numbers from October. ![](./meeting-matrix/meetingMatrix.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-11-26-meeting-matrix/) on my blog. --- # Some resources on staff retention and downsizing URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/resources-on-retention-and-downsizing Date: 2022-11-26 Tags: employee-turnover, layoffs, people-analytics, evidence-based-management Original post: https://blog-about-people-analytics.netlify.app/posts/2022-11-26-resources-on-retention-and-downsizing/ I referred them to the following resources - maybe some of you will find them useful as well: * [Employment Downsizing and Its Alternatives](https://www.shrm.org/hr-today/trends-and-forecasting/special-reports-and-expert-views/Documents/Employment-Downsizing.pdf) guideline from the SHRM's [Science-to-Practice Series](https://www.shrm.org/hr-today/trends-and-forecasting/special-reports-and-expert-views/pages/science-to-practice.aspx). * [Retaining Talent](https://www.shrm.org/hr-today/trends-and-forecasting/special-reports-and-expert-views/Documents/Retaining-Talent.pdf) guideline from the SHRM's [Science-to-Practice Series](https://www.shrm.org/hr-today/trends-and-forecasting/special-reports-and-expert-views/pages/science-to-practice.aspx). * Rubenstein et al.'s [meta-analysis of voluntary turnover predictors](https://onlinelibrary.wiley.com/doi/abs/10.1111/peps.12226); you can also use [this app](https://peopleanalyticsblog.shinyapps.io/voluntary_turnover_predictors/) visually summarizing its results. * Speer et al.'s article [Here to stay or go? Connecting turnover research to applied attrition modeling](https://www.researchgate.net/publication/334150536_Here_to_stay_or_go_Connecting_turnover_research_to_applied_attrition_modeling). * [Demo dashboard](https://aanalytics.shinyapps.io/Turnover_Analytics_Dashboard_Demo/) for analysis of employee turnover. Feel free to share any other resources on this topic you think might be useful to others. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-11-26-resources-on-retention-and-downsizing/) on my blog. --- # Evidence-based interventions that help reduce the gender pay gap URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/interventions-reducing-gender-pay-gap Date: 2022-11-18 Tags: gender-pay-gap, evidence-based-management Original post: https://blog-about-people-analytics.netlify.app/posts/2022-11-18-interventions-reducing-gender-pay-gap/ If you are responsible for DEI initiatives in your company, you might be interested in a paper by [The Behavioural Insights Team](https://www.bi.team/) that lists several possible interventions to close the gender pay gap, divided into three categories based on the extent to which their effectiveness is supported by empirical evidence. 🥇 **Actions with well-documented effectiveness:** * Including more women on shortlists in recruitment and promotion. *️ Use of tasks assessing job skill levels in the selection of new employees. * Use of structured interviews in recruitment and promotion. * Encouraging salary negotiation through disclosure of existing salary ranges. * Introducing transparent promotion and reward processes. * Appointing a manager or establishing a corporate diversity task force. 🥈 **Potentially promising actions, but requiring further evidence of their effectiveness:** * Increasing work flexibility for men and women. * Supporting shared parental leave. * Recruiting former employees who have had to interrupt their careers for a prolonged period for various personal reasons. * Offering mentoring and sponsorship. * Offering networking programs. * Setting internal targets. 🥉 **Actions with mixed evidence of their effectiveness:** * Training on the topic of unconscious bias. * Diversity training. * Leadership development training. * Demographically diverse selection panels in external and internal recruitment. For those interested, here is the original document for a closer look.

This browser does not support PDF files. Please download the PDF file to view it: Download PDF.


A final note. As useful as it is to know which interventions have a decent chance of reducing gender pay inequality, an integral part of this fight is to regularly check the existence of this inequality across the organisation and within different types of organisational processes. As [Alessandro Linari](https://www.linkedin.com/in/alezzandro/) aptly noted in a discussion on Linkedin, "*The best way to keep the gender pay gap under control is still to do a periodic pay equity analysis across the organisation and address any identified gap. I say unfortunately because there are a thousand different ways where pay differences still manage to sneak into the workforce, through hiring and promotion practices, but also retention policies, bonus allocation, performance management, and others.*" If you want to learn more about some of the technical details of such an analysis, check out [one of my previous posts on this topic](https://blog-about-people-analytics.netlify.app/posts/2021-01-29-paygap/) (unfortunately, it is now only available in Czech, but you can use one of the online translators to get around this limitation 😉). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-11-18-interventions-reducing-gender-pay-gap/) on my blog. --- # It's perfectly normal not to be normal URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-related-metrics-distribution Date: 2022-11-15 Tags: hr-metrics, statistics, collaboration, performance, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-11-15-people-related-metrics-distribution/ The fact is that many HR practitioners overestimate the frequency with which the phenomena they commonly encounter in their practice have a normal, bell-shaped, symmetrical distribution. This is very much the case, for example, in the area of communication and collaboration that we deal with at [Time is Ltd.](https://www.timeisltd.com/), as illustrated in the attached chart with some of our collaboration metrics, which show a wide range of distributions from log-normal and power law to exponential, gamma, Weibull and beta. ```r # uploading libraries library(tidyverse) library(patchwork) # uploading data mydata <- readRDS("./collaborationMetrics.rds") # External network size metric externalNetworkSizeG <- mydata %>% dplyr::filter(metric == "externalNetworkSize") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(breaks = seq(0,120,20)) + ggplot2::labs( x = "EXTERNAL NETWORK SIZE / PERSON / MONTH", y = "FREQUENCY", title = "External network size" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Focus rate metric focusRatePrctG <- mydata %>% dplyr::filter(metric == "focusRatePrct") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::percent_format()) + ggplot2::labs( x = "% OF FOCUS TIME / PERSON", y = "FREQUENCY", title = "Available focus time" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Multitasking in meetings metric multitaskingInMeetingsPrctG <- mydata %>% dplyr::filter(metric == "multitaskingInMeetingsPrct") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::percent_format(), limits = c(0,1)) + ggplot2::labs( x = "MULTITASKING RATE / PERSON", y = "FREQUENCY", title = "Meeting participations with multitasking" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Breaks between meetings metric breaksBetweenMeetingsMinutesG <- mydata %>% dplyr::filter(metric == "breaksBetweenMeetingsMinutes") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " min"), breaks = seq(0,600,120)) + ggplot2::labs( x = "LENGTH OF BREAK / PERSON", y = "FREQUENCY", title = "Lenght of breaks between meetings" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Collaboration time metric collaborationTimePersonHrsDayG <- mydata %>% dplyr::filter(metric == "collaborationTimePersonHrsDay") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " hrs"), breaks = seq(0,16,2)) + ggplot2::labs( x = "TIME SPENT COLLABORATING / PERSON / DAY", y = "FREQUENCY", title = "Time spent collaborating" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Supervised meetings metric micromngMeetingsPrctG <- mydata %>% dplyr::filter(metric == "micromngMeetingsPrct") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::percent_format()) + ggplot2::labs( x = "% OF SUPERVISED MEETINGS / PERSON", y = "FREQUENCY", title = "Proportion of supervised meetings" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Recurring meetings metric recurringMeetingsPrctG <- mydata %>% dplyr::filter(metric == "recurringMeetingsPrct") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::percent_format(), limits = c(0,1)) + ggplot2::labs( x = "% OF RECURRING MEETINGS / PERSON", y = "FREQUENCY", title = "Proportion of recurring meetings" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Call ending metric callEndingMinutesG <- mydata %>% dplyr::filter(metric == "callEndingMinutes") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " min"), breaks = seq(-60,60,20)) + ggplot2::labs( x = "PLANNED VS. ACTUAL END OF MEETINGS / PERSON", y = "FREQUENCY", title = "Planned vs. actual end of meetings" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Call delay metric callDelayMinutesG <- mydata %>% dplyr::filter(metric == "callDelayMinutes") %>% ggplot2::ggplot(aes(x = metricValue)) + ggplot2::geom_histogram(fill = "#fdd835", alpha = 0.9, color = "white") + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " min"), breaks = seq(0,40,5)) + ggplot2::labs( x = "DELAY OF ONLINE MEETINGS / PERSON", y = "FREQUENCY", title = "Late arrivals to online meetings" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) g <- (externalNetworkSizeG + focusRatePrctG + multitaskingInMeetingsPrctG) / (breaksBetweenMeetingsMinutesG + collaborationTimePersonHrsDayG + micromngMeetingsPrctG) / (recurringMeetingsPrctG + callEndingMinutesG + callDelayMinutesG) + patchwork::plot_annotation( title = 'Distribution of selected collaboration metrics', theme = theme( plot.title = element_text(size = 26, margin=margin(20,0,12,0)) ) ) print(g) ``` ![](./people-related-metrics-distribution/unnamed-chunk-1-1.png) But it also applies to more traditional areas of HR concern such as individual or team performance. For many job roles, especially knowledge-based ones, job performance has a power-law distribution, i.e., only a few percent of individuals or teams have disproportionately high performance and most have performance below the statistical average (many of you have probably heard of Pareto's law, and the 80/20 rule in this context). More detailed information on this particular topic can be found, for example, in the following two articles - [The Best and the Rest: Revisiting the Norm of Normality of Individual Performance](https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1744-6570.2011.01239.x) by O'Boyle Jr. & Aguinis (2012) and [Team Performance: Nature and Antecedents of Nonnormal Distributions](https://pubsonline.informs.org/doi/full/10.1287/orsc.2022.1619) by Bradley & Aguinis (2022). Why bother with that? Well, because, based on incorrect assumptions about the distribution of specific phenomena, companies may make decisions that ultimately harm them. For example, suppose a company applies the assumption of normal distribution in evaluating employees’ performance that actually has power law distribution. In that case, it will result in underestimating the contribution of the best performers and overestimating the contribution of the worst performers, which may be negatively reflected in various decisions regarding reward & compensation, learning & development, promotions, succession planning, etc. Lessons learned? For frequent decisions or decisions with a significant expected impact, it is worth checking that our underlying assumptions match reality. Btw, this also applies to personal life. Here's an example from mine: I thought I was in control of watching movies and TV shows on streaming platforms, but I started getting signals from those around me that I was spending too much time there. So as a proper ['Quantified Selfer'](https://quantifiedself.com/), I decided to track my daily screen time for a month. To my surprise, I found that I was indeed spending a lot more time there than I thought and wished. I then put in place simple solutions to prevent me from watching more than I should - I have started to pay extra fees into the family budget for watching movies ([loss aversion](https://en.wikipedia.org/wiki/Loss_aversion)), I have pre-selected time slots for watching movies ([implementation intention](https://en.wikipedia.org/wiki/Implementation_intention)), I've removed streaming apps from my phone (lowering [salience](https://en.wikipedia.org/wiki/Salience_(neuroscience))), I've stopped watching movies alone (social control), and I'm also playing with the idea of asking my wife to change the PINs (preventing [impulsive](https://en.wikipedia.org/wiki/Impulsivity) watching). P.S. If you ever need to check the shape distribution of any of your metrics, you should definitely try the amazing [fitdistrplus R package](https://cran.r-project.org/web/packages/fitdistrplus/index.html). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-11-15-people-related-metrics-distribution/) on my blog. --- # A visual introduction to Bayesian belief updating URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/bayesian-belief-updating Date: 2022-10-27 Tags: bayesian-statistics, decision-making, time-series, critical-thinking, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-10-27-bayesian-belief-updating/ When trying to reduce uncertainty, even very small pieces of information count if you are patient and have some tool to combine them effectively. I recently re-read [Philip Tetlock's](https://en.wikipedia.org/wiki/Philip_E._Tetlock) book [Superforecasting](https://en.wikipedia.org/wiki/Superforecasting:_The_Art_and_Science_of_Prediction) and came across an excellent illustration of such a tool: [Bayesian belief updating](https://en.wikipedia.org/wiki/Bayesian_inference). "*Imagine you are sitting with your back to a billiards table. A friend rolls a ball onto the table and it stops at a random spot. You want to locate the ball without looking. How? Your friend rolls a second ball, which stops at another random spot. You ask, “Is the second ball to the left or the right of the first?” Your friend says, “To the left.” That’s an almost trivial scrap of information. But it’s not nothing. It tells you that the first ball is not on the extreme left edge of the table. And it makes it just a tad more likely that the first ball is on the right side of the table. If your friend rolls another ball on the table and the procedure is repeated, you get another scrap of information. If he says, “It’s to the left,” the likelihood of the first ball being on the right side of the table increases a little more. Keep repeating the process and you slowly narrow the range of the possible locations, zeroing in on the truth—although you will never eliminate uncertainty entirely.*” After reading this paragraph, I thought it would be much more pedagogically compelling (and fun 😉) to see this update process live and in action. What a great opportunity to learn how to work with the [gganimate](chrome-extension://efaidnbmnnnibpcajpcglclefindmkaj/https://cran.r-project.org/web/packages/gganimate/gganimate.pdf) R package. Here is the code I put together. ```r # uploading libraries library(tidyverse) # data manipulation and visualization library(gganimate) # animation of the charts library(bayestestR) # Highest Density Interval computation # specifying the width of the billiards table field <- seq(0,178,1) # likelihoood function for the situation when the second ball stops to the right of the first one rightLikelihood <- (178-field)/178 # likelihoood function for the situation when the second ball stops to the left of the first one leftLikelihood <- 1-rightLikelihood # flat prior for the very beginning of the updating process firstPrior <- rep(1/179, 179) # position where the first ball stopped point <- 88 # Bayesian belief updating # creating shell dataframe for final results posteriors <- data.frame() # setting random seed for ensuring reproducibility set.seed(1234) # specifying the number of trials for(i in 1:500){ # throwing the second ball pointTrial <- runif(n = 1, min = 0, max = 178) # determining whether the second ball stopped to the left or to the right of the first one side <- ifelse(pointTrial > point, "right", "left") # selecting appropriate prior if(i==1){ prior <- firstPrior } else{ prior <- posteriors %>% dplyr::filter(trial == i-1) %>% dplyr::pull(posterior) } # combining prior and likelihood (evidence) and transforming the result into probabilities if(side == "right"){ likelihood <- rightLikelihood * prior probability <- likelihood/sum(likelihood) } else{ likelihood <- leftLikelihood * prior probability <- likelihood/sum(likelihood) } # putting results into the dataframe suppDf <- data.frame( posterior = probability, trial = i, side = side, pointTrial = pointTrial ) # computing Highest Density Interval sampling <- sample(x = field, size = 10000, replace = TRUE, prob = probability) hdi <- bayestestR::hdi(sampling, ci = 0.95) suppDf <- suppDf %>% dplyr::mutate( lhdi = hdi$CI_low, hhdi = hdi$CI_high ) # putting results into the shell dataframe posteriors <- dplyr::bind_rows(posteriors, suppDf) } # adjusting data for visualization purposes posteriorsDf <- posteriors %>% dplyr::group_by(trial) %>% dplyr::mutate(place = dplyr::row_number()-1) %>% dplyr::ungroup() %>% dplyr::mutate( hdi = ifelse(place >= lhdi & place <= hhdi, "yes", "no") ) # creating charts myAnimation <- ggplot2::ggplot(data = posteriorsDf, aes(x = place, y = posterior)) + ggplot2::geom_area(data = posteriorsDf %>% dplyr::filter(hdi == 'yes'), fill = 'light blue') + ggplot2::geom_line(size = 1) + ggplot2::geom_point(aes(x = pointTrial, y = 0), size = 4) + ggplot2::geom_point(aes(x = point, y = 0), size = 4, color = "red") + ggplot2::geom_text(aes(x = 8, y = 0.1, label = stringr::str_glue("Trial: {trial}\n95% HDI: [{lhdi}, {hhdi}]")), color = "grey") + ggplot2::scale_y_continuous(limits = c(0,0.105)) + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " cm")) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + ggplot2::labs( title = "Bayesian belief updating in the billiards table thought experiment", subtitle = "Target place: 88 cm", x = 'PLACE ON THE BILLIARDS TABLE', y = 'POSTERIOR PROBABILITY', caption = "\n\nThe static red point corresponds to a random and unknown location of the first ball. The moving black point then corresponds to the location where the second,\nrepeatedly rolled ball randomly ended up. The area in blue corresponds to the 95% Highest Density Interval of the posterior distribution. All points inside this\ninterval have a higher probability density than points outside this interval." ) + gganimate::transition_time(trial) + gganimate::ease_aes('linear') # animating chart gganimate::animate(myAnimation, nframes = 125, fps = 5, height = 6, width = 11, units = "in", res = 125) # saving animated chart as a .gif file gganimate::anim_save(filename = "./bayesianBelifUpdating.gif", animation = last_animation()) ``` And here is the resulting animation of the Bayesian belief updating process across 500 trials. ![](./bayesian-belief-updating/bayesianBelifUpdating.gif) For those who would like to incorporate Bayesian reasoning into their managerial decision-making under uncertainty, I can recommend [this article](https://journals.sagepub.com/doi/full/10.1177/0008125620948264) by [Brian T. McCann](https://www.linkedin.com/in/brian-t-mccann-7a322617/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-10-27-bayesian-belief-updating/) on my blog. --- # Police cadet evaluation dataset URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/police-cadet-evaluation-dataset Date: 2022-10-24 Tags: people-analytics, data-science, recruitment, learning-and-development Original post: https://blog-about-people-analytics.netlify.app/posts/2022-10-24-police-cadet-evaluation-dataset/ While cleaning out my (very) old computer, I came across a hidden gem: a dataset with real-world data about police cadet evaluation. It was part of a tutorial from [Peltarion](https://en.wikipedia.org/wiki/Peltarion), an AI software company providing specialized software ([Synapse](https://en.wikipedia.org/wiki/Peltarion_Synapse)) for creating, training, evaluating, and deploying artificial neural networks and other adaptive systems (recently acquired by [King](https://en.wikipedia.org/wiki/King_(company))). AFAIK, this dataset is not part of any publicly available database with training datasets, so it may add a bit to the portfolio of possibilities for those involved and interested in people analytics. The data were collected as part of an effort by the [National Police Services Agency](https://en.wikipedia.org/wiki/Korps_landelijke_politiediensten) and the [Dutch Ministry of Justice and Security](https://www.government.nl/ministries/ministry-of-justice-and-security) to objectively examine whether the data collected at the time of graduation of police cadets can be used to predict the requirements for passing the standard five-year evaluation. The main reason for the study was to find the key indicators for the then 20% failure rate, which was considered unacceptable (data were collected in the late 1990s), and to study the effects of lowering admission standards (accepting cadets with past criminal records and lowering the minimum grade from 5.5 to 4.0). The dataset has the following characteristics: * 2000 observations * 9 attributes: + **Age**: the age at which the cadet started studying to become a police officer. + **AvG**: average grade at the time of graduation (scale 1-10). + **Chdn**: number of children at the time of graduation. + **ExEd**: extra university-level or equivalent education (years). + **CR**: criminal record (0=No, 1=Yes). + **Sex**: sex of the cadet (0 = Male, 1 = Female). + **SecE**: other experience in the security sector (0 = No, 1 = Yes). + **AvgE**: average yearly evaluation score (The average of five years. The evaluation is performed by a committee of 10 senior police officers. Scale 1-5). This is a help attribute and not for use as input. + **FinalE**: final evaluation. Fail if average yearly evaluation score (Avg) < 2.0 otherwise pass. (1610 Pass / 390 Fail). This is the target attribute. Here is a table you can use to check and download the data. ```r # uploading libraries for data manipulation and making user-friendly data table library(tidyverse) library(DT) # uploading data data <- readr::read_csv("./policeCadetEvaluation.csv") # adjusting the data type for some variables for tabulation and visualization purposes data <- data %>% mutate( CR = as.factor(CR), Sex = as.factor(Sex), SecE = as.factor(SecE), FinalE = as.factor(FinalE) ) # defining the table DT::datatable( data, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy', 'csv', 'excel'), scrollX = TRUE, selection="multiple" ) ) ``` And here is a pairplot showing the distribution and relationships between variables in the dataset. ```r # uploading library for the pairplot data visualization library(GGally) # defining custom function for diagonal continuous variable chart my_dens <- function(data, mapping) { ggplot(data = data, mapping = mapping) + geom_density(alpha = 0.6, color = NA) } # pairplot GGally::ggpairs( data = data, title = "Police cadet evaluation dataset", mapping=ggplot2::aes(fill = FinalE), lower=list( combo = wrap("facethist", binwidth=1, alpha = 0.6), continuous = wrap("points", alpha = 0.3, size = 0.7), discrete = wrap("facetbar", alpha = 0.6) ), upper=list( discrete = wrap("box", alpha = 0.6), combo = wrap("box", alpha = 0.6) ), diag = list( continuous = my_dens, discrete = wrap("barDiag", alpha = 0.6) ) ) + ggplot2::scale_fill_manual(values=c("Fail" = "#e53935", "Pass" = "#00897b")) + labs(caption = "\nThe color indicates the pass/fail result of the final evaluation, the target attribute.") + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,12,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 12, hjust = 0), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./police-cadet-evaluation-dataset/unnamed-chunk-2-1.png) If you want to download the dataset, you can do so here via the table above or via [my GitHub page](https://github.com/lstehlik2809/Police-Cadet-Evaluation-Dataset) where you can also find more information about the dataset. Happy analysis 😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-10-24-police-cadet-evaluation-dataset/) on my blog. --- # Divide and... understand URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/conditional-inference-tree Date: 2022-10-18 Tags: machine-learning, statistics, interpretability, predictive-analytics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-10-18-conditional-inference-tree/ When correlating collaboration metrics with business criteria that our clients are interested in, such as the size of the internal network of salespeople vs. their sales performance, we often encounter the question of where the breakpoint is when people start to score significantly higher/lower on a given criterion. To answer this question, I find very handy the [Conditional Inference Tree algorithm](https://cran.r-project.org/web/packages/partykit/vignettes/ctree.pdf) - a non-parametric class of [decision trees](https://en.wikipedia.org/wiki/Decision_tree) that, unlike traditional decision trees, use a significance/permutation test (corrected for multiple testing) to select covariates to split and recurse the variable. When applied to just one numerical predictor, it will provide a set of partitions that allow you to split that predictor into bins in such a way that you end up with statistically significant differences between some of the identified bins. With this information in hand, it is much easier for you to find the "sweet spots" (there may be more than one) where the criterion starts to behave differently in relation to the predictor values. See charts below for illustration. ```r # uploading libraries for data manipulation and visualuzation library(tidyverse) # defining normalize function normalize <- function(x) { return ((x - min(x)) / (max(x) - min(x))) } # creating artificial dataset with internal network size and sales performance variables internalNetworkSize = seq(-6, 6, 0.1) salesPerf = 1*(internalNetworkSize**3) + 2*(internalNetworkSize**2) + 1*internalNetworkSize + 3 salesPerf_noise = 70 * rnorm(mean = 0, sd = 1, n=length(salesPerf)) salesPerformance = salesPerf + salesPerf_noise # putting data into dataframe and making some transformations of the variables data <- data.frame( internalNetworkSize = internalNetworkSize, salesPerformance = salesPerformance ) %>% dplyr::mutate( internalNetworkSize = normalize(internalNetworkSize), salesPerformance = normalize(salesPerformance), internalNetworkSize = internalNetworkSize*189, salesPerformance = salesPerformance*100 ) # visualizing relationship between internal network size and sales performance ggplot2::ggplot(data = data, aes(x = internalNetworkSize, y = salesPerformance)) + ggplot2::geom_point(color = "#4d009d", size = 3, alpha = 0.8) + ggplot2::labs( x = "INTERNAL NETWORK SIZE OF SALESPEOPLE", y = "SALES PERFORMANCE", title = "Relationship between internal network size of salespeople and their performance" ) + ggplot2::scale_y_continuous(labels = scales::number_format(suffix = "%")) + ggplot2::scale_x_continuous(breaks = seq(0,200, 20), limits = c(0,200)) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./conditional-inference-tree/unnamed-chunk-1-1.png) ```r # uploading libraries for ctree algorithm and visualization of the results of statistical tests library(partykit) library(ggstatsplot) # defining formula fmla <- as.formula("salesPerformance ~ internalNetworkSize") # binning internal network size variiablle using ctree algorithm ctree <- partykit::ctree( fmla, data = data, na.action = na.exclude, control = partykit::ctree_control(minbucket = ceiling(round(0.05*nrow(data)))) ) # plotting resulting tree #plot(ctree) # number of identified bins #bins = partykit::width(ctree) # extracting bin borders cutvct = data.frame(matrix(ncol=0,nrow=0)) # Shell n = length(ctree) # Number of nodes for (i in 1:n) { cutvct = rbind(cutvct, ctree[i]$node$split$breaks) } cutvct = cutvct[order(cutvct[,1]),] # sorting / converting to an ordered vector (asc) cutvct = ifelse(cutvct<0,trunc(10000*cutvct)/10000,ceiling(10000*cutvct)/10000) # rounding to 4th decimal place to avoid borderline cases # adding minimum and maximum values cutvct <- append(cutvct, min(data["internalNetworkSize"], na.rm = TRUE)) cutvct <- append(cutvct, max(data["internalNetworkSize"], na.rm = TRUE)) cutvct = cutvct[order(cutvct)] # creating bin categories valueCat <- cut(x = data %>% dplyr::pull("internalNetworkSize"), breaks = cutvct, include.lowest = TRUE) # creating supplementary dataframe for visualization purposes suppDf <- data %>% dplyr::select(internalNetworkSize, salesPerformance) %>% dplyr::mutate(category = valueCat) %>% dplyr::filter(category != "NA") # visualizing relationship between internal network size and sales performance using ggbetweenstats from ggstatsplot package ggstatsplot::ggbetweenstats( data = suppDf %>% as.data.frame(), x = category, y = salesPerformance, type = "robust" ) + ggplot2::scale_y_continuous(labels = scales::number_format(suffix = "%"), breaks = seq(0,100,20)) + ggplot2::labs( y = "SALES PERFORMANCE", x = "INTERNAL NETWORK SIZE OF SALESPEOPLE (BINNED)", title = "Relationship between internal network size of salespeople and their performance" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 13, margin=margin(0,0,15,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./conditional-inference-tree/unnamed-chunk-2-1.png) If you are dealing with similar use cases, give it a try. And if you use any other tools/approaches for this, feel free to share them in return. P.S. Thanks to [Filip Trojan](https://www.linkedin.com/in/filip-trojan-2543a36/), my former boss and colleague from the Deloitte Advanced Analytics team, who introduced me to this tool. I still benefit from it to this day 🙏💪 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-10-18-conditional-inference-tree/) on my blog. --- # Timeboxing. Does it really work? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/timeboxing Date: 2022-10-11 Tags: time-management, regression-analysis, causal-inference Original post: https://blog-about-people-analytics.netlify.app/posts/2022-10-11-timeboxing/ In one of my [previous posts](https://blog-about-people-analytics.netlify.app/posts/2022-06-11-visual-inference-statistics/), I addressed the question of why people don't make more use of **[timeboxing](https://clockify.me/timeboxing)**, a time management tool relying on a well-researched self-regulatory technique called **[implementation intention](https://en.wikipedia.org/wiki/Implementation_intention)** - planning what you will do, when, and how. Quite surprisingly, I found in our collaborative data that there is not a positive but a slightly negative relationship between the amount of time for focused work and the amount of blocked working time in the calendar, which I interpreted to mean that people who have plenty of time for focused work usually do not have a strong need to block time for focused work in their calendars. However, based on these results, one colleague wondered whether this result actually speaks against the usefulness of this tool. To answer her question properly, we should avoid comparing apples with pears and control for the effect of the number of collaborative activities people participate in, as it can be assumed that those who spend more time collaborating with others have less time for focused work and also use the timeboxing technique more. Using this approach and our clients' collaborative data, I looked at the relationship between the proportion of work time blocked on the calendar and the time available for focused work (i.e. no meetings, no ad-hoc calls, no email or instant messaging), and found that the marginal effect of timeboxing is in line with the positive effect of the timeboxing technique on the time available for focused work. The effect is not huge (each percentage point of working time blocked in the calendar yields on average .19% of focus rate), however, timeboxing seems to be saved, phew 😉 ![](./timeboxing/timeblockingFocusTime.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-10-11-timeboxing/) on my blog. --- # Are back-to-back meetings for good or bad? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/back-to-back-meetings Date: 2022-09-17 Tags: meetings Original post: https://blog-about-people-analytics.netlify.app/posts/2022-09-17-back-to-back-meetings/ "Context, Context, Context" could be the headline of this post. When we address the issue of good meeting habits with [our](https://www.timeisltd.com/) clients, the length of breaks between successive meetings is one of the first metrics we focus on. As many of you probably know from your firsthand experience, consecutive meetings with no breaks, a.k.a. **back-to-back meetings**, have many detrimental effects, from [overload and exhaustion](https://www.microsoft.com/en-us/worklab/work-trend-index/brain-research) to not being adequately prepared for subsequent meetings and [arriving late](https://bpspsychub.onlinelibrary.wiley.com/doi/abs/10.1111/joop.12183) to them. As useful as the above metric is, it does not tell the whole story and can lead to invalid conclusions and see a problem where there is none. Data from one of our teams illustrates this well. ```r # uploading libraries library(tidyverse) library(patchwork) library(scales) # uploading datasets from the platform data1 <- readr::read_delim("./timeisltd-chart1.csv",delim = ";") %>% rename(cat = `...1`) data2 <- readr::read_delim("./timeisltd-chart2.csv",delim = ";") %>% rename(cat = `...1`) data3 <- readr::read_delim("./timeisltd-chart3.csv",delim = ";") %>% rename(cat = `...1`) # Time between successive meetings g1 <- data1 %>% dplyr::mutate( all = sum(data), prop = data/all ) %>% ggplot2::ggplot(aes(x = cat, y = prop)) + ggplot2::geom_bar(stat = "identity", fill = "#20066b", alpha = 0.85) + ggplot2::scale_y_continuous(labels = scales::percent_format()) + ggplot2::labs( x = "", title = "Time between successive meetings", y = "PROPORTION OF CASES" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 20, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 12, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position=c(.15,.5), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_line(color = "#E0E1E6", linetype = "dashed"), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Number of back-to-back meetings in a row g2 <- data2 %>% dplyr::mutate( all = sum(data), prop = data/all, cat = as.numeric(cat) ) %>% ggplot2::ggplot(aes(x = cat, y = prop)) + ggplot2::geom_bar(stat = "identity", fill = "#e56b61", alpha = 0.85) + ggplot2::scale_y_continuous(labels = scales::percent_format()) + ggplot2::scale_x_continuous(labels = scales::number_format(suffix = " mtgs", accuracy = 1)) + ggplot2::labs( x = "", title = "Number of back-to-back meetings in a row", y = "PROPORTION OF CASES" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 20, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 12, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position=c(.15,.5), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_line(color = "#E0E1E6", linetype = "dashed"), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # Time spent in back-to-back meetings in a row g3 <- data3 %>% dplyr::mutate( all = sum(data), prop = data/all, cat = factor(cat, levels = c("31-60 Min", "61-90 Min", "91-120 Min", "121-150 Min", "151-180 Min", "181+ Min"), ordered = TRUE) ) %>% ggplot2::ggplot(aes(x = cat, y = prop)) + ggplot2::geom_bar(stat = "identity", fill = "#46c8ae", alpha = 0.85) + ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1)) + ggplot2::labs( x = "", title = "Time spent in back-to-back meetings in a row", y = "PROPORTION OF CASES" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 20, margin=margin(0,0,20,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 12, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position=c(.15,.5), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_line(color = "#E0E1E6", linetype = "dashed"), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # putting graphs together g <- g1/g2/g3 print(g) ``` ![](./back-to-back-meetings/unnamed-chunk-1-1.png) Based purely on the time between successive meetings, we could conclude that a given team suffers from an unhealthy frequency of back-to-back meetings, as in more than a third of cases, there is no break between meetings. However, if we look at how long the series of back-to-back meetings tend to be (in 75% of cases it's only 2 meetings in a row) and how much time people spend in them (in 42% of cases it’s between 31-60 minutes and in 30% of cases it’s between 61-90 minutes), then the resulting picture is less pessimistic and more indicative of a rather healthy level of effort to protect time for **focused work** by **batching** meetings into short blocks that do not come at the cost of exhausting people and making meetings less effective. What is your approach to back-to-back meetings? Do you try to always have at least a 5-minute buffer between two consecutive meetings? And how successful are you at this? Are you aware of situations where it is appropriate to batch meetings into tight blocks without breaks? And do you have a limit on how many meetings to put in a row? Feel free to share your thoughts and experiences in the comments. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-09-17-back-to-back-meetings/) on my blog. --- # Collaboration and personality URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/collaboration-and-personality Date: 2022-09-17 Tags: collaboration, communication, personality, big-five Original post: https://blog-about-people-analytics.netlify.app/posts/2022-09-17-collaboration-and-personality/ One of [our](https://www.timeisltd.com/) clients once asked us to what extent employees’ level of engagement in corporate communication and collaboration is driven by their personality and to what extent by their job role, the conditions in which they work, and other factors outside their personality. Our first answer was that the latter probably plays a more significant role than the former, but it was difficult to answer more specifically because we did not yet have all the data we needed to quantify the tightness of this relationship. This motivated me to look at existing research on this topic to help us better set our apriori expectations on this issue. With the help of [metaBus](http://metabus.org/), an amazing platform for curating, searching, and summarizing research findings from the social and organizational sciences, I was able to get the results of over 100 studies on the relationship between employees’ [Big 5 personality traits](https://en.wikipedia.org/wiki/Big_Five_personality_traits) and the amount of interaction and networking behavior they engage in. Among the criteria for the amount of interaction were variables like contact frequency, frequency of participation, communication frequency, meeting frequency, hours of interaction, interaction duration, etc. For networking behavior there were criteria as liaison, building networks, relationship building, network activity, maintaining contacts, increasing internal visibility, network ability, and informal network. ```r # The following concepts were used to search for relevant studies on the metaBus platform (their respective codes are given in brackets): # Big 5 (20443) # Amount of interaction (20287) # Networking behavior (80017) # uploading libraries library(tidyverse) # for data manipulation and visualization library(ggridges) # for data visualization library(ggtext) # for enabling markdown in ggplots library(patchwork) # for combining ggplots # data preparation # uploading data interaction <- readr::read_csv("./interactionAmount.csv") networking <- readr::read_csv("./networkingBehavior.csv") # preparing dataset for amount of interaction concept interactionPrep <- interaction %>% dplyr::filter( # removing non-relevant personality characteristics !Var1 %in% c("Empathic concern"), # limiting to studies conducted at the individual level Var2Unit == "Individual" ) %>% # reversing Neuroticism to Emotional Stability dplyr::mutate( r = case_when( Var1 == "Neuroticism" ~ r*-1, TRUE ~ r ) ) %>% # uniting the names of personality characteristics across the studies dplyr::mutate( Var1 = case_when( stringr::str_detect(Var1, "Extraversion") | stringr::str_detect(Var1, "Extroversion") | stringr::str_detect(Var1, "extraversion") ~ "Extraversion", stringr::str_detect(Var1, "Openness to experience") | stringr::str_detect(Var1, "openness") ~ "Openness", stringr::str_detect(Var1, "agreeableness") ~ "Agreeableness", stringr::str_detect(Var1, "emotional stability") | stringr::str_detect(Var1, "Neuroticism") ~ "Emotional Stability", stringr::str_detect(Var1, "Conscientious") | stringr::str_detect(Var1, "Consciousness") ~ "Conscientiousness", TRUE ~ Var1 ) ) # preparing dataset for networking behavior concept networkingPrep <- networking %>% dplyr::filter( Var2Unit == "Individual" ) %>% # reversing Neuroticism to Emotional Stability dplyr::mutate( r = case_when( Var1 == "Neuroticism" ~ r*-1, TRUE ~ r ) ) %>% # uniting the names of personality characteristics across the studies dplyr::mutate( Var1 = case_when( stringr::str_detect(Var1, "Openness") ~ "Openness", stringr::str_detect(Var1, "Emotional stability") | stringr::str_detect(Var1, "Neuroticism") ~ "Emotional Stability", stringr::str_detect(Var1, "Conscientious") | stringr::str_detect(Var1, "Consciousness") ~ "Conscientiousness", TRUE ~ Var1 ) ) # data visualization # creating chart for the amount of interaction concept interactionChart <- interactionPrep %>% ggplot2::ggplot(aes(x = r, y = Var1)) + ggplot2::geom_vline(xintercept = 0, linetype = "dashed", size = 0.56) + ggridges::geom_density_ridges( fill = "#e56b61", alpha = 0.5, scale = 1, jittered_points = TRUE, position = position_points_jitter(width = 0, height = 0,seed = 123), point_shape = '|', point_size = 1, point_alpha = 1, quantile_lines =TRUE, vline_linetype = "solid", vline_color = "black", vline_size = 0.55 #quantile_fun=function(x,...)median(x) ) + ggplot2::scale_x_continuous(limits = c(-1, 1), breaks = seq(-1,1,0.2)) + ggplot2::labs( x = "PEARSON CORRELATION COEFFICIENT", y = "" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 20, margin=margin(0,0,12,0), hjust = 0.5), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), legend.title = element_text(color = '#2C2F46', face = "plain", size = 12), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.text.x = element_text(), legend.position = "right", axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # creating chart for the networking behavior concept networkingChart <- networkingPrep %>% ggplot2::ggplot(aes(x = r, y = Var1)) + ggplot2::geom_vline(xintercept = 0, linetype = "dashed", size = 0.56) + ggridges::geom_density_ridges( fill = "#32b2c7", alpha = 0.5, scale = 1, jittered_points = TRUE, position = position_points_jitter(width = 0, height = 0,seed = 123), point_shape = '|', point_size = 1, point_alpha = 1, quantile_lines =TRUE, vline_linetype = "solid", vline_color = "black", vline_size = 0.55 #quantile_fun=function(x,...)median(x) ) + ggplot2::scale_x_continuous(limits = c(-1, 1), breaks = seq(-1,1,0.2)) + ggplot2::labs( x = "PEARSON CORRELATION COEFFICIENT", y = "" ) + ggplot2::theme( plot.title = element_text(color = '#2C2F46', face = "bold", size = 20, margin=margin(0,0,12,0), hjust = 0.5), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), legend.title = element_text(color = '#2C2F46', face = "plain", size = 12), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10), axis.text.y = element_blank(), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), legend.position = "right", axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # combining the charts together g <- interactionChart + networkingChart # adding title and caption g <- g + patchwork::plot_annotation( title = "**Do Big 5 traits predict** **the amount of interaction** **&** **networking behavior** **of employees?** ", caption = "The solid vertical lines represent quartile values.\nBased on studies found on the metaBus platform using the concepts 'Big 5' (code: 20443), 'Amount of interaction' (code: 20287), and 'Networking behavior' (code: 80017).", theme = theme( plot.title = ggtext::element_markdown(lineheight = 1.1, margin=margin(10,0,12,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 9, hjust = 0) ) ) print(g) ``` ![](./collaboration-and-personality/unnamed-chunk-1-1.png) As can be seen from the graphs above, the relationship between personality and the amount of interaction and networking behavior goes in the expected direction. Agreeable, open, and extraverted employees and to some extent also conscientious and emotionally stable employees tend to engage more in interactions with others and in networking. However, the relationships found are relatively weak. The middle 80% of observed effects range from an absolute value of .02 to .24, so across the studies shown, small effects prevail. And even in the case of the strongest effect (*r* = .38), personality “explains” only 14% of the variability in the networking behavior. There is therefore ample scope for the influence of a range of other factors. How about you? Are you able to engage in interactions and networking in a way that supports your career, work performance, or other positive outcomes, perhaps despite your natural tendencies due to your personality setup? Feel free to share your experience and thoughts in the comments. Btw, you can find interesting information on this topic in the excellent book [8 Steps to High Performance](https://www.amazon.com/Steps-High-Performance-Change-Ignore/dp/163369397X) by [Marc Effron](https://www.linkedin.com/in/effron/), specifically in chapters 4 and 6. **Caveat:** The graphs represent only a simple summary of the effects observed in the selected studies, not a proper meta-analysis. If you're interested in the specific analysis steps and choices behind the graphs shown, you can check the code above or go to my [GitHub page](https://github.com/lstehlik2809/Personality-interaction-and-networking). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-09-17-collaboration-and-personality/) on my blog. --- # Hot spots of collaboration overload and collaboration bottlenecks and how to find them URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/collaboration-overload-and-bottlenecks Date: 2022-09-17 Tags: collaboration, network-analysis, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-09-17-collaboration-overload-and-bottlenecks/ One simple way to identify such hot spots is to compare the **outbound and inbound collaborative activities** in which teams or individuals participate. The greater the difference between the two in favor of inbound collaboration activities, the stronger the signal that collaboration overload and/or bottlenecks may be a problem for that team or individual. To illustrate, take a look at the attached charts that show the patterns of collaboration between several teams via Slack. The distances between teams, the thickness, and the direction of the arrows between them tell us who is collaborating with whom and how much. The size of the nodes then represents the amount of inbound and outbound collaborative activities that the teams participate in, respectively. Based on the differences between them, the bar chart below shows us where the inbound collaboration activities outweigh the outbound ones the most, and therefore where the risk of collaboration overload and/or bottlenecks is greatest. ```r # uploading libraries library(tidyverse) library(igraph) library(ggraph) library(patchwork) # uploading data with nodes/ties based on the current frequency of communication via Slack nodesR <- readr::read_csv("./nodes.csv") tiesR <- readr::read_csv("./ties.csv") # specifying cut-off value for showing only the x% of strongest edges prob = 1 # changing coding of individual nodes in the network ties <- tiesR %>% dplyr::left_join(nodesR, by = c("from" = "id")) %>% dplyr::select(-from) %>% dplyr::rename(from = name) %>% dplyr::left_join(nodesR, by = c("to" = "id")) %>% dplyr::select(-to) %>% dplyr::rename(to = name) %>% dplyr::select(from, to, weight) %>% dplyr::mutate( from = stringr::str_to_title(from), to = stringr::str_to_title(to), from = stringr::str_replace(from, "Development", "Dev"), to = stringr::str_replace(to, "Development", "Dev"), from = stringr::str_replace(from, "Dev Management", "Dev - Management"), to = stringr::str_replace(to, "Dev Management", "Dev - Management") ) nodes <- nodesR %>% dplyr::select(-id) %>% dplyr::mutate( name = stringr::str_to_title(name), name = stringr::str_replace(name, "Development", "Dev"), name = stringr::str_replace(name, "Dev Management", "Dev - Management") ) # making the network from the data frame g <- igraph::graph_from_data_frame(d = ties, vertices = nodes, directed = TRUE) # setting name of the network g$name <- "Collaboration via Slack" # assigning ids to nodes V(g)$id <- seq_len(vcount(g)) # cutoff value for showing only the x% of strongest edges cutoff <- quantile(ties$weight, probs = prob)[[1]] # visualizing the inbound network set.seed(1234) inG <- ggraph(g, layout = 'fr', maxiter = 50000) + ggraph::geom_edge_link(aes(edge_width = ifelse(weight > cutoff, NA, weight), edge_color = weight), arrow = arrow(length = unit(3, 'mm')), end_cap = circle(2, 'mm')) + ggraph::geom_node_point(aes(size = inbound), alpha = 0.5, fill = "#32b2c7", color = "#32b2c7") + ggraph::scale_edge_width(range = c(0.1, 1.8)) + ggraph::scale_edge_color_gradient(low = "#b8b6b6", high = "#000000", guide = "none") + ggplot2::scale_size(range = c(0.5, 20)) + ggraph::geom_node_text(aes(label = name), repel = TRUE, size = 8) + ggraph::theme_graph(background = "white", foreground = "grey" , border = TRUE) + ggplot2::theme( legend.position = "", legend.box = "vertical", legend.title=element_text(size=8), legend.text=element_text(size=8), legend.spacing.y = unit(-0.2, "cm"), plot.title = element_text(hjust = 0.5, size = 30), plot.caption.position = "plot" ) + ggplot2::guides( size = guide_legend(reverse=TRUE, order = 1), color = guide_legend(order = 3, ncol=10, override.aes = list(size=5)), edge_width = guide_legend(reverse=TRUE, order = 2) ) + ggplot2::labs( edge_width = "Mutual collaboration", edge_color = "Mutual collaboration", color = "", size = "Communication intensity", title = stringr::str_glue("INBOUND COLLABORATION ACTIVITIES") ) # visualizing the outbound network set.seed(1234) outG <- ggraph(g, layout = 'fr', maxiter = 50000) + ggraph::geom_edge_link(aes(edge_width = ifelse(weight > cutoff, NA, weight), edge_color = weight), arrow = arrow(length = unit(3, 'mm')), end_cap = circle(2, 'mm')) + ggraph::geom_node_point(aes(size = outbound), alpha = 0.5, fill = "#46c8ae", color = "#46c8ae") + ggraph::scale_edge_width(range = c(0.1, 1.8)) + ggraph::scale_edge_color_gradient(low = "#b8b6b6", high = "#000000", guide = "none") + ggplot2::scale_size(range = c(0.5, 20)) + ggraph::geom_node_text(aes(label = name), repel = TRUE, size = 8) + ggraph::theme_graph(background = "white", foreground = "grey" , border = TRUE) + ggplot2::theme( legend.position = "", legend.box = "vertical", legend.title=element_text(size=8), legend.text=element_text(size=8), legend.spacing.y = unit(-0.2, "cm"), plot.title = element_text(hjust = 0.5, size = 30), plot.caption.position = "plot" ) + ggplot2::guides( size = guide_legend(reverse=TRUE, order = 1), color = guide_legend(order = 3, ncol=10, override.aes = list(size=5)), edge_width = guide_legend(reverse=TRUE, order = 2) ) + ggplot2::labs( edge_width = "Mutual collaboration", edge_color = "Mutual collaboration", color = "", size = "Communication intensity", title = stringr::str_glue("OUTBOUND COLLABORATION ACTIVITIES") ) # bar chart with info about difference between inbound and outbound collaboration activities inoutDiffG <- nodes %>% ggplot2::ggplot(aes(x = forcats::fct_reorder(name, inoutDiff), y = inoutDiff)) + ggplot2::geom_bar(stat = "identity", fill = ifelse(nodes$inoutDiff > 0, "#e56b61", "#20066b"), alpha = 0.85) + ggplot2::scale_y_continuous(breaks = seq(-200,200,50)) + ggplot2::labs( x = "", title = "DIFFERENCE BETWEEN IN/OUTBOUND COLLABORATION ACTIVITIES", y = "DIFFERENCE BETWEEN THE NUMBER OF IN/OUTBOUND INSTANT MESSAGES" ) + ggplot2::coord_flip() + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 30, margin=margin(0,0,12,0), hjust = 0.5), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 24, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 12, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 22, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position=c(.15,.5), legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_line(color = "#E0E1E6", linetype = "dashed"), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.caption.position = "plot" ) # combining the charts g <- (inG + outG) / inoutDiffG print(g) ``` ![](./collaboration-overload-and-bottlenecks/unnamed-chunk-1-1.png) We see that the “hottest” hot spots are in teams Dev-Management and Dev-Frontend. While this is not definitive proof that we have real problems in these two specific teams, it should be a strong enough signal to take notice and try to verify our suspicion with additional information, such as checking some relevant business metrics or simply asking a few people we know should be affected, if there is a problem. If the initial suspicion is confirmed, appropriate action should be taken, e.g. consider the relevance of some requests, possibly redirect them to other teams, automate some tasks, expand the team and recruit new people, etc. For more tips on how to leverage collaboration data in the current uncertain economic times, I recommend reading the articles [Top 7 Collaboration Metrics to Utilize in an Economic Crisis](https://www.timeisltd.com/post/top-7-collaboration-metrics-to-utilize-in-an-economic-crisis) by [Jan Rezab](https://www.linkedin.com/in/janrezab/) and [Top 6 Metrics to measure during an economic downturn](https://www.linkedin.com/pulse/top-6-metrics-measure-during-economic-downturn-shwetha-pai/) by [Shwetha Pai](https://www.linkedin.com/in/shwethatpai/). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-09-17-collaboration-overload-and-bottlenecks/) on my blog. --- # Multilevel modeling in people analytics URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/multilevel-modeling Date: 2022-09-17 Tags: multilevel-modeling, bayesian-statistics, collaboration, employee-engagement, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-09-17-multilevel-modeling/ In one of [our](https://www.timeisltd.com/) projects, where we were trying to find out how collaborative behavior relates to employee engagement, we repeatedly came across patterns that reminded the client of internally well-known differences between the behavior of teams from different parts of the company. For example, we found that more frequent participation in short and small meetings was related to lower employee engagement. This pattern matched well the client's observation that one particular part of the company had regular daily stand-up meetings and also lower engagement scores compared to the rest of the company due to some other aspects of their work. As further analysis confirmed, this pattern was really just a statistical artifact caused by the coincidence of these two facts. One way analysts can protect themselves from this type of misleading conclusions is by using **multilevel or hierarchical models** that take into account the fact that the **data have a nested structure**, i.e. that some observations are not independent of each other because they belong to the same higher-order group, e.g. to an organizational unit (one of the basic assumptions of most statistical models in use). This is well illustrated in the graphs below. They show that the relationship between the number of monthly 1:1s that employees have with their line manager and their subjectively perceived support from their line manager is slightly positive across most groups of teams (shown by colored dots and lines), but when all teams are analyzed together, the relationship is rather negative (shown by black dots and lines). ```r # uploading libraries library(tidyverse) library(scales) library(patchwork) library(ggtext) data <- readr::read_csv("./data.csv") # chart showing a relationship between 1:1s and perceived managerial support while taking into account differences between organizational units g1 <- data %>% ggplot2::ggplot(aes(oneonones, mngsupport)) + ggplot2::geom_point(aes(col = Unit), size = 2.5, alpha = 0.5) + ggplot2::geom_smooth(aes(col = Unit), method = 'lm', alpha=0.2, se = F) + labs( x = "AVERAGE MONTHLY NUMBER OF 1:1S", y = "PROPORTION OF FAVORABLE RESPONSES", color = "" ) + ggplot2::scale_y_continuous(labels = scales::percent_format()) + ggplot2::scale_x_continuous(breaks = seq(0,5,1)) + ggplot2::scale_color_manual(values = c("Unit A" = "#20066b", "Unit B" = "#e56b61", "Unit C" = "#b4ba0d", "Unit D" = "#32b2c7")) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 21, margin=margin(0,0,0,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,0,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # chart showing a relationship between 1:1s and perceived managerial support without taking into account differences between organizational units g2 <- data %>% ggplot2::ggplot(aes(oneonones, mngsupport)) + ggplot2::geom_point(size = 2.5, alpha = 0.5) + ggplot2::geom_smooth(method = 'lm', alpha=0.2, linetype = "solid", color = "black", se = F) + ggplot2::labs( x = "AVERAGE MONTHLY NUMBER OF 1:1S", y = "PROPORTION OF FAVORABLE RESPONSES" ) + ggplot2::scale_y_continuous(labels = scales::percent_format()) + ggplot2::scale_x_continuous(breaks = seq(0,5,1)) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "bold", size = 21, margin=margin(0,0,0,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,0,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # combining the charts g <- g2 + g1 g <- g + patchwork::plot_annotation( title = "**Relationship between the number of 1:1s and perceived managerial support across**
**and within organizational units**
", theme = theme( plot.title = ggtext::element_markdown(lineheight = 1.1, margin=margin(12,0,12,0), size = 22, face="bold") ) ) print(g) ``` ![](./multilevel-modeling/unnamed-chunk-1-1.png) ```r # uploading libraries library(brms) library(cmdstanr) # fitting Bayesian hierarchical linear regression model model <- brms::brm( brms::bf(mngsupport | trunc(lb = 0, ub = 1) ~ 1 + oneonones + (1 + oneonones | Unit)), data = data, family = gaussian(), chains = 3, iter = 3000, warmup = 1000, cores = 6, seed = 1234, backend = "cmdstanr", refresh = 0, silent = 2 ) # checking the fitted model # summary(model) # plot(model) # brms::pp_check(model, ndraws = 100) # fitting Bayesian non-hierarchical linear regression model modelNonHierarchical <- brms::brm( brms::bf(mngsupport | trunc(lb = 0, ub = 1) ~ 1 + oneonones), data = data, family = gaussian(), chains = 3, iter = 3000, warmup = 1000, cores = 6, seed = 1234, backend = "cmdstanr", refresh = 0, silent = 2 ) # checking the fitted model # summary(modelNonHierarchical) # plot(modelNonHierarchical) # brms::pp_check(modelNonHierarchical, ndraws = 100) ``` ```r # uploading libraries library(emmeans) library(tidybayes) # marginal effect of 1:1s in the Bayesian hierarchical linear regression model avg_marginal_effect <- model %>% emmeans::emmeans(~ oneonones, at = list(oneonones = seq(0, 6, by = 0.1)), epred = TRUE, re_formula = NULL) %>% tidybayes::gather_emmeans_draws() gf1 <- ggplot2::ggplot(avg_marginal_effect, aes(x = oneonones, y = .value)) + tidybayes::stat_lineribbon() + ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1), breaks = seq(0.62, 0.8, 0.05), limits = c(0.62, 0.8)) + ggplot2::scale_x_continuous(breaks = seq(0,6,1)) + ggplot2::scale_fill_brewer(palette = "Purples") + ggplot2::labs( x = "AVERAGE MONTHLY NUMBER OF 1:1S", y = "PREDICTED % OF FAVORABLE RESPONSES", fill = "Credible interval", title = "Hierarchical linear regression model" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,0,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # marginal effect of 1:1s in the Bayesian non-hierarchical linear regression model avg_marginal_effect_nonHierarchical <- modelNonHierarchical %>% emmeans::emmeans( ~ oneonones, at = list(oneonones = seq(0, 6, by = 0.1)), epred = TRUE, re_formula = NULL) %>% tidybayes::gather_emmeans_draws() gf2 <- ggplot2::ggplot(avg_marginal_effect_nonHierarchical, aes(x = oneonones, y = .value)) + tidybayes::stat_lineribbon() + ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 1), breaks = seq(0.62, 0.8, 0.05), limits = c(0.62, 0.8)) + ggplot2::scale_x_continuous(breaks = seq(0,6,1)) + ggplot2::scale_fill_brewer(palette = "Purples") + ggplot2::labs( x = "AVERAGE MONTHLY NUMBER OF 1:1S", y = "PREDICTED % OF FAVORABLE RESPONSES", fill = "Credible interval", title = "Non-hierarchical linear regression model" ) + ggplot2::theme(plot.title = element_text(color = '#2C2F46', face = "plain", size = 19, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", size = 16, margin=margin(0,0,0,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position= "bottom", legend.key = element_rect(fill = "white"), legend.key.width = unit(1.6, "line"), legend.margin = margin(0,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", size = 10, lineheight = 16), legend.background = element_rect(fill = "transparent"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) # combining the charts gf <- gf2 + gf1 gf <- gf + patchwork::plot_annotation( title = "**Posterior average marginal effect of 1:1 meetings on perceived managerial support** ", theme = theme( plot.title = ggtext::element_markdown(lineheight = 1.1, margin=margin(12,0,0,0), size = 22, face="bold") ) ) print(gf) ``` ![](./multilevel-modeling/unnamed-chunk-3-1.png) Without the use of the hierarchical model (and/or a careful post-hoc visual check of alternative explanations), we would reach a completely opposite (and incorrect) conclusion about the relationship between the number of 1:1s and perceived support from the line manager (a phenomenon known as [Simpson’s paradox](https://en.wikipedia.org/wiki/Simpson%27s_paradox)). In this particular case, it is relatively easy to recognize that something may be wrong, but the situation is not always so obvious and intuitive. In these other cases, it is advantageous to have some tool at hand to compensate for our imperfect intuition and limited knowledge and imagination. Hierarchical models are one such tool. If you don't already have it in your analytics toolbox, be sure to give it a try. If you work with [R](https://www.r-project.org/), you can use the [lme4](https://cran.r-project.org/web/packages/lme4/index.html) or [brms](https://cran.r-project.org/web/packages/brms/index.html) packages to implement it. In a [Python environment](https://www.python.org/), you can use the [statsmodels](https://www.statsmodels.org/stable/index.html) or [PyMC3](https://docs.pymc.io/en/v3/index.html) libraries to do this. And if you're more used to drag-and-drop tools, then [JASP](https://jasp-stats.org/) or [jamovi](https://www.jamovi.org/) (both open-source alternatives to [SPSS](https://www.ibm.com/analytics/spss-statistics-software)) will give you access to various mixed models through an easy-to-use graphical interface. For an accessible discussion of this topic in the context of people analytics, including other useful tools for working with hierarchical data, see also the excellent articles by [Paul van der Laken](https://www.linkedin.com/in/paulvanderlaken/), [John Lipinski](https://www.linkedin.com/in/john-lipinski-ph-d-99723814/), and [Max Blumberg](https://www.linkedin.com/in/maxblumberg/): * [Simpson’s Paradox: Two HR examples with R code.](https://paulvanderlaken.com/2017/09/27/simpsons-paradox-two-hr-examples-with-r-code/) * [How to Avoid Aggregation Errors and Simpson’s Paradox in HR Analytics: Part 1](https://hranalytics101.com/how-to-avoid-aggregation-errors-and-simpsons-paradox-in-hr-analytics-part-1/) * [How to Avoid Aggregation Errors and Simpson’s Paradox in HR Analytics: Part 2](https://hranalytics101.com/how-to-avoid-aggregation-errors-and-simpsons-paradox-in-hr-analytics-part-2/) * [Why People Analytics should NOT be using regression to predict team outcomes](https://www.linkedin.com/pulse/why-peopleanalytics-should-using-regression-predict-team-max-blumberg/) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-09-17-multilevel-modeling/) on my blog. --- # People Analytics Challenge from Orgnostic: Plan for high growth URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-analytics-challenge-from-orgnostic Date: 2022-08-20 Tags: recruitment, people-analytics, statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-08-20-people-analytics-challenge-from-orgnostic/ During the last few nights, I had the opportunity to participate in an interesting **People Analytics Challenge** from [Orgnostic](https://orgnostic.com/), a company providing people analytics platform that links scattered HR and finance data, run surveys on top, analyses the data, and get answers to the critical questions about organizations and their employees. Out of three possible challenges, I chose one that was quite far from what I'm currently usually working on or what I've worked on in the past. The chosen task was to analyze the **effectiveness of recruiting sources** for a company that plans to double in size from its current 750+ employees. Although the dummy data provided was quite limited for obvious reasons and did not allow to answer all relevant questions (but you could also use your own data which would not suffer from this shortcoming), after combining them and enriching them slightly based on realistic assumptions, it was possible to arrive at quite interesting insights and recommendations. See for yourself - the resulting presentation is attached to this post below.

This browser does not support PDFs. Please download the PDF to view it: Download PDF.


If you'd like to look more into the guts of the analyses conducted, you can find both the data provided by Orgnostic and the R script used to analyze it on [my GitHub page](https://github.com/lstehlik2809/People-Analytics-Challenge). P.S. Oh, I almost forgot - there are exciting prizes in the form of tickets to HRtechX Copenhagen or HR Technology Conference in Las Vegas. So please keep your fingers crossed for me 🤞😉 --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-08-20-people-analytics-challenge-from-orgnostic/) on my blog. --- # The impact of the COVID pandemic on the popularity of people analytics URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/people-analytics-popularity-after-covid Date: 2022-08-20 Tags: people-analytics, remote-work, segmented-regression, time-series, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-08-20-people-analytics-popularity-after-covid/ ## People analytics popularity after pandemic There is a fairly common perception among the people analytics professionals with whom I am in contact that after the COVID pandemic, companies are much more willing to use the insights provided by people analytics teams and incorporate them into their business-related decision-making processes. I wondered if I could find any empirical support for this feeling in the surge of global web search interest in "people analytics" and "HR analytics" terms on Google after the pandemic outbreak, assuming the pandemic broke out in March 2020. Let's start our quest with a simple visual inspection of a line chart showing the trend of worldwide web search interest in "people analytics" and "HR analytics" terms on Google from January 2007 to July 2022. (You can replicate this chart using the [Google Trends](https://trends.google.com/trends) website and the search terms, time range, location, and source for searches described above. If you are familiar with R, you can use the code below.) ```r # uploading library for getting data from Google Trends library(gtrendsR) # uploading libraries for data manipulation library(tidyverse) library(lubridate) # setting parameters for Google Trends searchTerms <- c("people analytics", "hr analytics") location <- "" # global time <- "2007-01-01 2022-07-31" source <- "web" # getting Google Trends data gtrendsResult <- gtrendsR::gtrends( keyword = searchTerms, geo = location, time = time, gprop = source ) # cleaning data gtrendsResultDf <- gtrendsResult %>% purrr::pluck("interest_over_time") %>% dplyr::select(date, hits, keyword) %>% dplyr::mutate(date = lubridate::ymd(date)) ``` ![](./people-analytics-popularity-after-covid/peopleAnalyticsGoogleTrends.png) Towards the end of the time series, somewhere between September 2019 and March 2020, it seems that the trend stops increasing and starts to stagnate, except for the very last part of the graph, which shows a sharp increase in searches for both terms, but this may just be the result of the improved data collection system from January 2022 onwards (as indicated by the vertical line in the graph with a note). Thus, the data seems to suggest the opposite of what we would expect if a pandemic were to have a positive effect on interest in people analytics. However, after combining the results for the two search terms and plotting them on a graph together with the unadjusted regression trend lines, the resulting picture gives a slightly different impression. There appears to be little to no decline in interest in people analytics immediately after the pandemic outbreak, but a steeper slope of change after the pandemic. ```r # normalize function normalize <- function(x) { return ((x - min(x)) / (max(x) - min(x))) } gtrendsResultDf %>% tidyr::pivot_wider(names_from = "keyword", values_from = "hits") %>% dplyr::mutate( interestInPeopleAnalytics = `people analytics` + `hr analytics`, interestInPeopleAnalytics = normalize(interestInPeopleAnalytics)*100 ) %>% # creating new pandemic variable dplyr::mutate( pandemic = case_when( date >= '2020-03-01' ~ "After the pandemic outbreak", TRUE ~ "Before the pandemic outbreak" ) )%>% ggplot2::ggplot(aes(x = date, y = interestInPeopleAnalytics, color = pandemic)) + ggplot2::geom_point() + ggplot2::geom_smooth(method = "lm", se = FALSE) + ggplot2::scale_color_viridis_d(option = "plasma", end = 0.8, name = "") + ggplot2::scale_x_date(breaks = "1 year", date_labels = "%Y") + ggplot2::labs( x = "", y = "Interest in people analytics", title = "Interest in people analytics before and after the pandemic outbreak", caption = "The solid lines represent unadjusted regression model trend lines before and after the pandemic outbreak." ) + ggthemes::theme_clean() + ggplot2::theme( legend.position = "bottom", legend.background = element_blank() ) ``` ![](./people-analytics-popularity-after-covid/unnamed-chunk-2-1.png) To make the picture a little bit clearer, let us take the help of inferential statistics to answer the question we are interested in. The ideal analytical tool for our use case is a segmented regression analysis of interrupted time series that enables estimation of the changes in levels and trends of search interest before and after after a known ‘intervention’ or ‘interruption’ (i.e., a change that could potentially affect the outcome variable). It does so by segmenting the time series data into different periods based on the known interruption points and modeling these segments separately. The model used has the following general structure: $$Y_{t} = β_{0} + β_{1}*time_{t} + β_{2}*intervention_{t} + β_{3}*time after intervention_{t} + e_{t}$$ The *β0* coefficient estimates the baseline level of the outcome variable at time zero; *β1* coefficient estimates the change in the mean of the outcome variable that occurs with each unit of time before the intervention (i.e. the baseline trend); *β2* coefficient estimates the level change in the mean of the outcome variable immediately after the intervention (i.e. from the end of the preceding segment); and *β3* estimates the change in the trend in the mean of the outcome variable per unit of time after the intervention, compared with the trend before the intervention (thus, the sum of *β1* and *β3* equals to the post-intervention slope). For a better understanding of the model, take a look at the illustrative chart taken from [Turner et al. (2021)](https://bmcmedresmethodol.biomedcentral.com/articles/10.1186/s12874-021-01306-w) below. ![](./people-analytics-popularity-after-covid/interruptedTimeSeriesAnalysis.png) Since we are dealing with correlated and truncated data, we should also include two additional terms in our model, an autocorrelation term and a truncation term, to handle these specific properties of our data. ## Data preparation But first, let's prepare the data we will need for this type of analysis. Specifically, we will need the following five variables: * **search interest in people analytics** – a numerical variable representing search interest in people analytics relative to the highest point on the chart for the given region and time; this variable is truncated within the interval between values of 0 and 100; a value of 100 is the peak popularity for the term; a value of 50 means that the term is half as popular; a score of 0 means that there was not enough data for this term; search interest for two monitored terms ("people analytics" and "HR analytics") was combined by simple summation and then normalized to a range of 0 to 100; this variable serves as a dependent (criterion) variable; * **elapsed time** – a numerical variable representing the number of months that elapsed from the beginning of the time series; this variable enables estimation of the size and direction of the overall trend in the data before the intervention; * **pandemic** – a binary variable indicating the presence/absence of pandemic; as already mentioned above, for the purpose of this analysis, the beginning of the pandemic is assumed to have started in March 2020; this variable enables estimation of the level change in the interest in people analytics immediately after the pandemic outbreak; * **elapsed time after pandemic outbreak** – a numerical variable representing the number of months that elapsed from the beginning of pandemic; this variable enables estimation of the change in the trend in the interest in people analytics after the outbreak of pandemic; * **month** – a categorical variable representing specific month within a year; this variable enables controlling for the effect of seasonality. ```r # munging data mydata <- gtrendsResultDf %>% tidyr::pivot_wider(names_from = "keyword", values_from = "hits") %>% dplyr::mutate( interestInPeopleAnalytics = `people analytics` + `hr analytics`, interestInPeopleAnalytics = normalize(interestInPeopleAnalytics)*100, month = lubridate::month(date,label = TRUE, abbr = TRUE), month = factor(month, levels = c("Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"), labels = c("Jan","Feb","Mar","Apr","May", "Jun","Jul","Aug","Sep","Oct","Nov","Dec"), ordered = FALSE) ) %>% # arranging data in ascending order by date dplyr::arrange(date) %>% # creating new variables elapsed time, pandemic, and time elapsed after pandemic outbreak dplyr::mutate( elapsedTime = row_number(), pandemic = case_when( date >= '2020-03-01' ~ 1, TRUE ~ 0 ), elapsedTimeAfterPandemic = cumsum(pandemic), pandemic = as.factor(ifelse(pandemic == 1, "After the pandemic outbreak", "Before the pandemic outbreak")) ) %>% # final selection of variables dplyr::select( date, interestInPeopleAnalytics, elapsedTime, month, pandemic, elapsedTimeAfterPandemic ) ``` Here is a table with the resulting data we will use for our analysis. ```r # uploading library for making more user-friendly data table library(DT) DT::datatable( mydata, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` ## Modeling We can now fit the model to the data and test what it tells us about the impact of the pandemic on people's search interest in people analytics. We will use [the brms r package](https://cran.r-project.org/web/packages/brms/vignettes/brms_overview.pdf) for this, which allows us to make inferences about the model parameters within a Bayesian inferential framework. For this reason, we must also specify some additional parameters (e.g. `chains`, `iter` or `warmup`) of [the Markov Chain Monte Carlo (MCMC) algorithm](https://en.wikipedia.org/wiki/Markov_chain_Monte_Carlo) that generates posterior samples of our model's parameters. The Bayesian framework also allows us to specify priors for the estimated parameters and use them to incorporate our domain knowledge into the analysis. The specified priors are important for both parameter estimation and hypothesis testing because they define our initial information state before we consider our data. Here, we will use relatively broad, uninformative, and only slightly regularizing priors (that is, the inference results will be very close to the results of standard, frequentist parameter estimation/hypothesis testing). ```r # uploading library for Bayesian statistical inference library(brms) library(cmdstanr) # checking available priors for the models brms::get_prior( interestInPeopleAnalytics | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = mydata, family = gaussian()) ``` ```r # specifying wide, uninformative, and only mildly regularizing priors priors <- c(brms::set_prior("normal(0,50)", class = "b", coef = "elapsedTime"), brms::set_prior("normal(0,50)", class = "b", coef = "elapsedTimeAfterPandemic"), brms::set_prior("normal(0,50)", class = "b", coef = "pandemicBeforethepandemicoutbreak"), brms::set_prior("normal(0,50)", class = "b", coef = "monthApr"), brms::set_prior("normal(0,50)", class = "b", coef = "monthAug"), brms::set_prior("normal(0,50)", class = "b", coef = "monthDec"), brms::set_prior("normal(0,50)", class = "b", coef = "monthFeb"), brms::set_prior("normal(0,50)", class = "b", coef = "monthJul"), brms::set_prior("normal(0,50)", class = "b", coef = "monthJun"), brms::set_prior("normal(0,50)", class = "b", coef = "monthMar"), brms::set_prior("normal(0,50)", class = "b", coef = "monthMay"), brms::set_prior("normal(0,50)", class = "b", coef = "monthNov"), brms::set_prior("normal(0,50)", class = "b", coef = "monthOct"), brms::set_prior("normal(0,50)", class = "b", coef = "monthSep")) # defining the statistical model model <- brms::brm( interestInPeopleAnalytics | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = mydata, family = gaussian(), prior = priors, chains = 4, iter = 20000, warmup = 2000, seed = 1234, sample_prior = TRUE, control = list(adapt_delta = 0.9), backend = "cmdstanr", refresh = 0, silent = 2 ) ``` Before making any inferences, we should perform several validation checks to ensure that the mechanics of the MCMC algorithm worked well and that we can use the generated posterior samples to make inferences about the parameters of our model. There are many ways to do this, but here we will only use a visual check of the MCMC chains. We want the plots of these chains to look like a hairy caterpillar, indicating the convergence of the underlying Markov chain to stationarity and the convergence of the Monte Carlo estimates to population quantities, respectively. As can be seen in the graph below, we can observe the characteristics we are looking for in the MCMC chains described above. (For additional MCMC diagnostics procedures, see for example [Bayesian Notes](https://jrnold.github.io/bayesian_notes/mcmc-diagnostics.html) from Jeffrey B. Arnold.) ```r # uploading library for plotting Bayesian models library(bayesplot) # plotting the MCMC chains bayesplot::mcmc_trace( model, facet_args = list(nrow = 6) ) + ggplot2::labs( title = "Plots of the MCMC chains used for estimation of the model parameters" ) + ggplot2::scale_color_viridis_d(option = "plasma", end = 0.8, name = "") + ggthemes::theme_clean() + ggplot2::theme( legend.position = "bottom", legend.background = element_blank() ) ``` ![](./people-analytics-popularity-after-covid/unnamed-chunk-7-1.png) It is also important to check how well the model fits the data. To do this, we can use the posterior predictive check, which uses a specified number of selected posterior values of the model parameters to show how well the fitted model predicts the observed data. In the graph below we see that the model fits the observed data reasonably well. ```r # investigating the model fit # specifying the number of samples ndraws = 100 brms::pp_check( model, ndraws = ndraws ) + ggplot2::labs( title = stringr::str_glue("Posterior predictive check (using {ndraws} samples)") ) + ggplot2::scale_color_viridis_d(option = "plasma", end = 0.8, name = "") + ggthemes::theme_clean() + ggplot2::theme( legend.position = "bottom", legend.background = element_blank() ) ``` ![](./people-analytics-popularity-after-covid/unnamed-chunk-8-1.png) ## What do the model and data tell us? We can now use the parameters of our model to obtain information about our main question. Specifically, we are interested in the value of the coefficient of the pandemic and the time after pandemic terms in our model. They represent how much and in what direction the search interest in people analytics changed after the pandemic outbreak. ```r # generating a summary of the fitted model summary(model) ``` The following graph shows the posterior distribution of the pandemic parameter. We can see that it is on the right-hand side of the zero value, which supports the claim that there is an effect of the pandemic on people's interest in people analytics immediately after the pandemic outbreak; however, it is on the opposite side of the zero value than we would expect if the pandemic were to have a positive effect on people's interest in people analytics. Thus, this suggests that immediately after the pandemic outbreak, interest in people analysis declined slightly (somewhere between ~1 and ~20 points, as indicated by the 95% credible interval). ```r # uploading library for working with output from Bayesian models library(tidybayes) paramVizBeforethepandemicoutbreak <- model %>% tidybayes::gather_draws(b_pandemicBeforethepandemicoutbreak) %>% dplyr::rename(value = .value) dens <- density(paramVizBeforethepandemicoutbreak$value) paramVizBeforethepandemicoutbreak <- tibble::tibble(x = dens$x, y = dens$y) # visualizing the posterior distribution of the model's b_pandemicBeforethepandemicoutbreak parameter ggplot2::ggplot(paramVizBeforethepandemicoutbreak, aes(x,y)) + ggplot2::geom_area(data = filter(paramVizBeforethepandemicoutbreak, x > 0), fill = "#fca636" ) + ggplot2::geom_area(data = filter(paramVizBeforethepandemicoutbreak, x <= 0), fill = "grey") + ggplot2::labs( title = "Posterior distribution of the pandemicBeforethepandemicoutbreak parameter", y = "Density", x = "pandemicBeforethepandemicoutbreak" ) + ggthemes::theme_clean() ``` ![](./people-analytics-popularity-after-covid/unnamed-chunk-10-1.png) ```r # uploading library for working with output from Bayesian models library(posterior) # extracting posterior samples samplesBeforethepandemicoutbreak <- posterior::as_draws_df(model, seed = 1234) # probability of b_pandemicBeforethepandemicoutbreak coefficient being higher than 0 sum(samplesBeforethepandemicoutbreak$b_pandemicBeforethepandemicoutbreak > 0) / nrow(samplesBeforethepandemicoutbreak) ``` Now let's check the second key parameter of our model, the time after the pandemic term. In this case, the posterior distribution is again on the right-hand side of zero value, but now this result is consistent with the claim that there is a positive effect of the pandemic on people's interest in people analytics, specifically in terms of the change in slope after the pandemic. Compared to the pre-pandemic trend, the post-pandemic trend is steeper by ~0 to ~1 point per month (as indicated by the 95% confidence interval). We should bear in mind, however, that this effect may in fact only be an artifact caused by the improved data collection system from January 2022, as mentioned at the very beginning of this post. ```r paramVizElapsedTimeAfterPandemic <- model %>% tidybayes::gather_draws(b_elapsedTimeAfterPandemic) %>% dplyr::rename(value = .value) dens <- density(paramVizElapsedTimeAfterPandemic$value) paramVizElapsedTimeAfterPandemic <- tibble::tibble(x = dens$x, y = dens$y) # visualizing the posterior distribution of the model's b_elapsedTimeAfterPandemic parameter ggplot2::ggplot(paramVizElapsedTimeAfterPandemic, aes(x,y)) + ggplot2::geom_area(data = filter(paramVizElapsedTimeAfterPandemic, x > 0), fill = "#fca636" ) + ggplot2::geom_area(data = filter(paramVizElapsedTimeAfterPandemic, x <= 0), fill = "grey") + ggplot2::labs( title = "Posterior distribution of the elapsedTimeAfterPandemic parameter", y = "Density", x = "elapsedTimeAfterPandemic" ) + ggthemes::theme_clean() ``` ![](./people-analytics-popularity-after-covid/unnamed-chunk-12-1.png) ```r # extracting posterior samples samplesElapsedTimeAfterPandemic <- posterior::as_draws_df(model, seed = 1234) # probability of b_elapsedTimeAfterPandemic coefficient being higher than 0 sum(samplesElapsedTimeAfterPandemic$b_elapsedTimeAfterPandemic > 0) / nrow(samplesElapsedTimeAfterPandemic) ``` The overall resulting picture thus partially supports the impression of many of my people analytics fellows about the growing importance of people analytics in HR and business leaders' decision making. However, given that the Google search interest in people analytics is a fairly distant proxy for its actual use in HR and business practice, we should take these results with a grain of salt and try to find other data sources that would support our results. For example, [Frank Corrigan](https://www.linkedin.com/in/frankxc/), head of analytics at Ponder, came up with the idea of analyzing changes in postings for people analytics job positions over time. A good inspiration for anyone willing to spend some time getting at and analyzing such data. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-08-20-people-analytics-popularity-after-covid/) on my blog. --- # Visual statistical inference URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/visual-inference-statistics Date: 2022-06-11 Tags: statistics, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2022-06-11-visual-inference-statistics/ Do you have any experience with **[visual statistical inference](https://www.tandfonline.com/doi/abs/10.1080/01621459.2013.808157)**? Not only is it a valid alternative to standard statistical inference, but as a by-product it helps with building intuition about [the difference between signal and noise](https://www.tandfonline.com/doi/full/10.1080/26939169.2021.1920866). The basis of the method is a so-called **lineup protocol** that places a graph of the actual data between arrays of graphs of null data that are generated by a method consistent with the null hypothesis. The lineup is shown to one or more observers who are asked to identify the graph that differs. If an observer can pick out the actual data as different from the others, this gives weight to the statistical significance of the pattern in the graph. Because people usually have a hard time recognizing randomness and tend to see patterns even where there are none, there is also a so-called **Rorschach protocol** that only displays graphs created with null datasets and which is used by observers to calibrate their eyes for variation due to sampling. You can try it for yourself in the graphs below, which show the relationship between two variables from my current area of work (**[collaboration analytics](https://www.computerworld.com/article/3614119/collaboration-analytics-yes-you-can-track-employees-should-you.html)**), namely between **[time available for focused work](https://www.getclockwise.com/blog/what-is-focus-time)** and the use of **[timeboxing](https://en.wikipedia.org/wiki/Timeboxing)** (productivity enhancing technique of assigning a fixed unit of time to an activity within which a planned activity takes place). Use the first array of graphs (the Rorschach protocol) to calibrate your eye for randomness and then try to identify the actual data in the second array of graphs (the lineup protocol). What's your guess? Which graph matches the actual data (1-20) and what relationship do you see in the data? You can give your guess in the comments. ```r # uploading libraries library(tidyverse) library(nullabor) library(patchwork) library(scales) # uploading data mydata <- readRDS("./visualInferenceData.RDS") # lineup protocol set.seed(1234) d <- lineup(null_permute("propBlockedTime"), mydata) lplot <- ggplot(data=d, aes(x = focusRate, y = propBlockedTime)) + geom_point(alpha = 0.3, size = 1) + facet_wrap(~ .sample) + labs( title = "LINEUP PROTOCOL", x = "FOCUS RATE", y = "PROPORTION OF BLOCKED WORKING TIME" ) + scale_x_continuous(labels = scales::number_format(suffix = "%")) + scale_y_continuous(labels = scales::percent_format()) # Rorschach protocol dr <- rorschach(null_permute("propBlockedTime"), mydata, n = 20, p = 0) rplot <- ggplot(data=dr, aes(x = focusRate, y = propBlockedTime)) + geom_point(alpha = 0.3, size = 1) + facet_wrap(~ .sample) + labs( title = "RORSCHACH PROTOCOL", x = "FOCUS RATE", y = "PROPORTION OF BLOCKED WORKING TIME" ) + scale_x_continuous(labels = scales::number_format(suffix = "%")) + scale_y_continuous(labels = scales::percent_format()) # combining plots fplot <- rplot / lplot print(fplot) ``` ![](./visual-inference-statistics/unnamed-chunk-1-1.png) Here's a check on your guess. The actual data are shown in chart # `r attr(d, "pos")`. As you can see in the chart below, the relationship between time for focused work and the use of timeboxing is slightly negative, which makes pretty good sense, because people who have enough time for focused work usually don't have such a strong need to block out time for focused work in their calendar. ```r mydata %>% ggplot(aes(x = focusRate, y = propBlockedTime)) + geom_point(size = 2, alpha = 0.5) + geom_smooth(method = "lm", se = F) + scale_x_continuous(limits = c(NA,NA), labels = scales::number_format(suffix = "%")) + scale_y_continuous(labels = scales::percent_format()) + labs( x = "FOCUS RATE", y = "PROPORTION OF BLOCKED WORKING TIME", title = "RELATIONSHIP BETWEEN FOCUSED TIME AND TIMEBOXING USAGE", caption = "\nThe blue line in the graph represents the linear regression line." ) ``` ![](./visual-inference-statistics/unnamed-chunk-2-1.png) We can also test the relationship between time for focused work and the use of timeboxing more formally by fitting a [Bayesian beta regression model](https://www.andrewheiss.com/blog/2021/11/08/beta-regression-guide/) to the data. As you can see in the summary tables and charts below, the null value is safely outside the 95% credible interval of the mean of the focus rate parameter, and the marginal effect of focus rate clearly shows its negative relationship with the predicted proportion of working time blocked in the calendar. Note also that the relationship is non-linear, i.e. the marginal effect of the focus rate is different depending on its level. ```r # uploading libraries library(brms) library(bayesplot) library(tidybayes) library(ggdist) # fitting Bayesian beta regression model model <- brms::brm( bf( propBlockedTime ~ focusRate, phi ~ focusRate ), data=mydata, family= Beta(), seed = 1234, iter = 20000, warmup = 2000, chains = 4, cores = 6, control = list( adapt_delta = 0.9, max_treedepth = 20 ) ) ``` ```r # summary of the fitted model summary(model) # visualizing posterior distribution of the estimated parameters posterior_beta <- model %>% gather_draws(`b_.*`, regex = TRUE) %>% mutate(component = ifelse(str_detect(.variable, "phi_"), "Precision", "Mean"), intercept = str_detect(.variable, "Intercept")) %>% filter(intercept == FALSE) ggplot(posterior_beta, aes(x = .value, y = fct_rev(.variable), fill = component)) + geom_vline(xintercept = 0, linetype = "dashed") + stat_halfeye(aes(slab_alpha = intercept), .width = c(0.8, 0.95), point_interval = "median_hdi") + scale_fill_viridis_d(option = "viridis", end = 0.6) + scale_slab_alpha_discrete(range = c(1, 0.4)) + guides(fill = "none", slab_alpha = "none") + labs( x = "COEFFICIENT", y = "", title = "POSTERIOR DISTRIBUTION OF THE ESTIMATED PARAMETERS", caption = "\n80% and 95% credible intervals shown in black") + facet_wrap(vars(component), ncol = 1, scales = "free_y") ``` ```r # visualizing marginal effect of the focus rate model_pred <- model %>% epred_draws(newdata = expand_grid(focusRate = seq(30, 100, by = 1))) ggplot(model_pred , aes(x = focusRate, y = .epred)) + stat_lineribbon() + scale_fill_brewer(palette = "Purples") + labs(x = "FOCUS RATE", y = "PREDICTED PROPORTION OF BLOCKED WORKING TIME", fill = "Credible interval", title = "MARGINAL EFFECT OF THE FOCUS RATE" ) + scale_x_continuous(limits = c(NA,NA), labels = scales::number_format(suffix = "%")) + scale_y_continuous(labels = scales::percent_format(accuracy = 1)) + theme(legend.position = "bottom") ``` ![](./visual-inference-statistics/unnamed-chunk-5-1.png) If you would like to apply the visual statistical inference approach to your own data, you can easily do so using the *[nullabor](https://github.com/dicook/nullabor)* R package. ## Figures ![](./visual-inference-statistics/unnamed-chunk-4-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-06-11-visual-inference-statistics/) on my blog. --- # Standard vs. trend predictors URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/standard-and-trend-predictors Date: 2022-02-06 Tags: predictive-analytics, machine-learning, employee-experience, employee-engagement, employee-satisfaction, employee-turnover, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2022-02-06-standard-and-trend-predictors/ As predictive analytics practitioners know, **trend variables** can be more useful in many situations for predicting certain phenomena than **standard variables** that simply refer to the state of the world at a particular time point or period. For example, when trying to predict [employee attrition](https://www.aihr.com/blog/employee-attrition/), a downward trend in the use of a piece of company equipment, such as a printer/copier, over the 6 months prior to the resignation may be more predictive than the absolute number of pages printed/copied over the same period. This is also true for our domain we focus on at [Time is Ltd.](https://www.timeisltd.com/) where, among other things, we try to use collaboration data to infer some aspects of [employee experience](https://www.cultureamp.com/blog/what-is-employee-experience). To illustrate, the attached chart shows the distribution of the typical daily amount of time people spend by collaboration for two groups of employees - one with above-average scores and the other with below-average scores on the employee satisfaction survey. As you can see, there is little difference between the two groups in terms of the average daily amount of time people spend by collaboration over the last six months (see the density plots), but there is a fairly clear difference in the trend of this metric over the same period, suggesting that less satisfied employees may be suffering from increasing collaboration overload (see the line charts with trend lines for individual employees and the estimated overall linear trend).
![](./standard-and-trend-predictors/collaborationActivityChart.png)
Do you have a similar experience with or just a strong hunch about other metrics in your area of expertise? Let me know in the comments. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-02-06-standard-and-trend-predictors/) on my blog. --- # Fighting meeting overload URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/meeting-planning Date: 2022-01-30 Tags: meetings Original post: https://blog-about-people-analytics.netlify.app/posts/2022-01-30-meeting-planning/ One of the recommended ways to save time in meetings is to plan them better in terms of the time we allocate for them. As in other activities, even here the well-known [Parkinson's rule](https://en.wikipedia.org/wiki/Parkinson%27s_law) applies that "work expands so as to fill the time available for its completion." When this is combined with the automatic use of default meeting lengths, it leads to spending more time in meetings than is necessary. For this reason, [Steven Rogelberg](https://www.linkedin.com/in/rogelberg/) suggests in his book [The Surprising Science of Meetings](https://www.amazon.com/Surprising-Science-Meetings-Lead-Performance/dp/0190689218) that all meeting times should be reduced by 5-10 percent by default. To assess whether you have room for improvement in this regard, it is useful to compare actual and planned meeting lengths. For illustration, the attached chart shows the distribution of the typical differences between actual and planned meeting lengths for each of our teams organizing online meetings over the course of a year. It clearly shows that a large proportion of teams are organizing meetings longer than necessary, by an average of 4 minutes. So in the case of our company [Time is Ltd.](https://www.timeisltd.com/), there definitely seems to be room for implementing Steven Rogelberg's suggestion. ```r # uploading package library(tidyverse) # uploading data data <- readRDS("./tardiness.rds") # preparing data for density plot mydata <- with(density(data %>% pull(tardiness)), data.frame(x, y)) %>% mutate(col = ifelse(x >= 0, "A", "B")) # visualizing data mydata %>% ggplot() + geom_rug(data = filter(data, tardiness >=0), aes(x = tardiness), color = "#4d009d", size = 0.55, alpha = 1, position = "identity") + geom_rug(data = filter(data, tardiness <0), aes(x = tardiness), color = "#4d009d", size = 0.55, alpha = 0.5, position = "identity") + geom_area(data = filter(mydata, col == 'A'), aes(x = x, y = y), fill = '#4d009d', alpha = 1) + geom_area(data = filter(mydata, col == 'B'), aes(x = x, y = y), fill = '#4d009d', alpha = 0.5) + geom_label(aes( x=-15.25, y=0.06, label=" Shorter than planned "), fill = "#a67fce", color="white", size=4.5 , fontface="bold", family = "Nunito Sans", label.padding = unit(0.5, "lines")) + geom_label(aes( x=10.5, y=0.04, label=" Longer than planned "), fill = "#4d009d", color="white", size=4.5 , fontface="bold", family = "Nunito Sans", label.padding = unit(0.5, "lines")) + labs( x = "TYPICAL DIFFERENCE BETWEEN ACTUAL AND PLANNED LENGTHS OF MEETINGS", y = "DENSITY", title = "Do our online meetings end on time?", subtitle = str_glue("On average, our teams organize online meetings {round(abs(mean(data$tardiness)),1)} minutes longer than necessary."), caption = "\nPositive values indicate that online meetings tend to overrun; negative values indicate that online meetings are planned longer than they need to be." ) + scale_x_continuous(labels = scales::label_number(suffix = " min"), breaks = seq(-40, 20, 10)) + theme(plot.title = element_text(color = '#2C2F46', face = "bold", family = "URW Geometric", size = 20, margin=margin(0,0,12,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", family = "URW Geometric", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 15, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 1), axis.title.y.right = element_text(margin = margin(t = 0, r = 0, b = 0, l = 15), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 12, lineheight = 16), axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), legend.position=c(.2,.98), legend.key = element_rect(fill = "white"), legend.key.size = unit(0, "cm"), legend.margin = margin(-0.8,0,0,0, unit="cm"), legend.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 10, lineheight = 16), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) ``` ![](./meeting-planning/unnamed-chunk-1-1.png) It is also worth noting the reverse situation where meetings take longer than planned, as a late end to one meeting becomes a late start to the next meeting. How do you feel about finishing meetings too early or too late? Are both similarly unpleasant for you? And isn't actually having a shorter meeting than planned something positive? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-01-30-meeting-planning/) on my blog. --- # How do we perceive probability words? URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/probability-words Date: 2022-01-18 Tags: statistics, cognitive-science Original post: https://blog-about-people-analytics.netlify.app/posts/2022-01-18-probability-words/ Nowadays - probably also due to the Covid pandemic and the associated predictions - we are more and more frequently encountering various probabilistic statements, but these are often expressed not in terms of precise numerical probabilities, but in terms of relatively vague probability words such as "probably", "maybe", "unlikely", etc. Since people may imagine different probabilities under these words, it would be useful to have something like a glossary to help us decipher these words and indicate what people usually mean when they use them. Fortunately, there are some studies that examine what numerical probabilities people typically associate with probability words. For this purpose, I used a collection of 123 responses to the [Wade Fagen-Ulmschneider's internet survey](https://github.com/wadefagen/datasets/tree/master/Perception-of-Probability-Words) and created two similar graphs based on them. The first shows the distribution of the numerical probabilities that people associate with each word, and these are sorted in the graph by the median value of the corresponding probability in descending order. The second graph then differs only in that the words are sorted by the size of the interquartile range in descending order. ```r # uploading libraries library(tidyverse) library(ggridges) library(ggpubr) # uploading data data <- readr::read_csv("./survey-results.csv") # getting ordered list of words based on the median value of corresponding probabilities wordsMedian <- data %>% select("\"Almost Certain\"":"\"Chances are Slight\"") %>% pivot_longer("\"Almost Certain\"":"\"Chances are Slight\"", names_to = "word", values_to = "probability") %>% group_by(word) %>% summarise(median = median(probability)) %>% mutate( word = factor(word), word = forcats::fct_reorder(word, median) ) levelsMedian <- levels(wordsMedian$word) # getting ordered list of words based on the IQR of corresponding probabilities wordsVariability <- data %>% select("\"Almost Certain\"":"\"Chances are Slight\"") %>% pivot_longer("\"Almost Certain\"":"\"Chances are Slight\"", names_to = "word", values_to = "probability") %>% group_by(word) %>% summarise(sd = IQR(probability)) %>% mutate( word = factor(word), word = forcats::fct_reorder(word, sd) ) levelsVariability <- levels(wordsVariability$word) # graph 1 g1 <- data %>% select("\"Almost Certain\"":"\"Chances are Slight\"") %>% pivot_longer("\"Almost Certain\"":"\"Chances are Slight\"", names_to = "word", values_to = "probability") %>% mutate(word = factor(word, levels = levelsMedian, ordered = TRUE)) %>% ggplot(aes(x = probability, y = word)) + geom_density_ridges( fill = "#4d009d", alpha = 0.85, scale = 1, jittered_points = TRUE, position = position_points_jitter(width = 1, height = 0), point_shape = '|', point_size = 1, point_alpha = 1, quantile_lines =TRUE, vline_linetype = "dashed", vline_color = "white", vline_size = 0.55, quantile_fun=function(x,...)median(x) ) + scale_x_continuous(limits = c(0, 100), breaks = seq(0,100,10), labels = scales::number_format(suffix = "%",accuracy = 1)) + labs( fill = "Trend size", x = "PROBABILITIES ASSIGNED TO WORDS", y = "", title = "How do people perceive probability words?", caption = "\nThe words are sorted by the median value of the corresponding probability in descending order.\nThe white dashed lines represent the median values.\nSource: A collection of 123 responses to an internet survey by Wade Fagen-Ulmschneider." ) + scale_fill_gradient2( low = "red", mid = "white", high = "blue", midpoint = 0, space = "Lab" ) + theme(plot.title = element_text(color = '#2C2F46', face = "bold", family = "URW Geometric", size = 20, margin=margin(0,0,16,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", family = "URW Geometric", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 1), legend.title = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 12), legend.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 10), axis.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 12, lineheight = 16), axis.text.x = element_text(), legend.position = "right", axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + guides( fill = guide_colourbar(barwidth = 0.75, barheight = 10) ) # graph 2 g2 <- data %>% select("\"Almost Certain\"":"\"Chances are Slight\"") %>% pivot_longer("\"Almost Certain\"":"\"Chances are Slight\"", names_to = "word", values_to = "probability") %>% mutate(word = factor(word, levels = levelsVariability, ordered = TRUE)) %>% ggplot(aes(x = probability, y = word)) + geom_density_ridges( fill = "#4d009d", alpha = 0.85, scale = 1, jittered_points = TRUE, position = position_points_jitter(width = 1, height = 0), point_shape = '|', point_size = 1, point_alpha = 1, quantile_lines =TRUE, vline_linetype = "dashed", vline_color = "white", vline_size = 0.55, quantile_fun=function(x,...)median(x) ) + scale_x_continuous(limits = c(0, 100), breaks = seq(0,100,10), labels = scales::number_format(suffix = "%",accuracy = 1)) + labs( fill = "Trend size", x = "PROBABILITIES ASSIGNED TO WORDS", y = "", title = "What probability words are the most noisy?", caption = "\nThe words are sorted by the size of the interquartile range in descending order.\n\n" ) + scale_fill_gradient2( low = "red", mid = "white", high = "blue", midpoint = 0, space = "Lab" ) + theme(plot.title = element_text(color = '#2C2F46', face = "bold", family = "URW Geometric", size = 20, margin=margin(0,0,16,0)), plot.subtitle = element_text(color = '#2C2F46', face = "plain", family = "URW Geometric", size = 16, margin=margin(0,0,20,0)), plot.caption = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 11, hjust = 0), axis.title.x.bottom = element_text(margin = margin(t = 15, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 0), axis.title.y.left = element_text(margin = margin(t = 0, r = 0, b = 0, l = 0), color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 13, lineheight = 16, hjust = 1), legend.title = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 12), legend.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 10), axis.text = element_text(color = '#2C2F46', face = "plain", family = "Nunito Sans", size = 12, lineheight = 16), axis.text.x = element_text(), legend.position = "right", axis.line.x = element_line(colour = "#E0E1E6"), axis.line.y = element_line(colour = "#E0E1E6"), panel.background = element_blank(), panel.grid.major.y = element_blank(), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), axis.ticks.x = element_line(color = "#E0E1E6"), axis.ticks.y = element_line(color = "#E0E1E6"), plot.margin=unit(c(5,5,5,5),"mm"), plot.title.position = "plot", plot.caption.position = "plot" ) + guides( fill = guide_colourbar(barwidth = 0.75, barheight = 10) ) # combining graphs ggarrange(g1, g2, ncol = 2, nrow = 1) ``` ![](./probability-words/unnamed-chunk-1-1.png) The first graph can thus help us to use the right word, which in the mind of the other person is most likely to evoke the same probability we want to express. The second graph can then help us to identify the most noisy probability words, for which we will know to ask for a more precise definition because we will be aware that people may imagine very different probabilities under these words. How about your perception of probability words? Is there anything in the graphs that surprised you? Would you expect differences between cultures? And what about other demographics? Btw, the original dataset also includes some demographic variables such as age, gender, and education level, so I'll probably come back to this question in a future post. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-01-18-probability-words/) on my blog. --- # Hofstede's theory of cultural dimensions URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/hofstede-theory-of-cultural-dimensions Date: 2022-01-03 Tags: cross-cultural-differences, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2022-01-03-hofstede-theory-of-cultural-dimensions/ One of the advantages of switch to remote working is that companies can expand the pool of talent from which they choose their employees, without being too constrained by country or even continental boundaries. However, the resulting cultural diversity can bring not only positive effects (e.g. a broader set of perspectives, a more diverse skill base, local market knowledge and insight, better creativity and innovation, etc.) but also some challenges (e.g. risk of prejudice or negative cultural stereotypes, misinterpretation of communication, conflicting work styles, different understanding of professional etiquette, etc.). Better knowledge and awareness of the cultural specificities of the societies from which people come is one [way of dealing with these challenges](https://en.wikipedia.org/wiki/Approaches_to_prejudice_reduction). In this respect, **[Hofstede's theory of cultural dimensions](https://en.wikipedia.org/wiki/Hofstede%27s_cultural_dimensions_theory)** may be useful to us. Just as [Big-5 theory](https://en.wikipedia.org/wiki/Big_Five_personality_traits) facilitates our understanding of other people's personalities, Hofstede's theory facilitates our understanding of their cultural background by describing their social values and releated behaviors through the following six cultural dimensions: [![Description of cultural dimensions](./hofstede-theory-of-cultural-dimensions/CulturalDimensionsDescription.png)](https://corporatefinanceinstitute.com/resources/knowledge/other/hofstedes-cultural-dimensions-theory/) *Image source:* https://corporatefinanceinstitute.com/resources/knowledge/other/hofstedes-cultural-dimensions-theory/ You can easily check how countries are doing on these six dimensions on [the Hofstede Insights website](https://www.hofstede-insights.com/product/compare-countries/). To make it easier to compare cultural differences/similarities between countries, I built a simple app that projects the cultural profiles of countries into 2D space using dimensionality reduction technique called [UMAP (Uniform manifold approximation and projection)](https://en.wikipedia.org/wiki/Nonlinear_dimensionality_reduction). By selecting a specific cultural dimension, you can see how it is distributed across countries and continents. In addition, you can select some specific countries in the comparator and compare them across all six cultural dimensions. Check it out here ➡️ https://peopleanalyticsblog.shinyapps.io/Hofstede_Cultural_Dimensions/ [![Dashbooard capture 1](./hofstede-theory-of-cultural-dimensions/Capture1.png)](https://peopleanalyticsblog.shinyapps.io/Hofstede_Cultural_Dimensions/) [![Dashbooard capture 2](./hofstede-theory-of-cultural-dimensions/Capture2.png)](https://peopleanalyticsblog.shinyapps.io/Hofstede_Cultural_Dimensions/) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2022-01-03-hofstede-theory-of-cultural-dimensions/) on my blog. --- # Makers' schedule and managers' schedule in collaboration data URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/makers-and-managers-schedule Date: 2021-12-19 Tags: time-management, collaboration Original post: https://blog-about-people-analytics.netlify.app/posts/2021-12-19-makers-and-managers-schedule/ I am sure that many of you have heard of the two types of schedules as described by Paul Graham in his famous article [Maker's Schedule, Manager's Schedule](http://www.paulgraham.com/makersschedule.html): *The manager's schedule [is] embodied in the traditional appointment book, with each day cut into one hour intervals. [By] default you change what you're doing every hour. But [makers] generally prefer to use time in units of half a day at least. You can't write or program well in units of an hour. That's barely enough time to get started. When you're operating on the maker's schedule, meetings are a disaster. A single meeting can blow a whole afternoon, by breaking it into two pieces each too small to do anything hard in. Plus you have to remember to go to the meeting.* I recently realized that I have only seen illustrative pictures on this topic so far, but not any real data. This inspired me to look at our own collaboration data at [Time Is Ltd.](https://www.timeisltd.com/) and see if these two schedule categories can be found there. When I contrasted the data on the average number of meetings per day and the average time between meetings, there were indeed categories of people who either have relatively more meetings with relatively shorter breaks (*managers*), or have relatively fewer meetings with relatively longer breaks (*makers*). ![](./makers-and-managers-schedule/schedulers2.png) But beyond that, there was a third type, which I called *batchers* - they have relatively fewer meetings with relatively shorter breaks, which is a good strategy when you have to be both manager and creator, which may be the case for more and more people as we move to remote working. In the charts below you can see how typical monthly calendars of these three types of schedulers look like. ![](./makers-and-managers-schedule/managerExample2.png) ![](./makers-and-managers-schedule/makerExample2.png) ![](./makers-and-managers-schedule/batcherExample2.png) What we cannot see in our own data, but could theoretically be there, is a fourth category I call *overtimers*, who have relatively more meetings but manage to keep relatively longer breaks in between. However, this can only be achieved by making the meetings more spread out over time, i.e. at the cost of working after hours. How about you? Where would you fit in? And is there anyone among you who would fit into the fourth, missing category? --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2021-12-19-makers-and-managers-schedule/) on my blog. --- # R Shiny app for LinkedIn connections analysis URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/linkedin-connections-analysis Date: 2021-12-16 Tags: linkedin, collaboration, network-analysis, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2021-12-16-linkedin-connections-analysis/ If you like to use the end of the year as an opportunity for deeper self-reflection, you might enjoy [this simple app](https://peopleanalyticsblog.shinyapps.io/linkedIn_connections_analysis/) I have put together over the past weekend. Once you upload your LinkedIn connections data to the app (you can easily download the data by following the instructions in the app or in [this video](https://www.youtube.com/watch?v=FLWSmiBxQQY)), it automatically generates basic descriptive statistics about your LinkedIn connections: * Cumulative number of connections over time * Number of established connections by years, months, and days of the week * Top N companies by the number of established connections * Top N positions by their frequency among your connections (based on whole position titles, bigrams and single words) * Proportion of connections by their gender (based on your connections' first name)

Unfortunately, since there is no information about your connections' connections in the data, the app cannot perform more advanced SNA-type of analyses on it. Still, I think you may find some of the statistics useful, or at least interesting and entertaining. You can take it as a kind of Christmas gift for my fellow LinkedIn users. Enjoy exploring your connections! And if you'd like to explore and better manage also your company's internal collaboration networks, then check out what we do at [Time is Ltd.](https://www.timeisltd.com/) P.S. The data you upload is not permanently stored anywhere. The app runs on the [shinyapps.io server](https://www.shinyapps.io/). If you don’t want to upload your own data, but would still like to see what the analysis output looks like, you can download and then upload ready-made sample data from the app. P.P.S. Big thanks to [Sebastian Vorac](https://www.linkedin.com/in/sebastian-vor%C3%A1%C4%8D-93361526/) for bringing me to this idea and for UX review. Any remaining errors are, of course, mine alone. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2021-12-16-linkedin-connections-analysis/) on my blog. --- # Overview of predictors of voluntary employee turnover URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/overview-of-predictors-of-voluntary-employee-turnover Date: 2021-12-12 Tags: employee-turnover, meta-analysis, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2021-12-12-overview-of-predictors-of-voluntary-employee-turnover/ ```r knitr::opts_chunk$set(echo = FALSE) ``` Although the 'Great Resignation' in some parts of the world may be due in no small part to factors specific to the COVID-19 pandemic, it is still useful in this context to draw on the extensive research on employee turnover carried out in the run-up to the pandemic. A useful overview of such findings is provided, for example, by a 2017 meta-analysis by Rubenstein et al. that summarizes the significance of 57 predictors of voluntary turnover from 9 different domains based on 316 studies from 1975 to 2016 involving more than 300,000 people. To make it easier to assimilate these findings, I extracted them from the original article and visualized them in a simple shiny app that helps one to quickly explore and grasp the estimated magnitude, direction, and reliability of the effect of each factor, along with information on the degree of their actionability. The last feature is based purely on my own judgement, so please take it with a grain of salt, or adjust it in your mind using your own judgement. Try it out and let me know if you find it useful. ➡️ https://peopleanalyticsblog.shinyapps.io/voluntary_turnover_predictors/ [![Overview of predictors of voluntary employee turnover](./overview-of-predictors-of-voluntary-employee-turnover/turnover_predictors_app.png)](https://peopleanalyticsblog.shinyapps.io/voluntary_turnover_predictors/) And here is the original research paper on which the shiny app is based.

This browser does not support PDFs. Please download the PDF to view it: Download PDF.

--- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2021-12-12-overview-of-predictors-of-voluntary-employee-turnover/) on my blog. --- # Firemní audit rozdílu mezi platy mužů a žen URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/paygap Date: 2021-01-29 Tags: gender-pay-gap, regression-analysis, r Original post: https://blog-about-people-analytics.netlify.app/posts/2021-01-29-paygap/ ```r # uploading libraries library(tidyverse) library(ggthemes) library(plotly) library(RColorBrewer) library(DT) library(GGally) library(skimr) library(ggstatsplot) library(ggpubr) if (!require(remotes)) { install.packages("remotes") } remotes::install_github('jorvlan/raincloudplots') library(raincloudplots) library(PupillometryR) library(brms) library(tidybayes) Sys.setlocale("LC_CTYPE", "czech") ``` ## Co to je *gender pay gap* a jak ho měřit? *Gender pay gap* (GPG), v překladu genderová příjmová nerovnost nebo příjmová propast mezi muži a ženami, označuje **typický rozdíl mezi platovým ohodnocením pracujících žen a mužů**. Obvykle je GPG vyjadřována procenty, poměrem typické hrubé hodinové (či roční) mzdy ženy k typické mzdě muže nebo poměrem rozdílu mezi typickou mzdou mužů a žen vůči typické mzdě mužů. Bez ohledu na způsob měření GPG, je dobře doloženým faktem, že ženy jsou obecně hůře placeny než muži, jakkoli [se tento rozdíl postupem času zmenšuje](http://www.econ.jku.at/papers/2003/wp0311.pdf). Rozdíly v platech se přitom mohou v jednotlivých zemích poměrně dost lišit. Názorně to ilustruje níže uvedený graf, který ukazuje vývoj (neadjustované) GPG (definované jako poměr rozdílu mediánové mzdy zaměstnaných mužů a žen a mediánové mzdy zaměstnaných mužů) v průběhu několika minulých let v zemích [OECD](https://data.oecd.org/earnwage/gender-wage-gap.htm). ```r # uploading data gpgoecd <- readr::read_csv("./DP_LIVE_29012021212234147.csv") # creating color palette # list of R color Brewer's palettes: https://www.r-graph-gallery.com/38-rcolorbrewers-palettes.html nbCols <- length(unique(gpgoecd$LOCATION)) myColors <- colorRampPalette(brewer.pal(8, "Set1"))(nbCols) # creating a graph g <- gpgoecd %>% ggplot2::ggplot(aes(x = forcats::fct_reorder(LOCATION, Value), y = Value, fill = LOCATION, text = paste('Země: ', LOCATION, '

GPG: ', round(Value))))+ ggplot2::geom_col() + ggplot2::facet_wrap(~ TIME, nrow = 4) + ggplot2::labs(x = "", y = "GPG", title = "Genderová příjmová nerovnost v zemích OECD v letech 2016-2019") + ggthemes::theme_few() + ggplot2::scale_fill_manual(values = myColors) + ggplot2::theme(legend.position = "", legend.title = element_blank(), axis.text.x = element_text(angle = 90, vjust = 0.5, hjust=1)) # making the graph interactive plotly::ggplotly( g, width = 800, height = 700, tooltip = "text" ) ``` Důvodů pro nevyváženost příjmů žen a mužů pravděpodobně existuje větší množství. Mezi nejčastěji uváděné důvody patří: * **Diskriminace na pracovišti**. Stejné práce je odměňována rozdílně čistě na základě pohlaví pracovníka. * **Genderové stereotypy**. Předsudky ohledně výkonnosti, schopností a vlastností žen mají za následek oslabení jejich pozic a vytváření tzv. „skleněného stropu“, tj. neviditelné bariéry, na kterou ženy naráží při snaze o kariérní postup na lépe placené pozice. * **Segregace trhu**. Odvětví, v nichž je tradičně zaměstnáváno více žen než mužů jako je zdravotnictví, školství nebo veřejná správa, jsou společností vnímána jako méně prestižní, a tedy i hůře odměňována. * **Rodinný život**. Ženy většinou nesou větší část zátěže spojené s rodinným životem (např. při odchodu na mateřskou dovolenou, při péči o nemocné děti či jiné členy domácnosti), což jim významně stěžuje jejich snahu o kariérní růst. V situaci, kdy při reportování GPG nerozlišujeme mezi různými důvody pro platovou nerovnost, hovoříme o tzv. **neadjustované GPG**. Pro potřeby firemního auditu platové nerovnosti je však důležité zjistit rovněž tzv. **adjustovanou GPG**, která se snaží vyjádřit míru platové nerovnosti, která je způsobena čistě pohlavím zaměstnance. Zatímco adjustovaná GPG umožňuje firmě identifikovat možnou diskriminaci na pracovišti, neadjustovaná GPG (při neprokázané adjustované GPG) může poukazovat na existenci problémů jako jsou genderové stereotypy či nedostatečná podpora žen při snaze skloubit svůj osobní a profesní život. Pro firmy je tak užitečné sledovat oba ukazatele. ## Proč se zabývat platovou nerovností ve Vaší firmě? I kdybychom odhlédli od etických či právních aspektů platové nerovnosti mezi muži a ženami, je ve velice pragmatickém zájmu každé firmy, aby se tento druh nespravedlnosti v jejím systému odměňování nevyskytoval. V době sociálních sítí a platforem na hodnocení firem jejich současnými i bývalými zaměstnanci (za všechny zmiňme např. [Glassdoor](https://www.glassdoor.com/Reviews/index.htm) nebo český [Atmoskop](https://www.atmoskop.cz/)) se totiž informace o nerovném přístupu může velice snadno rozšířit mezi potenciální i stávající zaměstnance, kteří ji mohou zohlednit při svém rozhodování, zda se v dané firmě ucházet o práci, resp. zda v ní i nadále zůstat. Tuto skutečnost dokládají např. výsledky [průzkumu provedeného společností Glassdoor](https://about-content.glassdoor.com//app/uploads/sites/2/2019/03/Gender-Pay-Gap-Fact-Sheet-2019.pdf), podle kterého cca 67 % (U.S.) zaměstnanců by se neucházelo o práci tam, kde by si myslelo, že muži a ženy mají nerovné platové podmínky. ## Audit platové nerovnosti mezi muži a ženami Stejně jako při řešení jakéhokoli jiného problému, i v tomto případě platí, že **v první řadě je především potřeba ověřit, že nějaký problém k řešení vůbec existuje**. K tomu poslouží **firemní audit platové nerovnosti mezi muži a ženami**. Ten prostřednictvím analýzy platových, demografických a organizačních dat ověří, zda máme nějaké doklady pro to, že v dané společnosti existují platové rozdíly mezi zaměstnanci spojené s jejich pohlavím. Teprve na základě výsledků takové analýzy je možné se začít poohlížet po možných opatřeních v oblastech náboru, odměňování a/nebo povyšování, která by mohla pomoct nespravedlivé platové nerovnosti odstranit nebo alespoň zmírnit. Níže uvedený příklad takového auditu vychází z článku [How to Analyze Your Gender Pay Gap: An Employer's Guide](https://www.glassdoor.com/research/app/uploads/sites/2/2019/03/GD_Report_AnalyzingGenderPayGap_v2-2.pdf) od [Andrew Chamberlaina, Ph.D.](https://www.linkedin.com/in/andrewdavidchamberlain/), hlavního ekonoma a vedoucího výzkumu ve společnosti Glassdoor. ## Plán analýzy Analýzu platové nerovnosti mezi muži a ženami provedeme v následujících několika krocích: * Načteme si data, která obsahují informace o platech vzorku zaměstnanců, jejich pohlaví, demografických a organizačních charakteristikách, na kterých budeme testovat naše hypotézy. Za tímto účelem použijeme [ilustrační data poskytnutá společností Glassdoor](https://glassdoor.app.box.com/v/gender-pay-data). * V případě potřeby si upravíme data tak, aby lépe vyhovovala potřebám naší analýzy. * Provedeme explorační analýzu, která nám poskytne základní představu o našich datech. * Spočítáme si neadjustovanou GPG. * S pomocí hierarchické regresní analýzy vytvoříme statistický model GPG, který nám umožní lépe rozlišit "vliv" různých faktorů, včetně jejich interakcí, na pozorované rozdíly v platech mužů a žen. * Ověříme, zda samotné pohlaví zaměstance - při zohlednění "vlivu" ostatních faktorů, ke kterým máme k dispozici nějaká data - hraje nějakou významnější roli ve výši platu, který zaměstnanec dostává. * Ověříme, zda pohlaví zaměstnance neinteraguje s některými dalšími faktory při predikci výše jejich mzdy. ## Dostupná data ```r data <- readr::read_csv("./GenderPay_Data.csv") ``` K dispozici máme následující data ke vzorku `r nrow(data)` zaměstnanců: * Typ pozice, na které zaměstnanec pracuje (*jobTitle*) * Pohlaví zaměstnance (*gender*) * Věk zaměstnance (*age*) * Hodnocení pracovního výkonu zaměstnance (*perfEval*) * Úroveň vzdělání zaměstnance (*edu*) * Oddělení, ve kterém zaměstnanec pracuje (*dpt*) * Míra seniority zaměstnance (*seniority*) * Základní mzda zaměstnance (*basePay*) * Bonusová složka platu zaměstnance (*bonus*) ```r DT::datatable( data, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames = FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` ## Příprava dat k analýze Ze zběžné kontroly povahy našich dat je patrné, že ne každá z proměnných je v našem datasetu reprezentována pomocí adekvátního datového typu. Před samotnou analýzou si tedy budeme muset naše data ještě trochu upravit. ```r dplyr::glimpse(data) ``` Konkrétně budeme chtít upravit všechny textové proměnné (pracovní pozice, pohlaví, úroveň vzdělání a pracovní oddělení) a dvě numerické proměnné (hodnocení pracovního výkonu a míru seniority) na faktorové proměnné. Ke třem z těchto nově vytvořených faktorových proměnných (úroveň vzdělání, hodnocení pracovního výkonu a míra senirotity) je potom potřeba přidat informaci o správném pořadí jejich jednotlivých kategorií, protože reprezentují ordinální proměnné, u kterých lze smysluplně hovořit o relativním pořadí kategorií ve smyslu vyšší/nižší, resp. větší/menší. Takto upravená data již odpovídají typu informací, které reprezentují, a můžeme je tedy začít používat pro analýzu našeho problému. ```r mydata <- data %>% dplyr::mutate_if(is.character, as.factor) %>% dplyr::mutate(edu = factor(edu, ordered = TRUE, levels = c("High School", "College", "Masters", "PhD")), perfEval = factor(as.character(perfEval), ordered = TRUE, levels = c("1","2","3","4","5")), seniority = factor(as.character(seniority), ordered = TRUE, levels = c("1","2","3","4","5"))) ``` ## Explorační analýza V níže uvedených tabulkách jsou uvedeny základní popisné statistiky k jednotlivým proměnným. Můžeme z nich vyčíst např. to, že našich 1000 zaměstnanců je relativně rovnoměně rozdělených do jednotlivých kategorií z hlediska pracovní pozice, pohlaví, hodnocení pracovního výkonu, úrovně vzdělání, oddělení, ve kterém pracují, i míry jejich seniority. Dále se z nich můžeme dozvědět, že prostředních 50 % zaměstnanců je ve věku mezi 29 a 54 lety, jejich roční základní mzda se pohybuje od 76 850 do 111 558 USD a jejich bonusy za rok činí 4 849 až 8 026 USD. ```r skimr::skim(mydata) ```
Z hlediska námi analyzovaného problému jsou pro nás ale důležitější vztahy mezi jednotlivými proměnnými, zejména mezi pohlavím a ostatními proměnnými a jejich různými kombinacemi. Rychlý přehled o některých těchto vztazích nám může poskytnout níže uvedený graf, který zobrazuje souvislosti mezi jednotlivými dvojicemi proměnných a s pomocí barevného kódování navíc nese informaci o tom, jak se tyto souvislosti liší mezi pohlavími. V grafu můžeme např. vidět, že se v případě některých pracovních pozic významně liší relativní zastoupení mužů a žen. V menší míře se zdá tento rozdíl platit i v případě úrovně vzdělání. Určitý rozdíl mezi muži a ženami se zdá existovat rovněž ve výši jejich základní mzdy (narozdíl od bonusové složky, která se zdá být u mužů a žen obdobně vysoká). ```r GGally::ggpairs(mydata, aes(color = gender, alpha = 0.4)) + ggplot2::theme( strip.text.x = element_text( size = 22), strip.text.y = element_text( size = 22) ) + ggplot2::scale_fill_brewer(palette="Dark2") + ggplot2:: scale_color_brewer(palette="Dark2") ``` ![](./paygap/unnamed-chunk-8-1.png) Vizuální dojem o rozdílné výši základní mzdy u mužů a žen potvrzuje i detailnější analýza tohoto rozdílu. Ta ukazuje, že v našem vzorku mediánová mzda žen činí `r format(round(median(mydata[mydata['gender'] == 'Female', 'basePay'] %>% pull()),2), scientific=FALSE)` USD a mediánová mzda mužů `r format(round(median(mydata[mydata['gender'] == 'Male', 'basePay'] %>% pull()),2), scientific=FALSE)` USD. To odpovídá rozdílu `r round((median(mydata[mydata['gender'] == 'Male', 'basePay'] %>% pull()) - median(mydata[mydata['gender'] == 'Female', 'basePay'] %>% pull())), 1)` USD, resp. neadjustované GPG (definované jako poměr rozdílu mediánové mzdy mužů a žen a mediánové mzdy mužů) `r round((median(mydata[mydata['gender'] == 'Male', 'basePay'] %>% pull()) - median(mydata[mydata['gender'] == 'Female', 'basePay'] %>% pull())) / median(mydata[mydata['gender'] == 'Male', 'basePay'] %>% pull()) * 100, 1)` %. Míra platové nerovnosti se tak v námi sledované firmě zdá být spíše nižší, srovnatelná s celkovou hodnotou tohoto ukazatele v zemích jako je např. Švédsko nebo Nový Zéland (viz graf z úvodu tohoto článku). Pokud bychom chtěli zohlednit míru naší nejistoty při odhadu velikosti rozdílu mezi typickým platem mužů a žen, která je daná tím, že pracujeme pouze se vzorkem zaměstnanců a nikoli s celou firmou, měli bychom sáhnout po inferenční statistice. Při použití bayesovského ekvivalentu t-testu pro dva nezávislé výběry získáme takto informaci o posteriorní distribuci velikosti tohoto rozdílu. Na grafu níže můžeme vidět, že 95% interval kredibility se nachází v rozmezí od 5511 do 11615 USD, s mediánovou hodnotou 8392 USD. Z grafu také můžeme vyčíst, že dostupná data mluví silně v neprospěch nulové hypotézy o neexistenci rozdílu mezi průměrným platem mužů a žen - viz velmi nízká hodnota logaritmu [Bayesova faktoru](https://en.wikipedia.org/wiki/Bayes_factor) ve prospěch nulové hypotézu BF~01~. ```r set.seed(123) ggstatsplot::ggbetweenstats( data = mydata, x = gender, y = basePay, type = "bayes", title = "Rozdíl v základní mzdě mezi muži a ženami", palette = "Dark2" ) + ggplot2::scale_y_continuous( labels = scales::number_format( accuracy = 1, scale = 1/1000, suffix = "k", prefix = "$", big.mark = ","), limits = c(0,200000) ) + ggplot2::labs(x = "") ``` ![](./paygap/unnamed-chunk-9-1.png) **Samotný fakt rozdílné výše základní mzdy u mužů a žen ale ještě nemusí automaticky znamenat, že by se za ním skrývala diskriminace žen**. Pozorovaný rozdíl může být totiž např. způsobený tím, že ženy zaměstnané v námi sledované firmě mají typicky nižší vzdělání než ve stejné firmě zaměstnaní muži. A vzhledem k tomu, že výše vzdělání (z hlediska "meritokratické spravedlnosti" zcela neproblematicky) pozitivně koreluje s výší platu, projeví se tato souvislost v nižší typické mzdě žen (ponechme nyní stranou otázku, v jaké míře mají ženy obecně přístup k vyššímu vzdělání ve společnosti, kde daná firma působí). Tuto hypotézu se zdají podporovat i dva níže uvedené grafy, které vizualizují vztah mezi úrovní vzdělání zaměstnance a výší jeho základní mzdy, resp. souvislost mezi pohlavím zaměstnance a úrovní jeho vzdělání. ```r mydata %>% ggplot2::ggplot(aes(x = edu, y = basePay)) + PupillometryR::geom_flat_violin(position = position_nudge(x = .2, y = 0), alpha = .8, fill = "#a9b2d1") + ggplot2::geom_point(aes(y = basePay), position = position_jitter(width = .15), size = .5, alpha = 0.8, color = "#a9b2d1") + ggplot2::geom_boxplot(width = .1, guides = FALSE, outlier.shape = NA, alpha = 0.5, fill = "#a9b2d1") + ggplot2::expand_limits(x = 5.25) + ggplot2::guides(fill = FALSE) + ggplot2::guides(color = FALSE) + ggplot2::scale_y_continuous( labels = scales::number_format( accuracy = 1, scale = 1/1000, suffix = "k", prefix = "$", ), limits = c(0,200000) ) + ggplot2::theme_minimal() + ggplot2::theme(panel.border = element_blank()) + ggplot2::labs(title = "Vztah mezi úrovní vzdělání a výší základní mzdy", x = "") ``` ![](./paygap/unnamed-chunk-10-1.png) ```r mydata %>% ggplot2::ggplot(aes(x = edu, fill = gender)) + ggplot2::geom_bar(position = "fill") + ggplot2::scale_fill_hue() + ggplot2::theme_minimal() + ggplot2::labs(title = "Míra zastoupení můžů a žen v jednotlivých kategoriích úrovně vzdělání", x = "", y = "", fill = "") + ggplot2::scale_fill_brewer(palette="Dark2") + ggplot2:: scale_color_brewer(palette="Dark2") + ggplot2::scale_y_continuous(labels = scales::percent_format()) + ggplot2::theme(legend.position = "top") ``` ![](./paygap/unnamed-chunk-11-1.png) Podobných kombinovaných souvislostí může v našich datech (a v realitě, kterou reprezentují) existovat větší množství. Pokud by čtenář chtěl vztahy mezi různými kombinacemi proměnných prozkoumat sám a detailněji, může za tímto účelem využít [tuto interaktivní aplikaci](https://peopleanalyticsblog.shinyapps.io/gender-pay-gap/?_ga=2.145829618.756830262.1613246735-1591991673.1613246735), kde jsou nahraná naše data a kde lze snadno různým způsobem vizualizovat zadané kombinace proměnných. Viz níže uvedená ukázka využití této aplikace při vizualizaci vztahu mezi výší platu, pohlavím a pracovní pozicí, včetně počtu zaměstnanců v jednotlivých kombinovaných kategoriích. Z tohoto konkrétního grafu je dobře patrné, že ženy jsou ve srovnání s muži disproporčně méně zastoupeny na dvou nadprůměrně odměňovaných pozicích *Manager* a *Software Engineer* a naopak disproporčně více jsou zastoupeny na podprůměrně platově ohodnocené pozici *Marketing Associate*. [![Interactive Exploration of GPG Data](./paygap/eda_app.png)](https://peopleanalyticsblog.shinyapps.io/gender-pay-gap/?_ga=2.145829618.756830262.1613246735-1591991673.1613246735)
Důležitou kategorií vztahů mezi proměnnými, kterou bychom měli prozkoumat, pokud se chceme co nejblíže dostat k příčinám pozorovaných nerovností v platech mužů a žen a dobře zacílit případné intervence, jsou tzv. **interakce**. Ty popisují situace, kdy vztah mezi dvěma proměnnými závisí na hodnotě nějaké třetí proměnné. Nás zde bude konkrétně zajímat interakce mezi naší hlavní nezávislou proměnnou (prediktorem), tj. pohlavím zaměstnance, a dalšími nezávislými proměnnými (např. věkem, úrovní vzdělání, hodnocením pracovního výkonu, pracovní pozicí nebo oddělením) ve vztahu k naší závislé proměnné (kritériu), tedy základní mzdě. Příkladem vizualizace tohoto druhu vztahu mezi proměnnými je níže uvedený graf, ze kterého můžeme vyčíst, že ženy mají sice v průměru nižší základní mzdu než muži napříč celým věkovým spektrem (viz níže položená regresní přímka pro skupinu žen), ale fakt, že zobrazené regresní přímky jsou rovnoběžné, svědčí pro to, že v rámci obou skupin platí stejný typ vztahu mezi věkem a výší platu, a tedy že mezi pohlavím a věkem ve vztahu k výši mzdy nedochází k žádné interakci. Pokud by se existence takové interakce potvrdila i při zohlednění dalších relevantních faktorů, mělo by to pro nás být podnětem k další exploraci toho, co se pozorovaným rozdílem skrývá. ```r mydata %>% ggplot2::ggplot(aes(x = age, y = basePay, fill = gender, colour = gender, group = gender)) + ggplot2::geom_point(size = 1L, position = "jitter", alpha = 0.5) + ggplot2::geom_smooth(span = 1L, method = "lm") + ggplot2::scale_fill_brewer(palette = "Dark2") + ggplot2::scale_color_brewer(palette = "Dark2") + ggplot2::theme_minimal() + ggplot2::labs(title = "Vztah mezi věkem zaměstnanců a výší jejich základní mzdy", fill = "", color = "") + ggplot2::scale_y_continuous( labels = scales::number_format( accuracy = 1, scale = 1/1000, suffix = "k", prefix = "$", big.mark = ","), limits = c(0,200000) ) + ggplot2::theme(legend.position = "top") ``` ![](./paygap/unnamed-chunk-12-1.png) ## Statistický model platové nerovnosti Abychom dokázali izolovat vliv samotného pohlaví zaměstnanců na výši platu a zohlednit přitom zároveň vliv všech ostatních relevantních faktorů, včetně některých jejich interakcí, musíme sáhnout po komplexnějším nástroji než je popisná statistika. A tímto nástrojem je **statistické modelování**. Statistické modelování, podobně jako jakékoli jiné modelování ve vědě, ale i v běžném životě, není ničím jiným než snahou **vytvořit menší a zjednodušený model našeho světa, který však jeho chování odráží dostatečně věrně na to, abychom s jeho pomocí mohli činit úsudky a předpovědi o skutečném světě a zakládat na něm svá rozhodnutí** (k tomuto tématu viz srozumitelně napsaný popularizující článek [Modeluji, tedy jsem](https://www.bisop.eu/josef-slerka-modeluji-tedy-jsem-lidove-noviny/) od [Josefa Šlerky](https://www.linkedin.com/in/josefslerka/)). Statistické modelování se potom od jiných druhů modelování liší v tom, že se ve větší míře opírá o nástroje matematické statistiky a teorie pravděpodobnosti. Překvapivě mnoho jevů našeho světa se dá úspěšně modelovat a předpovídat pomocí relativně jednoduchých statistických modelů **zobecněné lineární regrese** (*Generalized Linear Models*, GLM). Ty předpokládají, že závislá proměnná, transformovaná prostřednictvím tzv. **linkovací funkce** (*link function*), je funkcí lineární kombinace nezávislých proměnných. Nejznámější z této rodiny statistických modelů je **klasický lineární model**, který předpokládá normální rozdělení závislé proměnné, resp. reziduí (chyb) okolo predikované/ očekávané střední hodnoty závislé proměnné (viz ilustrativní obrázek níže). ![](./paygap/glm.png) Vzhledem k tomu, že námi modelovaná proměnná základní mzdy se zdá mít normální, nebo téměř normální rozdělení (viz některé grafy v části věnované explorační analýze), můžeme i my sáhnout po tomto statistickém modelu. Jako nezávislé proměnné v našem modelu použijeme všechny nám dostupné prediktory, spolu s interakcemi mezi proměnnou pohlaví na straně jedné a proměnnými úrovně vzdělání, seniority, věku a hodnocení pracovního výkonu na straně druhé. Protože zaměstnanci tvoří přirozené shluky v rámci oddělení, napříč kterými se liší výše mzdy a také by se mohla lišit povaha vztahu mezi pohlavím zaměstnance a výší jeho mzdy, použijeme **hierarchickou/víceúrovňovou variantu modelu lineární regrese**, která umožňuje, aby hodnoty vybraných parametrů modelu variovaly v závilosti na příslušnosti zaměstnanců do konkrétního oddělení. K odhadu hodnot parametrů našeho modelu použijeme **inferenční rámec bayesovské statistiky**, která ve srovnání s frekventistickou statistikou nabízí bohatší a intuitivně snáze uchopitelné výstupy. Pro apriorní distribuci parametrů modelu použijeme defaultní, široké a neinformativní hodnoty, takže výsledky analýzy budou nominálně podobné těm, které bychom získali při použití tradičnější frekventistické inferenční statistiky. ```r # defining and running the model model <- brms::brm( basePay | trunc(lb = 0) ~ 1 + jobTitle + gender + age + perfEval + edu + seniority + gender:edu + gender:seniority + gender:age + gender:perfEval + (1 + gender | dept), data = mydata %>% dplyr::mutate_if(is.factor, as.character), family = gaussian(link = "identity"), iter = 3000, chains = 3, cores = 6, warmup = 1000, seed = 2809, control = list( adapt_delta = 0.99, max_treedepth = 20 ) ) ``` ## Výsledky analýzy Dříve než přistoupíme k interpretaci výsledků analýzy je dobré si ověřit, že náš statistický model dokáže dostatečně věrně napodobit či simulovat data reprezentující firemní realitu, na jejíž vlastnosti chceme s pomocí tohoto modelu usuzovat. Za tímto účelem můžeme použít nástroj posteriorní prediktivní kontroly (*posterior predictive check*), který ověřuje, jak moc dobře námi zvolený a odhadnutý model predikuje pozorovaná data na základě vzorku posteriorních hodnot jeho parametrů. Z níže uvedeného grafu je dobře patrné, že náš model si z tohoto hlediska nevede vůbec špatně. Po této kontrole (a také po ověření dalších [technických náležitostí](https://jrnold.github.io/bayesian_notes/mcmc-diagnostics.html), jako je např. konvergence [MCMC](https://en.wikipedia.org/wiki/Markov_chain_Monte_Carlo) řetězců, které umožňují odhadnout posteriorneí distribuci parametrů i komplexnějších statistických modelů jako je ten náš) můžeme začít využívat parametry našeho modelu k usuzování na pravděpodobné vlastnosti námi studované firemní reality. ```r # investigating the model's fit # specifying the number of samples nsamples = 100 brms::pp_check( model, nsamples = nsamples ) + ggplot2::labs( title = stringr::str_glue("Posteriorní prediktivní kontrola modelu za použití vzorku o velikoti n = {nsamples}") ) ``` ![](./paygap/unnamed-chunk-14-1.png) Níže je uveden souhrn informací o našem odhadnutém modelu. Primárně nás zajímá hodnota parametru pohlaví (*genderMale*) v sekci věnované efektům na úrovni celé populace (*Population-Level Effects*). 95% interval kredibility (*Credible Interval*), který udává kam v posteriorním rozdělení spadá hodnota nepozorovaného parametru s 95% pravděpodobností, se nachází v rozmezí od -3750.04 USD do 9081.92 USD, se střední hodnotou 2717.57. Tzn., že podle našeho modelu má muž - při zohlednění ostatních faktorů a jejich vybraných interakcí - typicky o cca 2700 USD vyšší základní mzdu než její ženský protějšek. Analýza našich dat tak do určité míry podporuje hypotézu o existenci platové diskriminace na základě pohlaví zaměstnance v námi studované firmě. Síla důkazu ve prospěch této hypotézy však není nijak výrazná, což vyplývá z toho, že 95% interval kredibility zahrnuje vedle kladných hodnot i nulovou hodnotu a záporné hodnoty parametru pohlaví jako jeho plauzibilní hodnoty. ```r summary(model) ``` Pokud bychom chtěli přesněji vyjadřit míru, s níž naše data v rámci našeho modelu favorizují hodnoty parametru pohlaví větší než nula (tj. hodnoty, které jsou v souladu s hypotézou o existenci platové diskriminace na základě pohlaví v neprospěch žen), můžeme se podívat na posteriorní distribuci tohoto parametru a jednoduše na něm spočítat, s jakou pravděpodobností nabývá kladných hodnot. ```r # visualizing posterior distribution of the model's b_genderMale parameter paramViz <- model %>% tidybayes::gather_draws( b_genderMale ) %>% dplyr::rename(value = .value) dens <- density(paramViz$value) paramViz <- tibble(x = dens$x, y = dens$y) ggplot2::ggplot( paramViz, aes(x,y) ) + ggplot2::geom_area( data = filter(paramViz, x > 0), fill = "lightblue" ) + ggplot2::geom_area( data = filter(paramViz, x <= 0), fill = "grey" ) + ggplot2::geom_line( ) + ggplot2::scale_x_continuous(breaks = seq(-15000, 15000, 5000)) + ggplot2::theme_minimal() + ggplot2::labs( title = "Posteriorní distribuce parametru pohlaví zaměstnance", y = "Density", x = "genderMale" ) ``` ![](./paygap/unnamed-chunk-16-1.png) ```r # extracting posterior samples samples <- brms::posterior_samples(model) # probability of b_genderMale coefficient being higher prop <- sum(samples$b_genderMale > 0) / nrow(samples) ``` ```r # Bayesian hypothesis test the_test <- brms::hypothesis(model, "genderMale > 0") ``` Po provedení tohoto výpočtu nám vychází hodnota `r round(prop*100,1)` %. To je v souladu s předchozím tvrzením, že důkaz ve prospěch testované hypotézy není příliš silný. Další možností by bylo použití tzv. [Bayesova faktoru](https://en.wikipedia.org/wiki/Bayes_factor), který vyjadřuje míru s níž dostupná data favorizují testovanou hypotézu ve srovnání s modelem odpovídajícím nulové hypotéze. Ten má pro naši hypotézu hodnotu `r round(the_test$hypothesis$Evid.Ratio, 1)`, což odpovídá významnému, ale zdaleka nikoli silnému či rozhodnému důkazu ve prospěch naší hypotézy. Vedle parametru pohlaví může být pro nás potenciálně užitečné podívat se také na vztah základní mzdy a ostatních prediktorů použitých v našem modelu. Za tímto účelem můžeme použít vizualizaci marginálních efektů jednotlivých prediktorů, které vyjadřují vztah mezi prediktorem a kritériem při zohlednění vlivu ostatních prediktorů. Takto např. můžeme na jednom z grafů vidět, že vztah mezi úrovní vzdělání a výší základního platu se má tendenci u mužů a žen lišit. Na jiném grafu si můžeme zase všimnout toho, že rozdíl mezi základní mzdou mužů a žen má tendenci narůstat s tím, jak klesá seniorita zaměstnanců. Tyto a další podobné vhledy nám mohou pomoct přiblížit se k důvodům za pozorovanými nerovnostmi v platech mužů a žen. ```r # plotting marginal effects of predictors used # Note: Conditional vs. Marginal Relationships: The regression coefficients in generalized linear mixed models represent conditional effects in the sense that they express comparisons holding the cluster-specific random effects (and covariates) constant. For this reason, conditional effects are sometimes referred to as cluster-specific effects. In contrast, marginal effects can be obtained by averaging the conditional expectation μij over the random effects distribution. Marginal effects express comparisons of entire sub-population strata defined by covariate values and are sometimes referred to as population-averaged effects.In linear mixed models (identity link), the regression coefficents can be interpreted as either conditional or marginal effects. However, conditional and marginal effects differ for most other link functions. marginalEffplots <- plot( brms::marginal_effects( model, effects = c("jobTitle", "age", "perfEval", "edu", "seniority", "gender:edu", "gender:seniority", "gender:age", "gender:perfEval"), probs = c(0.025, 0.975)), ask = FALSE ) ``` ```r # putting all graphs with marginal effects together ggpubr::ggarrange( plotlist = marginalEffplots, nrow = 9, ncol = 1 ) ``` ![](./paygap/unnamed-chunk-20-1.png) ## Možné další kroky I v situaci, kdy analýza dat nepodpoří naše podezření na existenci platové diskriminace na základě pohlaví zaměstnance, je stále možné, že za pozorovaným rozdílem v platech mužů a žen jsou jiné faktory, které s pohlavím zaměstance nějak souvisí. Např. skutečnost, že jsou ženy méně reprezentované na lépe placených seniornějších pozicích, by mohla svědčit o tom, že se ženy na pracovišti mohou potýkat s genderovými stereotypy a že při snaze o kariérní postup na lépe placené pozice narážejí na tzv. "skleněný strop“. Pro učinění takového závěru je však zapotřebí získat další data, a to spíše kvalitativní povahy, taková, která sbírá a analyzuje např. [organizační](https://lpsonline.sas.upenn.edu/features/what-organizational-anthropology) či [firemní antropologie](https://www.bu.edu/anthrop/undergraduate/internship-opportunities/business-anthropology/#:~:text=Business%20anthropology%20is%20an%20important%20subfield%20of%20anthropology.&text=In%20short%2C%20business%20anthropology%20is,business%20problems%20in%20everyday%20life.). V situaci, kdy máme dostatečně silné důkazy pro to, že se za pozorovanou platovou nerovností mezi muži a ženami skrývají faktory související s pohlavím zaměstnance, je možné začít se poohlížet po možných řešeních. Stejně jako při identifikaci problému, i při hledání způsobu jeho řešení je dobré držet se zásad [na důkazech založeného managementu](https://scienceforwork.com/blog/what-is-evidence-based-management/) a volit pouze řešení s dostatečně empiricky doloženou účinností, která zároveň dávají smysl ve specifickém kontextu dané firmy. Užitečný přehled možných akcí, které zaměstnavatelé mohou podniknout s cílem snížit GPG ve své organizaci, vytvořila známá skupina odborníků na behaviorální vědy v rámci tzv. [The Behavioral Insights Team](https://www.bi.team/), která svého času vznikla pro to, aby britské vládě pomáhala realizovat účinnou politiku založenou na důkazech. V dokumentu s názvem *Reducing the gender pay gap and improving gender equality in organisations: Evidence-based actions for employers* tato skupina odborníků uvádí několik možných intervencí, které řadí do tří kategorií podle toho, jak dobře je jejich účinnost podložená empirickými důkazy. Mezi **akce s dobře doloženou účinností** řadí následující intervence: * Zahrnutí většího počtu žen do užších seznamů v rámci výběru nových zaměstnanců a povyšování. * Používání úloh posuzujících úroveň pracovních dovedností v rámci výběru nových zaměstnanců. * Používání strukturovaného interview v rámci výběru nových zaměstnanců a povyšování. * Podpora vyjednávání o výši platu pomocí zvěřejnění existujícího platového rozmezí. * Zavedení transparentních procesů povyšování a odměňování. * Jmenování manažera či zřízení pracovní skupiny pro firemní diverzitu. Mezi **potenciálně slibné akce, které ale vyžadují další důkazy o své účinnosti**, řadí následující postupy: * Zvýšení pracovní flexibility pro muže a pro ženy. * Podporu sdílené rodičovské dovolené. * Nábor bývalých zaměstnanců, kteří museli z různých osobních důvodů na delší dobu přerušit svou kariéru. * Nabídku mentoringu and sponsorshipu. * Nabídku networkingových programů. * Nastavení interních cílů. A mezi **akce se smíšenými doklady o jejich účinnosti** potom řadí následující opatření: * Školení věnované tématu nevědomých předsudků. * Školení v oblasti diverzity. * Školení věnované rozvoji leadershipu. * Demograficky různorodé výběrové panely v rámci externího i interního náboru. Zde je pro zájemce originální dokument k bližšímu prostudování.

Tento prohlížeč nepodporuje soubory PDF. Pro zobrazení si, prosím, PDF soubor stáhněte: Stáhnout PDF.


Skript k analýze je k dispozici ke stažení v podobě Jupyter Notebooku na mých [GitHub stránkách](https://github.com/lstehlik2809/gender-pay-gap-analysis). --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2021-01-29-paygap/) on my blog. --- # Modeling impact of the COVID-19 pandemic on people’s interest in work-life balance and well-being URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/segmentedregression Date: 2020-12-31 Tags: well-being, work-life-balance, remote-work, segmented-regression, time-series, bayesian-statistics, r Original post: https://blog-about-people-analytics.netlify.app/posts/2020-12-31-segmentedregression/ The turn of the year, which is full of all sorts of resolutions to change for the better in our private lives and in our organizations, is a good time to remind ourselves that analytic tools can be very helpful in our efforts to make these resolutions come true. One way they can help us is by verifying that we have really achieved our stated goals and that we are not just fooling ourselves into believing so. We need to keep in mind [Richard Feynman](https://en.wikipedia.org/wiki/Richard_Feynman)'s famous principle of critical thinking... ![](./segmentedregression/feynman.jpg)
One of the tools that can help us with that is [segmented regression analysis of interrupted time series data](https://www.researchgate.net/publication/227681545_Segmented_Regression_Analysis_of_Interrupted_Time_Series_Studies_in_Medication_Use_Research) (thanks to [Masatake Hirono](https://www.linkedin.com/posts/masatakehirono1351_day044-100daysofcode-r-activity-6749580595254456320-qHK_) for pointing me to its existence). It allows us to model changes in various processes and outcomes that follow interventions, while controlling for other types of changes (e.g. trends and seasonality) that may have occurred regardless of the interventions. It is thus very useful for data analysis conducted within studies with a [quasi experimental study design](https://en.wikipedia.org/wiki/Quasi-experiment) that are often in the organizational context the best alternative to the “gold standard” of [randomized controlled trials](https://en.wikipedia.org/wiki/Randomized_controlled_trial) (RCTs) that are not always realizable or politically acceptable. ## Search interest in work-life balance and well-being For illustration, let's use this tool for testing hypothesis about people’s increased interest in topics related to work-life balance and well-being due to the COVID-19 pandemic and subsequent changes in the way people work. As a proxy measure of this interest we will use worldwide search interest data over the last 10 years from [Google Trends](https://trends.google.com/trends/?geo=US) using search terms *work-life balance* and *well-being* (see Fig. 1 and 2 below). ![](./segmentedregression/workLifeBalanceGoogleTrends.png) *Fig. 1: Interest in “work-life balance” topic over the last 10 years measured as a search interest by Google Trends. The numbers represent search interest relative to the highest point on the chart for the given region and time. A value of 100 is the peak popularity for the term. A value of 50 means that the term is half as popular. A score of 0 means that there was not enough data for this term.*
![](./segmentedregression/wellBeingGoogleTrends.png) *Fig. 2: Interest in “well-being” topic over the last 10 years measured as a search interest by Google Trends. The numbers represent search interest relative to the highest point on the chart for the given region and time. A value of 100 is the peak popularity for the term. A value of 50 means that the term is half as popular. A score of 0 means that there was not enough data for this term.*
Based solely on the visual inspection of the graphs, it is pretty difficult to tell whether there was some effect of the COVID-19 pandemic or not, especially in the case of work-life balance (for the purpose of this analysis, the beginning of the pandemic is assumed to have started in March 2020). For sure it’s not a job for “inter-ocular trauma test” when the existence of the effect hits you directly between the eyes. We need to rely here on inferential statistics and its ability to help us with distinguishing signal from noise. Before conducting the analysis itself, we need to wrangle the data from Google Trends a little bit using the recipe presented in [the Wagner et. al (2002) paper](https://www.researchgate.net/publication/227681545_Segmented_Regression_Analysis_of_Interrupted_Time_Series_Studies_in_Medication_Use_Research). Specifically, we need the following five variables (or six, given that we have two dependent variables): * **search interest** – a numerical variable representing search interest relative to the highest point on the chart for the given region and time; this variable is truncated within the interval between values of 0 and 100; a value of 100 is the peak popularity for the term; a value of 50 means that the term is half as popular; a score of 0 means that there was not enough data for this term; this variable serves as a dependent (criterion) variable; * **elapsed time** – a numerical variable representing the number of months that elapsed from the beginning of the time series; this variable enables estimation of the size and direction of the overall trend in the data; * **pandemic** – a binary variable indicating the presence/absence of pandemic; as already mentioned above, for the purpose of this analysis, the beginning of the pandemic is assumed to have started in March 2020; this variable enables estimation of the level change in the interest in work-life balance and well-being immediately after the pandemic outbreak; * **elapsed time after pandemic outbreak** – a numerical variable representing the number of months that elapsed from the beginning of pandemic; this variable enables estimation of the change in the trend in the interest in work-life balance and well-being after the outbreak of pandemic; * **month** – a categorical variable representing specific month within a year; this variable enables controlling for the effect of seasonality. ```r # uploading library for data manipulation library(tidyverse) # uploading data dfWorkLifeBalance <- readr::read_csv("./workLifeBalanceGoogleTrendData.csv") dfWellBeing <- readr::read_csv("./wellBeingGoogleTrendData.csv") dfAll <- dfWorkLifeBalance %>% # joining both datasets dplyr::left_join( dfWellBeing, by = "Month" ) %>% # changing the format and name of Month variable dplyr::mutate( Month = stringr::str_glue("{Month}-01"), Month = lubridate::ymd(Month) ) %>% dplyr::rename( date = Month ) %>% # creating new variable month dplyr::mutate( month = lubridate::month(date,label = TRUE, abbr = TRUE), month = factor(month, levels = c("Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"), labels = c("Jan","Feb","Mar","Apr","May", "Jun","Jul","Aug","Sep","Oct","Nov","Dec"), ordered = FALSE) ) %>% # arranging data in ascending order by date dplyr::arrange( date ) %>% # creating new variables dplyr::mutate( elapsedTime = row_number(), pandemic = case_when( date >= "2020-03-01" ~ 1, TRUE ~ 0 ), elapsedTimeAfterPandemic = cumsum(pandemic) ) %>% dplyr::mutate( pandemic = as.factor(case_when( pandemic == 1 ~ "After the pandemic outbreak", TRUE ~ "Before the pandemic outbreak" )) ) %>% # changing order of variables in df dplyr::select( date, workLifeBalance, wellBeing, elapsedTime, month, pandemic, elapsedTimeAfterPandemic ) ```
Here is a table with the resulting data we will use for testing our hypothesis. ```r # uploading library for making user-friendly data table library(DT) DT::datatable( dfAll, class = 'cell-border stripe', filter = 'top', extensions = 'Buttons', fillContainer = FALSE, rownames= FALSE, options = list( pageLength = 5, autoWidth = TRUE, dom = 'Bfrtip', buttons = c('copy'), scrollX = TRUE, selection="multiple" ) ) ``` *Table 1: Final dataset used for testing hypothesis about impact of the COVID-19 pandemic on people's interest in work-life balance and well-being.*
## Bayesian segmented regression We will model our data using common segmented regression models that have following general structure: $$Y_{t} = β_{0} + β_{1}*time_{t} + β_{2}*intervention_{t} + β_{3}*time after intervention_{t} + e_{t}$$ The *β0* coefficient estimates the baseline level of the outcome variable at time zero; *β1* coefficient estimates the change in the mean of the outcome variable that occurs with each unit of time before the intervention (i.e. the baseline trend); *β2* coefficient estimates the level change in the mean of the outcome variable immediately after the intervention (i.e. from the end of the preceding segment); and *β3* estimates the change in the trend in the mean of the outcome variable per unit of time after the intervention, compared with the trend before the intervention (thus, the sum of *β1* and *β3* equals to the post-intervention slope). For a better understanding of the model, take a look at the illustrative chart below. ![](./segmentedregression/interruptedTimeSeriesAnalysis.png) Since we are dealing with correlated and truncated data, we should also include two additional terms in our model, an autocorrelation term and a truncation term, to handle these specific properties of our data. Now let’s fit the models to the data and check what they tell us about the effect of pandemic on people’s search interest in work-life balance and well-being. We will use [brms r package](https://cran.r-project.org/web/packages/brms/vignettes/brms_overview.pdf) that enables making inferences about statistical models’ parameters within Bayesian inferential framework. Because of that, we also need to specify some additional parameters (e.g. `chains`, `iter` or `warmup`) of [the Markov Chain Monte Carlo (MCMC) algorithm](https://en.wikipedia.org/wiki/Markov_chain_Monte_Carlo) that will generate posterior samples of our models’ parameters. Bayesian framework also enables us to specify priors for estimated parameter and through them include our domain knowledge in the analysis. The specified priors are important for both parameter estimation and hypothesis testing as they define our starting information state before we take into account our data. Here we will use rather wide, uninformative, and only mildly regularizing priors (it means that the results of the inference will be very close to the results of standard, frequentist parameter estimation/hypothesis testing). ```r # uploading library for Bayesian statistical inference library(brms) # checking available priors for the models brms::get_prior( workLifeBalance | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = dfAll, family = gaussian()) brms::get_prior( wellBeing | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = dfAll, family = gaussian()) ``` ```r # uploading library for Bayesian statistical inference library(brms) # specifying wide, uninformative, and only mildly regularizing priors for predictors in both models priors <- c(set_prior("normal(0,50)", class = "b", coef = "elapsedTime"), set_prior("normal(0,50)", class = "b", coef = "elapsedTimeAfterPandemic"), set_prior("normal(0,50)", class = "b", coef = "pandemicBeforethepandemicoutbreak"), set_prior("normal(0,50)", class = "b", coef = "monthApr"), set_prior("normal(0,50)", class = "b", coef = "monthAug"), set_prior("normal(0,50)", class = "b", coef = "monthDec"), set_prior("normal(0,50)", class = "b", coef = "monthFeb"), set_prior("normal(0,50)", class = "b", coef = "monthJul"), set_prior("normal(0,50)", class = "b", coef = "monthJun"), set_prior("normal(0,50)", class = "b", coef = "monthMar"), set_prior("normal(0,50)", class = "b", coef = "monthMay"), set_prior("normal(0,50)", class = "b", coef = "monthNov"), set_prior("normal(0,50)", class = "b", coef = "monthOct"), set_prior("normal(0,50)", class = "b", coef = "monthSep")) # defining the statistical model for work-life balance modelWorkLifeBalance <- brms::brm( workLifeBalance | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = dfAll, family = gaussian(), prior = priors, chains = 4, iter = 3000, warmup = 1000, seed = 12345, sample_prior = TRUE ) # defining the statistical model for well-being modelWellBeing <- brms::brm( wellBeing | trunc(lb = 0, ub = 100) ~ elapsedTime + pandemic + elapsedTimeAfterPandemic + month + ar(p = 1), data = dfAll, family = gaussian(), prior = priors, chains = 4, iter = 3000, warmup = 1000, seed = 678910, sample_prior = TRUE ) ```
## Some necessary sanity checks Before making any inferences, we should make some sanity checks to be sure that the mechanics of the MCMC algorithm worked well and that we can use generated posterior samples for making inferences about our models’ parameters. There are many ways for doing that, but here we will use only visual check of the MCMC chains. We want plots of these chains look like hairy caterpillar which would indicate convergence of the underlying Markov chain to stationarity and convergence of Monte Carlo estimators to population quantities, respectively. As can be seen in Graph 1 and 2 below, in case of both models we can observe wanted characteristics of the MCMC chains described above. (For additional MCMC diagnostics procedures, see for example [Bayesian Notes](https://jrnold.github.io/bayesian_notes/mcmc-diagnostics.html) from Jeffrey B. Arnold.) ```r # uploading library for plotting Bayesian models library(bayesplot) # plotting the MCMC chains for the modelWorkLifeBalance bayesplot::mcmc_trace( modelWorkLifeBalance, facet_args = list(nrow = 6) ) + ggplot2::labs( title = "Plots of the MCMC chains used for estimation of the modelWorkLifeBalance's parameters" ) ``` ![](./segmentedregression/unnamed-chunk-5-1.png) *Graph 1: Trace plots of Markov chains for individual parameters of the modelWorkLifeBalance.*
```r # plotting the MCMC chains for the modelWellBeing bayesplot::mcmc_trace( modelWellBeing, facet_args = list(nrow = 6) ) + ggplot2::labs( title = "Plots of the MCMC chains used for estimation of the modelWellBeing's parameters" ) ``` ![](./segmentedregression/unnamed-chunk-6-1.png) *Graph 2: Trace plots of Markov chains for individual parameters of the modelWellBeing.*
It is also important to check how well the models fit the data. We can use for this purpose posterior predictive checks that use specified number of sampled posterior values of models' parameters and show how well the fitted models predict observed data. We can see in Graphs 3 and 4 that both models fit the observed data reasonably well. ```r # investigating modelWorkLifeBalance fit # specifying the number of samples nsamples = 1000 brms::pp_check( modelWorkLifeBalance, nsamples = nsamples ) + ggplot2::labs( title = stringr::str_glue("Posterior predictive checks for modelWorkLifeBalance (using {nsamples} samples)") ) ``` ![](./segmentedregression/unnamed-chunk-7-1.png) *Graph 3: Posterior predictive checks comparing simulated/replicated data under the fitted modelWorkLifeBalance with the observed data.*
```r # investigating modelWellBeing fit # specifying the number of samples nsamples = 1000 brms::pp_check( modelWellBeing, nsamples = nsamples ) + ggplot2::labs( title = stringr::str_glue("Posterior predictive checks for modelWellBeing (using {nsamples} samples)") ) ``` ![](./segmentedregression/unnamed-chunk-8-1.png) *Graph 4: Posterior predictive checks comparing simulated/replicated data under the fitted modelWellBeing with the observed data.*
## Results of the analysis Now, after having sufficient confidence that - using terminology from the [Richard McElreath's book Statistical Rethinking](https://xcelab.net/rm/statistical-rethinking/) - our "small worlds" can pretty accurately mimic the data coming from our real,"big world", we can use our models' parameters to learn something about our research questions. Our primary interest is in the coefficient value of the `pandemicBeforethepandemicoutbreak` and `elapsedTimeAfterPandemic` terms in our models. It expresses how much and in what direction people's search interest in work-life balance and well-being changed immediately after the outbreak of pandemic, and how slope of the trend changed after the pandemic, respectively. In Graph 5 and 6 we can see posterior distribution of the `pandemicBeforethepandemicoutbreak` parameter in our two models. In both cases the posterior distribution of the pandemic term is (predominantly or completely) on the left side of the zero value, which supports the claim about existence of the effect of pandemic on people's increased search interest in work-life balance and well-being immediately after the outbreak of pandemic. As is apparent from the graphs, for well-being (Graph 6) this evidence is much stronger than for work-life balance (Graph 5), which corresponds to impression we might have when looking at the original Google Trends charts shown in Fig. 1 and 2. ```r # uploading library for library(tidybayes) # visualizing posterior distribution of the pandemicBeforethepandemicoutbreak parameter in the modelWorkLifeBalance modelWorkLifeBalance %>% tidybayes::gather_draws( b_pandemicBeforethepandemicoutbreak ) %>% dplyr::mutate( .variable = factor( .variable, levels = c("b_pandemicBeforethepandemicoutbreak"), ordered = TRUE ) ) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot( aes(x = value) ) + ggplot2::geom_density( fill = "lightblue" ) + ggplot2::labs( title = "Posterior distribution of the pandemicBeforethepandemicoutbreak parameter\nin the modelWorkLifeBalance" ) ``` ![](./segmentedregression/unnamed-chunk-9-1.png) *Graph 5: Visualization of the posterior distribution of the `pandemicBeforethepandemicoutbreak` parameter in the modelWorkLifeBalance.*
```r # visualizing posterior distribution of the pandemicBeforethepandemicoutbreak parameter in the modelWellBeing modelWellBeing %>% tidybayes::gather_draws( b_pandemicBeforethepandemicoutbreak ) %>% dplyr::mutate( .variable = factor( .variable, levels = c("b_pandemicBeforethepandemicoutbreak"), ordered = TRUE ) ) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot( aes(x = value) ) + ggplot2::geom_density( fill = "lightblue" ) + ggplot2::labs( title = "Posterior distribution of the pandemicBeforethepandemicoutbreak parameter\nin the modelWellBeing" ) ``` ![](./segmentedregression/unnamed-chunk-10-1.png) *Graph 6: Visualization of the posterior distribution of the `pandemicBeforethepandemicoutbreak` parameter in the modelWellBeing.*
To generate more summary statistics about posterior distributions (and also some diagnostic information like `Rhat` or `ESS`), we can use `summary()` function. ```r # generating a summary of the results for modelWorkLifeBalance summary(modelWorkLifeBalance) ```
```r # generating a summary of the results for modelWellBeing summary(modelWellBeing) ```
Given that for work-life balance model the posterior distribution of pandemic term crosses the zero value, it would be useful to know how strong is the evidence in the favor of hypothesis that pandemic term is lower than zero. For that purpose we can extract posterior samples and use them for calculation of the proportion of values that are larger/smaller than zero. The resulting proportions show that the vast majority (around 92%) of posterior distribution lies below zero. ```r # extracting posterior samples samples <- brms::posterior_samples(modelWorkLifeBalance, seed = 12345) # probability of b_pandemicBeforethepandemicoutbreak coefficient being lower than 0 sum(samples$b_pandemicBeforethepandemicoutbreak < 0) / nrow(samples) ```
Now let's check the parameter `elapsedTimeAfterPandemic`. Its posterior distribution in both models "safely" includes zero value, which indicates that there is not huge support for positive change in trend after the outbreak of pandemic. ```r # visualizing posterior distribution of the elapsedTimeAfterPandemic parameter in the modelWorkLifeBalance modelWorkLifeBalance %>% tidybayes::gather_draws( b_elapsedTimeAfterPandemic ) %>% dplyr::mutate( .variable = factor( .variable, levels = c("b_elapsedTimeAfterPandemic"), ordered = TRUE ) ) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot( aes(x = value) ) + ggplot2::geom_density( fill = "lightblue" ) + ggplot2::labs( title = "Posterior distribution of the elapsedTimeAfterPandemic parameter\nin the modelWorkLifeBalance" ) ``` ![](./segmentedregression/unnamed-chunk-14-1.png) *Graph 7: Visualization of the posterior distribution of the `elapsedTimeAfterPandemic` parameter in the modelWorkLifeBalance.*
```r # visualizing posterior distribution of the elapsedTimeAfterPandemic parameter in the modelWellBeing modelWellBeing %>% tidybayes::gather_draws( b_elapsedTimeAfterPandemic ) %>% dplyr::mutate( .variable = factor( .variable, levels = c("b_elapsedTimeAfterPandemic"), ordered = TRUE ) ) %>% dplyr::rename(value = .value) %>% ggplot2::ggplot( aes(x = value) ) + ggplot2::geom_density( fill = "lightblue" ) + ggplot2::labs( title = "Posterior distribution of the elapsedTimeAfterPandemic parameter\nin the modelWellBeing" ) ``` ![](./segmentedregression/unnamed-chunk-15-1.png) *Graph 8: Visualization of the posterior distribution of the `elapsedTimeAfterPandemic` parameter in the modelWellBeing.*
In conclusion, we can say that there is some evidence that the COVID-19 pandemic has prompted people to be more interested in topics related to work-life balance and well-being. I wish us all to be able to transform our increased interest in these topics into truly increased quality of our personal and professional lives. It would be a shame not to use that extra incentive many of us have now for making significant change in our lives. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2020-12-31-segmentedregression/) on my blog. --- # HR analytika a odchodovost zaměstnanců URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/hr-analytika-a-odchodovost-zamstnanc Date: 2018-10-14 Tags: employee-turnover, evidence-based-management, shiny-app Original post: https://blog-about-people-analytics.netlify.app/posts/2018-10-14-hr-analytika-a-odchodovost-zamstnanc/ ```r # Nastaveni ceskeho jazyka Sys.setlocale("LC_CTYPE", "czech") ``` ## Co je to HR analytika? HR analytika ve své podstatě vychází ze známé zásady managementu, že **co nelze měřit, nelze ani (efektivně) řídit a zlepšovat**, a aplikuje tuto zásadu na lidské zdroje. V několika posledních letech potom k tomu navíc přidává nadstavbu v podobě pokročilejších analytických postupů, které mají větší potenciál přijít s hlubšími vhledy a s doporučeními s větším efektem. Ale ať už využíváte pouze základní reporting nebo nějakou pokročilejší analytiku, cíl je vždy stejný – snažit se s pomocí dat a jejich analýzy žádoucím způsobem ovlivnit jednotlivé HR procesy, které organizacím pomáhají dosahovat jejich strategických cílů. Názorně to ilustruje níže uvedené schéma zachycující mechanismus propojující HR procesy s (nejen) finančními výsledky organizace ([Paauwe & Richardson, 1997](https://www.tandfonline.com/doi/abs/10.1080/095851997341621?journalCode=rijh20)). ![](./hr-analytika-a-odchodovost-zamstnanc/HR_Value_Chain.png) HR analytika pomáhá optimalizovat nastavení tohoto mechanismu tím, že umožňuje nalézat odpovědi na některé klíčové otázky, jako např.: * Kterými kanály se k nám dostávají ti nejlepší kandidáti? * Jaké charakteristiky od sebe odlišují úspěšné a neúspěšné kandidáty? * Které faktory přispívají k úspěšnému onboardingu? * Která „kápéíčka“ mají nejsilnější vazbu na finanční výsledky firmy? * Jaké tréninky vedou s nejvyšší pravděpodobností ke zlepšení pracovního výkonu? * Které intervence mají největší dopad na zaměstnanci pociťovaný well-being nebo work-life balance? * Co u zaměstnanců zvyšuje, nebo naopak snižuje míru jejich angažovanosti? * Kde se v organizaci nachází izolovaná sila a úzká hrdla znemožňující efektivní komunikaci a spolupráci mezi jednotlivými zaměstnanci, týmy nebo i celými odděleními? * Kdo představuje skrytý talent, který je potřeba podchytit a dále rozvíjet? * Kde lze očekávat odpor v souvislosti s plánovanými změnami ve firmě a kdo naopak může být jejich ambasadorem a katalyzátorem? * Které faktory přispívají k odchodovosti zaměstnanců a u kterých konkrétních zaměstnanců je zvýšené riziko, že firmu během několika příštích měsíců opustí? ## Odchodovost zaměstnanců a její prediktory Právě posledně jmenovaný způsob využití HR analytiky často představuje jeden z prvních druhů HR analytických projektů, kterými se ve firmách s HR analytikou začíná, a to z dobře pochopitelného důvodu. S **nežádoucími odchody zaměstnanců** jsou totiž spojené [vysoké přímé i nepřímé náklady](http://login.bersin.com/uploadedFiles/021517-calculating-true-cost-voluntary-turnover.pdf), takže i poměrně mírné snížení odchodovosti zaměstnanců může představovat značnou úsporu, kterou ocení management každé firmy. Naléhavost tohoto problému navíc ještě zvyšuje současná fáze ekonomického cyklu s rekordně nízkou mírou nezaměstnanosti, která v kombinaci s různými on-line platformami na zprostředkování práce motivuje mnoho lidí k hledání nového místa, kde, jak doufají, bude práce zajímavější, smysluplnější a lépe placená a kde kolegové budou sympatičtější a šéfové inspirativnější. Viz také graf níže, který na datech z USA názorně dokládá těsnost vztahu mezi mírou nezaměstnanosti a mírou dobrovolné odchodovosti zaměstnanců (*r* = -0,95, *p* < 0,001 ). ```r # Nacteni dat ---------------------------------------------------- # Nacteni potrebnych knihoven library(readxl) library(tidyverse) # Nacteni dat po jejich stazeni z https://www.bls.gov/news.release/jolts.t18.htm , resp. # https://data.bls.gov/pdq/SurveyOutputServlet unmpl_data <- read_xlsx("./BLS_Unemployment_Rates.xlsx", sheet = "Unemployment Rate", col_names = TRUE) quits_data <- read_xlsx("./BLS_Quits_Rates.xlsx") # Priprava dat pro vizualizaci ------------------------------------ # Data k mire nezamestnanosti # Prejmenovani sloupcu colnames(unmpl_data) <- c("Date", "Unmpl_Rate") # Odstraneni prvniho radku unmpl_data <- unmpl_data[-1,] # Zmena typu dat library(lubridate) unmpl_data <- unmpl_data %>% mutate(Unmpl_Rate = as.numeric(Unmpl_Rate), Date = myd(Date, truncated = 1)) # Data k dobrovolne odchodovosti zamestnancu # Prejmenovani sloupcu colnames(quits_data) <- quits_data[11,] # Odstraneni prvnich 11 radku quits_data <- quits_data[-1:-11,] # Zmena typu data a formatu dat z sirokeho na dlouhy quits_data <- quits_data %>% gather(Month, Quits_Rate, Jan:Dec, factor_key= FALSE) %>% na.omit() %>% mutate(Year = as.integer(Year), Quits_Rate = as.numeric(Quits_Rate)) %>% mutate(Date = paste(Month, " ", Year)) %>% mutate(Date = myd(Date, truncated = 1)) %>% select(Date, Quits_Rate) %>% arrange(Date) # Spojeni obou souboru dat do jednoho mydata <- quits_data %>% inner_join(unmpl_data, by = "Date") # Vizualizace dat ----------------------------------------------- library(scales) # Vytvoreni grafu mydata %>% ggplot(aes(x = Date)) + geom_line(aes(y = Quits_Rate, color = "Míra dobrovolné odchodovosti (po statistickém odstranění sezónní komponenty)"), size = 1.5) + geom_line(aes(y = Unmpl_Rate/5, color = "Míra nezaměstnanosti (po statistickém odstranění sezónní komponenty)"), size = 1.5) + scale_y_continuous(sec.axis = sec_axis(~.*5, name = "Měsíční míra nezaměstnanosti [%]")) + scale_x_date(date_breaks = "12 months", labels=date_format("%m/%y")) + scale_colour_manual(values = c("#0A9BD0", "orange")) + theme(axis.text.x = element_text(angle = 45, hjust = 1), legend.position="bottom", legend.direction = "vertical") + labs(title = "Vztah mezi mírou nezaměstnanosti a mírou dobrovolné odchodovosti", subtitle = "Údaje pro USA mezi lety 2001 a 2018", caption = "Zdroj dat: Bureau of Labor Statistics", y = "Měsíční míra dobrovolné odchodovosti [%]", x = "Měsíc a rok", colour = "") # Korelace mezi mirou nezamestnanosti a dobrovolne odchodovosti zamestnancu # cor.test(mydata$Quits_Rate, mydata$Unmpl_Rate)$estimate # cor.test(mydata$Quits_Rate, mydata$Unmpl_Rate)$p.value ``` Vzhledem k palčivosti tohoto problému, který trápí nejednu firmu, není žádným velkým překvapením, že se tématu odchodovosti zaměstnanců věnovalo a stále věnuje velké množství různých studií. Takto např. na konci roku 2017 vyšla rozsáhlá meta-analýza od autorů [Rubensteina, Eberlyové a Leeho](https://onlinelibrary.wiley.com/doi/abs/10.1111/peps.12226), kteří syntetizovali výsledky více než 300 dílčích výzkumů týkajících se prediktorů odchodovosti. Můžeme se tak oprávněně ptát, co nového nám může přinést HR analytika zaměřená na odchodovost zaměstnanců realizovaná pouze v jediné organizaci. Nebylo vše podstatné k tomuto tématu již objeveno? (K této otázce viz např. [tento inspirativní a trochu provokativní článek](https://www.linkedin.com/pulse/predicting-turnover-stating-bleedin-obvious-hedegaard-rasmussen/) od [Thomase Rasmussena](https://www.linkedin.com/in/thomas-hedegaard-rasmussen-9b26043/).) Je pravda, že není příliš pravděpodobné, že při analýze vašich vlastních dat narazíte na nějaký naprosto nový faktor související s odchodovostí. Na druhou stranu je rovněž pravda, že každá organizace je v něčem jedinečná, takže některé z retenčních faktorů pro danou organizaci budou pravděpodobně více a jiné méně důležité. Tato informace o relativní důležitosti jednotlivých retenčních faktorů je potom klíčová při nastavování retenčního plánu a HR analytika může být při tomto velice nápomocná. ## Na důkazech založená pro-retenční opatření S pomocí [tohoto dashboardu](https://aanalytics.shinyapps.io/turnover_analytics_dashboard/) - vytvořeného prostřednictvím [analytického nástroje R](https://www.r-project.org/) a [vizualizačního nástroje Shiny](https://shiny.rstudio.com/) a za využití [ukázkových dat od společnosti IBM](https://www.ibm.com/communities/analytics/watson-analytics-blog/hr-employee-attrition/) - si můžete sami vyzkoušet, jak užitečné by pro Vás mohly být výstupy z takového HR analytického projektu zaměřeného na odchodovost zaměstnanců. Dashboard obsahuje informace, které pomáhají (nejen) managementu zodpovědět řadu klíčových otázek, které stojí na počátku každého účinného plánu na retenci zaměstnanců, jako např.: * Kolik zaměstnanců nás ročně opouští? * Které skupiny zaměstnanců odcházejí nejčastěji? * Jaký je externí benchmark? Jsme na tom podobě jako konkurence v oboru? * Představuje pro nás stávající úroveň odchodovosti závažný problém, a vyplatí se nám ho tedy řešit? * Z jakých důvodů lidé obecně nejčastěji odcházejí ze zaměstnání? * Jaké faktory přispívají k odchodu specificky našich zaměstnanců? * Jaká pro-retenční opatření jsou obecně k dispozici? * Jaká pro-retenční opatření bychom měli zvolit vzhledem k pravděpodobným důvodům odchodů našich zaměstnanců? * Na jaké skupiny zaměstnanců se především zaměřit z hlediska prevence jejich odchodovosti? * U kterých konkrétních zaměstnanců existuje zvýšené riziko, že odejdou, a na jaké konkrétní retenční faktory se u nich zaměřit v rámci pravidelného stay interview? Jak je z výše uvedeného výčtu otázek patrné, dashboard obsahuje informace, které při svém rozhodování mohou využít **nejen HR manažeři, ale také HR business partneři nebo přímo team-leadeři a linioví manažeři** jednotlivých týmů či oddělení. Kromě toho dashboard obsahuje také řadu technických detailů o použitém predikčním modelu a samotná data, které stojí v pozadí všech prezentovaných vizualizací a analýz. S jejich pomocí tak **HR/Business analytik** může např. hledat optimální způsob, jak nastavit skórovací algoritmus, aby se maximalizoval pozitivní efekt pro-retenčních opatření, nebo může v dostupných datech sám hledat nějaké další užitečné informace. Více viz již [samotný dashboard](https://aanalytics.shinyapps.io/Turnover_Analytics_Dashboard_Demo/), z něhož můžete níže vidět několik screenshotů. *Screenshot části dashboardu, která obsahuje různé řezy odchodovostí zaměstnanců, a dává tak dobrý přehled o tom, které skupiny zaměstnanců jsou odchodovostí nejvíce ohrožené.* ![](./hr-analytika-a-odchodovost-zamstnanc/DSHB1.png) *Screenshot části dashboardu, která obsahuje informace o pravděpodobnosti odchodu jednotlivých zaměstnanců společně s dalšími informacemi, které mohou posloužit jako podklad pro individuální intervence s cílem předejít nežádoucím odchodům zaměstnanců.* ![](./hr-analytika-a-odchodovost-zamstnanc/DSHB2.png) *Screenshot části dashboardu, která obsahuje informace o výkonu/kvalitě statistického modelu použitého k identifikaci významných prediktorů odchodovosti zaměstnanců a k odhadu pravděpodobnosti odchodu jednotlivých zaměstnanců.* ![](./hr-analytika-a-odchodovost-zamstnanc/DSHB3.png) ## Figures ![](./hr-analytika-a-odchodovost-zamstnanc/unnamed-chunk-2-1.png) --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2018-10-14-hr-analytika-a-odchodovost-zamstnanc/) on my blog. --- # Moneyball v HR URL: https://lstehlik2809.github.io/people-analytics-second-brain/posts/moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt Date: 2018-10-11 Tags: employee-selection, statistics, regression-analysis, structural-equation-modeling, r Original post: https://blog-about-people-analytics.netlify.app/posts/2018-10-11-moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/ ```r # Nastaveni ceskeho jazyka Sys.setlocale("LC_CTYPE", "czech") ``` HR analytika už dnes není ve světě HR žádnou horkou novinkou. Téměř všichni z oboru už o HR analytice něco slyšeli, něco o ní vědí a případně se jí už také pokouší ve svých organizacích v nějaké podobě zavádět. Zároveň většinou uznávají její důležitost při transformaci HR z podpůrné a administrativní funkce na funkci, která dokáže organizacím bezprostředně pomáhat dosahovat jejich strategických cílů. Navzdory tomuto všeobecnému povědomí o HR analytice a navzdory řadě úspěšně realizovaných HR analytických projektů (viz např. série článků od [Davida Greena](https://www.linkedin.com/in/davidrgreen/) - [článek 1](https://www.linkedin.com/pulse/20-people-analytics-case-studies-part-1-david-green/?trk=mp-author-card), [článek 2](https://www.linkedin.com/pulse/20-people-analytics-case-studies-part-2-david-green/?published=t), [článek 3](https://www.linkedin.com/pulse/20-more-people-analytics-case-studies-part-i-david-green/), [článek 4](https://www.linkedin.com/pulse/20-people-analytics-case-studies-part-ii-david-green/?published=t&lipi=urn%3Ali%3Apage%3Ad_flagship3_pulse_read%3BFsCNnpkoSuyb52Adn9lUhw%3D%3D)) překvapivě málo organizací HR analytiku reálně a systematicky využívá. Tento stav reflektují i výsledky výzkumu [2018 Human Capital Trends](https://www2.deloitte.com/cz/en/pages/human-capital/articles/globalni-trendy-v-oblasti-lidskeho-kapitalu-pro-rok-2018.html) od společnosti Deloitte, ze kterých vyplývá, že organizace si většinou uvědomují strategickou důležitost výzvy, kterou představuje datifikace HR, zároveň se ale necítí být na čelení této výzvě příliš dobře připraveny. Už nějakou dobu platí, že když už se v organizaci s HR daty nějak pracuje, tak je to většinou pouze na úrovni nějakého základního reportingu vybraných HR metrik a KPIs typu náklady na nábor, délka období neobsazenosti volné pracovní pozice, míra ne/dobrovolné odchodovosti zaměstnanců, počet zaměstnanců na jednoho HR business partnera apod. Slabinou tohoto přístupu je, že takto sledované metriky jsou často relevantní pouze pro monitorování a řízení efektivnosti HR coby nákladového střediska, ale již méně pro dosahování strategických cílů organizace. Spíše výjimečně se potom v tomto kontextu využívají nějaké pokročilejší analytiky, které obecně mají větší potenciál přicházet s doporučeními s přímým dopadem na schopnost organizací dosahovat svých strategických cílů. Výsledky [výzkumu provedeného společnostmi MIT Sloan Management Review a SAS](https://sloanreview.mit.edu/projects/the-hard-work-behind-data-analytics-strategy/) naznačují, že tento nevyužitý potenciál HR analytiky má dvě hlavní příčiny. První z nich je to, že tradiční HR mnohdy postrádá analytický mindset a některé z kompetencí, které jsou klíčové pro úspěšnou realizaci HR analytických projektů (přehled těchto kompetencí a důsledků jejich absence či nedostatečné úrovně viz např. [tento článek](https://mortenkamp.com/2016/06/06/six-must-have-competencies-in-a-world-class-analytics-team/) od [Mortena Kamp Andersena](https://www.linkedin.com/in/mortenkampandersen/)). Ve stejném duchu [Josh Bersin](https://www.linkedin.com/in/bersin/) ve své zprávě [HR Technology Disruptions for 2018](http://marketing.bersin.com/rs/976-LMP-699/images/HRTechDisruptions2018-Report-100517.pdf) konstatuje, že zvládnutí základních analytických dovedností patří mezi nejdůležitější prediktory efektivní implementace HR analytiky v organizacích: "*Equip all HR staff with basic data literacy skills. All HR practitioners should know basic statistical concepts, where to find data, how to slice and dice it, how to read a dashboard, and how to bring data and analytics to bear on business issues. Our research reveals that such basic skills are among the most important predictors of high-performing people analytics.*" Druhou hlavní příčinou je potom to, že HR analytické projekty nebývají ukotveny v rámci nějaké širší strategie, jak data systematicky využívat při řízení lidských zdrojů, navíc způsobem, který by byl sladěný se strategickými cíli společnosti. Zde platí praxí osvědčená pravda projektového managementu, že při implementaci projektů je potřeba vždy začínat od konce. V kontextu HR analytických projektů to tedy znamená začínat nikoli od dat, ale od toho, k čemu mají být HR analytické výstupy použity. A očekávání managementu je, že HR analytika bude v posledku hlavně pomáhat zlepšovat obchodní výsledky společnosti. Názorně to ilustruje níže uvedené schéma (převzaté z [článku](https://www.hrzone.com/lead/strategy/why-hr-needs-to-up-its-game-in-strategic-people-analytics) [Maxe Blumberga](https://www.linkedin.com/in/maxblumberg/)), které zachycuje předpokládaný kauzální řetězec spojující HR procesy s obchodními výsledky. Úkolem HR analytiky je potom s pomocí dat a analytických nástrojů tyto dvě oblasti propojit a zjistit, jak optimalizací prvního zajistit zlepšení toho druhého. ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/HRValueChain.png) Řadě organizací by v tomto ohledu mohl být inspirací známý příběh [oaklandského baseballového týmu „Áček“](https://en.wikipedia.org/wiki/Oakland_Athletics), který se stal předlohou pro [knihu Moneyball](https://en.wikipedia.org/wiki/Moneyball) a z ní vycházející stejnojmenný [film](https://www.csfd.cz/film/255150-moneyball/prehled/). Právě tento příběh jako jeden z prvních ukázal a mezi širokou veřejností zpopularizoval možnosti využití statistické analýzy ve světě sportu a potažmo také v rámci řízení lidských zdrojů. Díky radikální změně dosavadního přístupu k výběru nových hráčů, který se začal více opírat o výstupy statistické analýzy [sabermetrických](https://en.wikipedia.org/wiki/Sabermetrics) dat o herním chování hráčů, dokázal management oaklandského baseballového týmu „Áček“ přijímat rozhodnutí, která z jednoho z nejchudších týmů americké baseballové ligy učinila jeden z nejúspěšnějších týmů soutěže (měřeno počtem vítězství v základní části soutěže a počtem postupů do play-off). Abychom mohli tento příběh plně vytěžit coby inspiraci, jak analyzovat svá vlastní zaměstnanecká data, bude užitečné, když se na jednotlivé analytické kroky, které stály v pozadí úspěchu oklandských "Áček", podíváme trochu podrobněji. A učiníme tak za využití volně dostupného [statistického softwaru R](https://www.r-project.org/) a veřejně dostupné [databáze historických údajů o výsledcích v americké baseballové lize](https://drive.google.com/file/d/0B5YUO0Asd9KfMG1UaXpEVjJqQWc/view). ## 1. krok: Začít od konce aneb strategický rámec HR analytiky Jak bylo uvedeno výše, často podceňovaným krokem při zavádění HR analytiky do firem a organizací je zasazení HR analytiky do nějakého širšího strategického rámce, ze kterého by jasně vyplývalo, čemu má vlastně HR analytika sloužit. HR analytika je pouze nástroj, konkrétně nástroj na zodpovídání otázek, resp. na testování různých hypotéz. To, zda bude tento nástroj užitečný, závisí na tom, zda si dokážeme klást ty správné otázky. To je přitom z velké části dáno tím, zda si jsme vědomi, jaké jsou strategické cíle naší organizace. Jen ve světle těchto cílů dává smysl klást si nějaké otázky, sbírat a analyzovat nějaká data za účelem nalezení odpovědí na položené otázky a posléze činit nějaká konkrétní rozhodnutí na základě nalezených odpovědí. V případě oaklandských „Áček“ byl cíl jasný – kvalifikovat se do play-off. ## 2. krok: Definice problému a kvantifikace cíle [Paul DePodesta](https://en.wikipedia.org/wiki/Paul_DePodesta), kterého generální manažer oaklandských „Áček“ [Billy Beane](https://en.wikipedia.org/wiki/Billy_Beane) přijal do týmu jako statistického analytika, redukoval tento cíl na celkem jednoduchý matematický problém: Kolik zápasů musí tým vyhrát, aby se kvalifikoval do play-off? K zodpovězení této otázky DePodesta potřeboval historická data o počtu vítězství jednotlivých týmů v minulých sezónách a o tom, zda se jim podařilo postoupit do play-off, či nikoli. ```r # Načtěme si knihovnu, která nám umožní si načíst a předpřipravit data k analýze a také je i vizualizovat. library(tidyverse) # Načteme si naše data. baseball <- read_csv("baseball.csv") # Pro získání lepší představy o nich se podívejme na jejich prvních deset řádků. head(baseball, 10) # Pro následující analýzy si potom vytvořme podmnožinu dat, která měli k dispozici v Oaklandu v roce 2002, kdy se děj Moneyballu převážně odehrává. moneyball <- baseball %>% filter(Year < 2002) ``` Podíváme-li se na data mezi lety 1996–2001, tj. na data z relativně nedávné minulosti (vztaženo k roku 2002, kdy se děj Moneyballu převážně odehrává), z grafického vyjádření vztahu mezi počtem vítězství v základní částí soutěže a postupem do play-off je dobře patrné, že čím více zápasů tým vyhraje v základní soutěži, tím větší je šance, že se také dostane do play-off. ```r # Vytvořme si graf zachycující vztah mezi počtem vítězství v základní části soutěže a postupem do play-off moneyball %>% filter(Year < 2002 & Year > 1995) %>% select (W, Playoffs) %>% mutate(rnd = runif(176,0,1)) %>% ggplot(aes(x = W, y = rnd, color = as.factor(Playoffs)))+ geom_point(size = 2)+ scale_x_continuous(limits=c(50,120), breaks = seq(50,120,5))+ scale_color_manual(values = c("#9e9e9e", "#ff1919"), labels = c("Tým nepostoupil do play-off","Tým postoupil do play-off"))+ ggtitle("Postupy týmů do play-off mezi lety 1996-2001")+ ylab("")+ xlab("Počet vítězství v základní části soutěže")+ theme(legend.position = "bottom", axis.ticks.y = element_blank(), axis.text.y = element_blank(), axis.text.x = element_text(size=11), axis.title.x = element_text(size=11), legend.text = element_text(size=11), legend.title = element_blank()) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-3-1.png) S daty, která máme k dispozici, máme tu výhodu, že můžeme vztah mezi počtem vítězství v základní části soutěže a šancí na postup do play-off přesně kvantifikovat. Provedeme-li podrobnější analýzu našich dat, ukáže se, že velkou (přibližně 95%) šanci na postup do play-off má tým tehdy, když v základní části vyhraje minimálně 95 zápasů. Těchto 95 vítězství představuje dobře definovaný a kvantifikovaný cíl, kterého by se oaklandská „Áčka“ měla snažit dosáhnout. ```r # Vyfiltrujme si opět data mezi lety 1996-2001. moneyball2 <- moneyball %>% filter(Year < 2002 & Year > 1995) # Vytvořme si seznam několika různých hodnot počtu vítězství v základní části soutěže. pocet_vitezstvi <- seq(60,115,5) ucast_v_playoff <- vector(mode="numeric", length=length(pocet_vitezstvi)) playoff_data <- data.frame(pocet_vitezstvi = pocet_vitezstvi, ucast_v_playoff = ucast_v_playoff) # Vypočtěme si, jaká je pravděpodobnost postupu do play-off při různém počtu vítězství v základní části soutěže. for(i in 1:nrow(playoff_data)){ playoff_data$ucast_v_playoff[i] <- length(moneyball2$W[moneyball2$W >= playoff_data$pocet_vitezstvi[i] & moneyball2$Playoffs == 1])/length(moneyball2$W[moneyball2$W >= playoff_data$pocet_vitezstvi[i]]) } # A nyní si vztah mezi počtem vyhraných zápasů v základní části soutěže a pravděpodobností účasti v play-off vizualizujme. ggplot(playoff_data, aes(x = pocet_vitezstvi, y = ucast_v_playoff))+ geom_point(size = 2)+ geom_line()+ ggtitle("Souvislost mezi počtem výher v základní části soutěže a\npravděpodobností postupu týmu do play-off (1996-2001)")+ ylab("Pravděpodobnost postupu týmu do play-off")+ xlab("Počet vítězství v základní části soutěže")+ scale_x_continuous(limits=c(60,115), breaks = seq(60,115,5))+ scale_y_continuous(limits=c(0,1), breaks = seq(0,1,0.1))+ theme(axis.text = element_text(size=11), axis.title = element_text(size=11)) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-4-1.png) ## 3. krok: Kladení otázek a měření S takto definovaným a kvantifikovaným cílem si potom můžeme klást dalších otázky, na které když si dokážeme odpovědět, zvýšíme tím naše šance na to, že tohoto cíle dosáhneme. V případě oaklandských „Áček“ se můžeme ptát, díky čemu tým dosahuje v zápasech vítězství? Celkem zjevná odpověď zní, že díky tomu, že dokáže získat více bodů než jeho soupeři. Otázkou ale je, přesně o kolik bodů navíc musí tým získat, aby v základní části soutěže dosáhl na minimálně 95 vítězství. K zodpovězení této otázky opět potřebujeme historická data (údaje o vyhraných a prohraných bodech) a relativně jednoduchý statistický model zvaný [lineární regrese](https://cs.wikipedia.org/wiki/Lineární_regrese), pomocí kterého můžeme popsat vztah mezi počtem vyhraných zápasů v základní části soutěže a rozdílem mezi vyhranými a prohranými body. Z níže uvedeného grafu je zřejmé, že mezi těmito dvěma proměnnými je velice těsný vztah a že spolu velice silně [korelují](https://cs.wikipedia.org/wiki/Korelace). ```r # Vypočtěme si rozdíl mezi vyhranými a prohranými body moneyball <- moneyball %>% mutate(RD = RS - RA) # Graficky si znázorněme vztah mezi počtem vyhraných zápasů v základní části soutěže a rozdílem mezi vyhranými a prohranými body library(ggpubr) ggplot(moneyball, aes(x = RD , y = W))+ geom_point(alpha = 0.5, size = 2)+ geom_smooth(method = "lm", se = FALSE)+ ggtitle("Vztah mezi počtem vítězství v základní části soutěže a\nrozdílem mezi vyhranými a prohranými body")+ xlab("Rozdíl mezi počtem vyhraných a prohraných bodů")+ ylab("Počet vítězství")+ theme(axis.title = element_text(size = 11), axis.text = element_text(size = 11))+ scale_x_continuous(limits = c(-350,350), breaks = seq(-350,350,50))+ scale_y_continuous(limits = c(40, 120), breaks = seq(40,120,10))+ stat_cor(method = "pearson", label.x = 175, label.y = 45) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-5-1.png) Při použití modelu lineární regrese můžeme vztah mezi těmito dvěma proměnnými popsat trochu podrobněji. ```r # Regresní analýza vztahu mezi mezi počtem vyhraných zápasů v základní části soutěže a rozdílem mezi vyhranými a prohranými body reg_model1 <- glm(W ~ RD, data = moneyball, family = "gaussian") summary(reg_model1) ``` Výsledná regresní rovnice nám říká, že *očekávaný počet vítězství = 80.88 + 0.106 x Rozdílový skór*. Tzn., že při vyrovnaném poměru vyhraných a prohraných bodů můžeme očekávat, že tým vyhraje přibližně 80 zápasů za sezónu, a že když se rozdílové skóre navýší o deset bodů, můžeme očekávat, že tým vyhraje v průměru o jeden zápas za sezónu navíc. Klíčové je ale pro nás to, že s pomocí této rovnice a s trochou algebry si můžeme jednoduše vypočítat, že k dosažení minimálně 95 vítězství za sezónu potřebuje tým vyhrát přibližně o 133 bodů více, než kolik jich se soupeři prohraje (*(95 - 80.88) / 0.106*). ## 4. krok: Kladení dalších otázek a další měření Tímto zjištěním se náš cíl opět trochu více specifikuje a vyvolává další otázky. Otázka, která se téměř sama nabízí, se týká charakteristik hráčů, které nejlépe předpovídají počet vyhraných a prohraných bodů, a tím tedy také pravděpodobnost postupu týmu do play-off. DePodesta na základě svých analýz zjistil, že počet vyhraných bodů nejtěsněji souvisí s procentem případů, kdy se hráč dostane na metu (tzv. *On-Base Percentage* - OBP), a to, jak daleko se hráč dostane při svém odpalu (tzv. *Slugging Percentage* - SLG). Analogické statistiky pro týmy soupeřů (OOBP a OSLG) potom stejně dobře předpovídají počet prohraných bodů. Když vztah mezi těmito proměnnými popíšeme opět pomocí modelu lineární regrese, můžeme se s jeho pomocí pokusit předpovědět, jak si tým povede příští sezónu. Taková předpověď by přitom mohla být potenciálně velice užitečná, protože na jejím základě bychom případně mohli upravit některá svá rozhodnutí o koupi nebo prodeji vybraných hráčů. Pojďme tuto předpověď vytvořit pro tým oaklandských „Áček“ pro sezónu 2002 na základě dat z let 1962-2001. Z předchozí analýzy již víme, že… *Počet vítězství = 80.88 + 0.106 x (Počet vyhraných bodů - Počet prohraných bodů)*. Nyní potřebujeme určit, jaký bude pravděpodobný počet vyhraných a prohraných bodů. Pomůžeme si opět regresní analýzou. ```r # Regresní analýza vztahu mezi mezi počtem vyhraných bodů v základní části soutěže a dvěma vybranými hráčskými/týmovými statistikami regModel2 = lm(RS ~ OBP + SLG, data=moneyball) summary(regModel2) # Regresní analýza vztahu mezi mezi počtem prohraných bodů v základní části soutěže a dvěma vybranými hráčskými/týmovými statistikami regModel3 = lm(RA ~ OOBP + OSLG, data=moneyball) summary(regModel3) ``` S pomocí regresní analýzy jsme zjistili, že… *Počet vyhraných bodů = -804.63 + 2737.77 x OBP + 1584.91 x SLG* *Počet prohraných bodů = -837.38 + 2913.60 x OOBP + 1514.29 x OSLG*. Se znalostí hráčských/týmových statistik oaklandských „Áček“ za rok 2001 se nyní můžeme pokusit předpovědět nejdříve počet vyhraných a prohraných bodů a potom také předpokládaný počet vítězství v základní části soutěže. Při formulování této předpovědi vycházíme z předpokladu, že se složení týmu v průběhu sezóny 2002 nebude (např. z důvodu zranění hráčů) příliš lišit od jeho složení v roce 2001. ```r # Hráčské/týmové statistiky oaklandských „Áček“ za rok 2001 OBP_OAK <- moneyball$OBP[which(moneyball$Team=="OAK" & moneyball$Year == 2001)] SLG_OAK <- moneyball$SLG[which(moneyball$Team=="OAK" & moneyball$Year == 2001)] OOBP_OAK <- moneyball$OOBP[which(moneyball$Team=="OAK" & moneyball$Year == 2001)] OSLG_OAK <- moneyball$OSLG[which(moneyball$Team=="OAK" & moneyball$Year == 2001)] # Pravděpodobné hodnoty vybraných statistik oaklandských "Áček" pro rok 2002 vypočítané s pomocí odhadnutých regresních modelů pocet_vyhranych_bodu_pred <- round(-804.63 + 2737.77*OBP_OAK + 1584.91*SLG_OAK) pocet_prohranych_bodu_pred <- round(-837.38 + 2913.60*OOBP_OAK + 1514.29*OSLG_OAK) pocet_vitezstvi_pred <- round(80.88 + 0.106 * (pocet_vyhranych_bodu_pred - pocet_prohranych_bodu_pred), 0) # Skutečné hodnoty vybraných statistik oaklandských "Áček" pro rok 2002 pocet_vyhranych_bodu_real <- baseball$RS[which(baseball$Team=="OAK" & baseball$Year == 2002)] pocet_prohranych_bodu_real <- baseball$RA[which(baseball$Team=="OAK" & baseball$Year == 2002)] pocet_vitezstvi_real <- baseball$W[which(baseball$Team=="OAK" & baseball$Year == 2002)] # Tabulka porovnávající statistické předpovědi se skutečností pred <- c(pocet_vyhranych_bodu_pred, pocet_prohranych_bodu_pred, pocet_vitezstvi_pred) real <- c(pocet_vyhranych_bodu_real, pocet_prohranych_bodu_real, pocet_vitezstvi_real) table <- data.frame("Předpověd" = pred, "Skutečnost" = real) row.names(table) <- c("Vyhrané body", "Prohrané body", "Počet vítězství") table ``` Porovnání našich předpovědí s reálnými výsledky za sezónu 2002 ukazuje, že se nám podařilo velice přesně předpovědět výsledky v nadcházející ligové sezóně, a významně tak snížit míru naší nejistoty při jejím plánování. ## 5. krok: Propojení dílčích vhledů aneb organizace jako stroj [Matt Dancho](https://www.linkedin.com/in/mattdancho/) ve své metodice k datově-analytickým projektům doporučuje, abychom se při snaze o pochopení obchodního problému organizace na danou organizaci dívali jako na druh stroje, který má určité vstupy, procesy a výstupy. Tuto metaforu stroje můžeme nyní využít k tomu, abychom všechny výše uvedené dílčí vhledy spojili do jednotného rámce. V něm budou mít oaklandská "Áčka" podobu jednoduchého stroje na výrobu postupů do play-off - viz obrázek níže. ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/machine_producing_playoffs.png) Ze schématu je dobře patrné, jak tento stroj funguje: Jeho výstupy jsou postupy do play-off, kterých dosahuje tak, že se snaží vyhrát více zápasů, resp. získat více bodů než soupeřící týmy; k tomu využívá vstupy v podobě schopnosti hráčů hrát dobře na pálce a v poli; vstupem ovlivňujícím chod stroje jsou rovněž obdobné schopnosti hráčů soupeřících týmů. Jedná se samozřejmě o velmi zjednodušený kauzální model fungování týmu oakladnských "Áček", ale jak konstatuje slavný statistický aforismus, [modely jsou vždy nepřesné, ale některé z nich jsou užitečné](https://en.wikipedia.org/wiki/All_models_are_wrong). Jakkoli naše modely fungování organizace budou vždy neúplné, je důležité ověřit, zda tyto modely i přes svou omezenost v dostatečné míře odrážejí realitu tak, jak nám ji zprostředkovávají dostupná data. Za tímto účelem můžeme použít statistickou metodu [strukturálního modelování](https://en.wikipedia.org/wiki/Structural_equation_modeling), která umožňuje formalizovat naše představy o vzájemných vztazích mezi několika různými proměnnými a zhodnotit míru souladu těchto našich představ s dostupnými daty. Teprve po takovém zhodnocení věrohodnosti modelu je rozumné na něm zakládat svá další rozhodnutí. Pojďme tedy tuto metodu použít rovněž na náš nově vytvořený model fungování týmu oaklandských "Áček" a ověřit míru jeho věrohodnosti. ```r # Data, která budeme potřebovat pro ověření věrohodnosti našeho modelu fungování oaklandských "Áček" sem_data <- moneyball %>% filter(Year < 2002 & Year > 1995) %>% select(RS, RA, RD, W, Playoffs, OBP, SLG, OOBP, OSLG) # Definice modelu, která je v souladu s výše uvedeným schématem library(lavaan) oak_model <- ' Playoffs ~ W W ~ RS + RA RA ~ OOBP + OSLG RS ~ OBP + SLG ' # Odhad parametrů modelu fit_oak_model <- sem(oak_model, data = sem_data, missing = "pairwise", estimator = "WLSMV", ordered = "Playoffs") summary(fit_oak_model, standardized = T, fit.measures = T, rsq = T) ``` ```r # Grafické znázornění modelu fungování oaklandských "Áček" library(semPlot) semPaths(fit_oak_model, whatLabels="std", intercepts=FALSE, style="lisrel", nCharNodes=0, nCharEdges=0, curveAdjacent = TRUE, title=TRUE, layout="tree2", curvePivot=TRUE, rotation =3) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-10-1.png) Výstupy provedené tzv. [pěšinkové analýzy](https://en.wikipedia.org/wiki/Path_analysis_(statistics)), která je speciálním typem strukturálního modelování, naznačují, že námi navržený model je v souladu s daty, která máme k dispozici (viz "příznivé" hodnoty indexů shody, resp. neshody jako je TLI a CFI, resp. RMSEA, a také vysoké hodnoty standardizovaných regresních koeficientů). Dávají nám tak dobrý důvod věřit, že naše další kroky a rozhodnutí, která založíme na tomto modelu, budou mít žádoucí efekt na požadované výstupy, tj. na postup oaklandských "Áček" do play-off. ## 6. krok: Intervence Na základě výše uvedených zjištění začal management oaklandských „Áček“ do svého týmu vybírat hráče, kteří sice nevyhovovali tradičním kritériím, podle kterých hráčští skauti posuzovali kvalitu baseballových hráčů, ale za to vykazovali přesně ty charakteristiky, které podle DePodestových analýz předpovídaly počet vyhraných a prohraných bodů, a potažmo tedy také pravděpodobnost účasti v play-off, která byla hlavním cílem managementu. Díky tomu, že konkurenční týmy důležitost těchto hráčských statistik podceňovaly a naopak přeceňovaly jiné, méně důležité proměnné (např. míru úspěšnosti odpalů, tzv. *Batting Average*), mohl management oaklandských „Áček“ relativně levně skupovat hráče, kteří jim umožňovali dosahovat stanoveného cíle. Výsledkem bylo to, že oaklandská „Áčka“ vyhrávala zhruba o 20 zápasů za sezónu více než stejně „chudé“ týmy a přibližně stejně tolik zápasů jako 2krát až 3krát bohatší konkurence - viz graf níže. ```r # Načtěme si potřebná data Lahmanovy baseballové databáze, která je veřejně přístupná na adrese http://seanlahman.com/baseball-archive/statistics/ mzdyHracu <- read_csv("salaries.csv") vyhryTymu <- read_csv("teams.csv") # Vypočtěme si průměrnou sumu mezd vyplácených jednotlivými týmy svým hráčům v letech 1998-2001 prumerna_suma_MezdHracu <- mzdyHracu %>% filter(yearID > 1997 & yearID < 2002) %>% group_by(teamID) %>% summarise(prumerna_suma_MezdHracu = sum(salary)/length(unique(yearID))) # Vypočtěme si pro jednotlivé týmy průměrný počet výher za sezónu v letech 1998-2001 prumerny_pocet_vyher <- vyhryTymu %>% filter(yearID > 1997 & yearID < 2002) %>% group_by(teamID) %>% summarise(prumerny_pocet_vyher = sum(W)/length(unique(yearID))) # Vyjádřeme si graficky vztah mezi počtem výher a množstvím peněz, které týmy vynakládají na mzdy svých hráčů library(ggrepel) prumerna_suma_MezdHracu %>% left_join(prumerny_pocet_vyher, "teamID") %>% mutate(OAK = ifelse(teamID == "OAK", "ano", "ne")) %>% ggplot(aes(x= prumerna_suma_MezdHracu, y = prumerny_pocet_vyher, fill = OAK)) + geom_point()+ ggtitle("Mzdy hráčů a počet vítězství v letech 1998-2001")+ xlab("Průměrná suma mezd hráčů (USD)")+ ylab("Průměrný počet výher za sezónu")+ geom_label_repel( aes(label = teamID), box.padding = 0.25, point.padding = 0.25, segment.color = 'grey50')+ theme(legend.position="none")+ scale_fill_manual(values = c("#ffd400", "#ffffff"), labels = c("ano","ne"))+ scale_y_continuous(limits=c(65,100), breaks = seq(65,100,5)) + scale_x_continuous(limits=c(2e+07,9e+07), breaks = seq(2e+07,9e+07,1e+07)) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-11-1.png) ## Omezení HR analytiky Přes veškerou přidanou hodnotu, kterou HR analytika pro organizaci může mít, je vhodné si vůči ní zachovat zdravou míru skepse a být si vědom jejích omezení. Níže uvádím přehled několika z nich. * Kvalita a užitečnost výstupů HR analytiky je závislá na kvalitě dat, která do ní vstupují. Jako kdekoli jinde i zde platí okřídlené rčení „*rubbish in, rubbish out*“. Schopnost získat potřebná data včas, v dostatečné kvalitě a v dostatečném množství přitom představuje jedno z nejužších hrdel celého procesu zavádění HR analytiky v organizacích. * HR analytika pracuje s historickými daty a vychází z předpokladu, že minulost je dobrým prediktorem budoucnosti. Ale jak nás na to opakovaně upozorňují odborníci jako [Nassim Taleb](https://en.wikipedia.org/wiki/Nassim_Nicholas_Taleb) nebo [Philip Tetlock](https://en.wikipedia.org/wiki/Philip_E._Tetlock), tento vztah mezi minulostí a budoucností platí pouze do určité míry a pouze v relativně krátkém časovém horizontu. Na každém rohu na nás číhá nějaká potenciální [černá labuť](https://en.wikipedia.org/wiki/The_Black_Swan:_The_Impact_of_the_Highly_Improbable), která může postavit na hlavu všechno, co jsme se na základě našich minulých zkušeností naučili brát jako samozřejmou jistotu. * Ne každé prostředí je stejně předvídatelné jako svět sportu. Poměr [signálu a šumu](https://www.kosmas.cz/knihy/191522/signal-a-sum/) se může napříč různými oblastmi významně lišit a čím více převládá náhodný šum nad signálem, tím méně jsou výstupy z HR analytiky užitečné. Příkladem zde může být relativně neúspěšná snaha předpovídat to, jak si baseballové týmu povedou v play-off. Na rozdíl od základní části soutěže, kde se hraje dostatek zápasů na to, aby se vyrušil vliv náhodného štěstí a smůly, v pětizápasových kolech play-off hraje náhoda tak významnou roli, že souvislost mezi celkovým počtem vítězství v základní části a pořadím týmu v play-off je téměř nulová. ```r # Vyfiltrujme si data mezi lety 1994-2011, kdy v play-off hraje 8 týmů. moneyball3 <- moneyball %>% filter(Year < 2012 & Year > 1993) # Výpočtěme si Kendallovu pořadovou korelaci mezi mezi celkovýmm počtem vítězství v základní části soutěže a pořadím týmu v play-off mezi lety 1994-2011. suppressWarnings(cor.test(~ W + RankPlayoffs, data = moneyball3, method = "kendall")) ``` * Čísla mají tu zvláštní moc, že dokážou v člověku velice snadno vzbudit dojem, že toho víme mnohem více než je tomu ve skutečnosti. Je však dobré si být vědom toho, že každá statistická předpověď je vždy zatížena nějakou mírou chyby, tu větší, tu menší. Velkou výhodou statistických modelů je to, že tato chyba je u nich explicitně vyčíslena, takže s ní lze dopředu počítat a zohlednit ji při následném rozhodování. Tato „upřímnost“ ohledně své vlastní omylnosti paradoxně mnohdy staví statistické modely do horšího světla než jinak méně přesné intuitivní úsudky expertů, pro které podobné údaje o míře jejich omylnosti většinou nejsou vůbec k dispozici. * Velikost výhody, kterou nám zavedení HR analytiky dává, může být závislá na tom, zda podobné postupy využívá také naše konkurence. Opět to lze celkem dobře doložit na oaklandských „Áčkách“. Jejich výsledky se mezi lety 2002 až 2012, tj. v době po zveřejnění Moneyballu, kdy již všechny týmy měly příležitost seznámit se s principy prediktivní analytiky a zavést ji do své praxe, začaly více přibližovat výsledkům podobně „chudých“ soupeřů a naopak jejich bohatší soupeři jim svým výkonem zase trochu odskočili - viz graf níže. Z toho mimo jiné vyplývá, že s tím, jak se stále více společností bude při řízení lidských zdrojů spoléhat na výstupy z HR analytiky, přestane být HR analytika nějakou zásadní konkurenční výhodou a stane se z ní něco, co organizaci "pouze" umožní držet krok s konkurencí. ```r # Vypočtěme si průměrnou sumu mezd vyplácených jednotlivými týmy svým hráčům v letech 2002-2012 prumerna_suma_MezdHracu2 <- mzdyHracu %>% filter(yearID > 2001 & yearID <= 2012) %>% group_by(teamID) %>% summarise(prumerna_suma_MezdHracu = sum(salary)/length(unique(yearID))) # Vypočtěme si pro jednotlivé týmy průměrný počet výher za sezónu v letech 2002-2012 prumerny_pocet_vyher2 <- vyhryTymu %>% filter(yearID > 2001 & yearID <=2012) %>% group_by(teamID) %>% summarise(prumerny_pocet_vyher = sum(W)/length(unique(yearID))) # Vyjádřeme si graficky vztah mezi počtem výher a množstvím peněz, které týmy vynakládají na mzdy svých hráčů prumerna_suma_MezdHracu2 %>% left_join(prumerny_pocet_vyher2, "teamID") %>% mutate(OAK = ifelse(teamID == "OAK", "ano", "ne")) %>% ggplot(aes(x= prumerna_suma_MezdHracu, y = prumerny_pocet_vyher, fill = OAK)) + geom_point()+ ggtitle("Mzdy hráčů a počet vítězství v letech 2002-2012")+ xlab("Průměrná suma mezd hráčů (USD)")+ ylab("Průměrný počet výher za sezónu")+ geom_label_repel( aes(label = teamID), box.padding = 0.25, point.padding = 0.25, segment.color = 'grey50')+ theme(legend.position="none")+ scale_fill_manual(values = c("#ffd400", "#ffffff"), labels = c("ano","ne")) + scale_y_continuous(limits=c(65,100), breaks = seq(65,100,5)) + scale_x_continuous(limits=c(3e+07,2e+08), breaks = seq(3e+07,2e+08,2e+07)) ``` ![](./moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/unnamed-chunk-13-1.png) ## Závěr Na příkladu oaklandského baseballového mužstva jsme takto mohli sledovat obvyklý postup aplikace HR analytiky na určitý druh problému, který se snaží v dané organizaci vyřešit. Vzhledem ke specifickému předmětu podnikání oaklandských „Áček“ bylo tímto cílem dosáhnout postupu do play-off a to v situaci, kdy management neměl dostatek finančních prostředků na zaplacení hráčů považovaných dle tradičních měřítek za kvalitní a perspektivní. Od tohoto cíle se potom odvíjela řada kroků, které blíže specifikovaly jeho povahu a identifikovaly faktory (mimo jiné i ty personální), které s jeho dosažením souvisí. Na základě této znalosti potom bylo možné formulovat určité předpovědi a učinit jistá rozhodnutí, která zvýšila pravděpodobnost toho, že se podaří vytčeného cíle dosáhnout. Přestože tento příběh o využití HR analytiky se odehrál ve světě sportu, jeho logika je platná i v kontextu tradičnějšího typu organizací. Ostatně **ve všech typech organizací jde nakonec především o to mít na správném místě a ve správný čas ty správné lidi** - jedině tak tyto organizace mohou systematicky dosahovat svých strategických cílů. --- > 📄 Read the [original post with full outputs](https://blog-about-people-analytics.netlify.app/posts/2018-10-11-moneyball-v-hr-od-hr-analytiky-ke-sportovn-analytice-a-zpt/) on my blog.