Deneb Examples - Sentiment Analysis

Deneb/Vega-Lite can be used for sentiment analysis in Power BI. Rating scales (aka Likert Scales) are often visualized in Power BI as ranged stacked bar charts, but multiple scales and the separation of neutrals can present challenges. The example presented herein separates neutrals, doesn’t use a single scale, and doesn’t use hard-coded colours; instead, the colours are set based on the input data. This resulted in difficulties generating a consistent legend, so a layered visual consisting of circle and text marks was used instead. (I recognize and am not happy with the choice spacing in the legend, but decided to release anyway and leave this as a future enhancement.)

This example was inspired by the Deneb visual demonstrated by Daniel Marsh-Patrick (@dm-p) as part of one of last years’ DiscoverEI sessions

and by examples posted on the Vega-Lite website

The example also uses the new button slicer in the November 2023 release of Power BI Desktop (version 2.123.684.0). (Note: while full formatting was available and used for the category (“Values”), the font size/weight/style options for the subcategory (“Labels”), although selected, were not implemented - future releases of Power BI Desktop will hopefully resolve this shortcoming.)

This example illustrates a number of Deneb/Vega-Lite features, including:
0 - General:

  • use of a “title” block with title and subtitle
  • use of a “transform” block to extend the dataset with:
    • in-visual calculations for the category total (using the “joinaggregate” transform) and category percent
    • in-visual calculations for the X coordinates of the category choice bars (using the “stack” transform)
  • use of a “params” block to enhance the dataset with:
    • variables for the sentiment class title and underline colours
  • use of a top-level concatenation block for the custom legend and the sentiment bar charts

1 - Legend:

  • use of a “transform” block to extend the dataset with:
    • in-visual calculations for the X coordinates for the legend choices
  • use of a “layer” block
    • a “circle” mark for the legend choice shape using colour from the dataset
    • a “text” mark for the legend choice label

2 - Sentiments:

  • use of a nested horizontal concatenation block for the non-neutral (negative, positive) and neutral bar charts

3 - Non-neutral Bar Chart:

  • use of a nested “layer” block with:
    • a “filter” transform to retain only sentiment choices that are non-neutral (negative, positive)
  • a “bar” mark for the negative and positive category choice blocks with:
    • a fixed scale (-100% to 100%)
    • category labels inside the scale to save space
    • bar colours from the dataset
    • custom tooltips for the category, choice, and %
  • a “text” mark for the negative and positive category choice percent labels with:
    • a “filter” transform to remove label values below 5% (for space reasons)
    • label colours from the dataset
  • a nested “layer” block with:
    • a “filter” transform to remove all but a single row
    • a vertical “rule” mark for the Y=0 constant line
    • 2x “text” marks with hard-coded positions for the negative and positive sentiment titles labels
      • using the sentiment class title colour parameter variable
    • 2x horizontal “rule” marks for the negative and positive sentiment title underlines
      • using the sentiment class title underline colour parameter variable

4 - Neutral Bar Chart:

  • use of a nested “layer” block with:
    • a “filter” transform to retain only sentiment choices that are neutral
  • a “bar” mark for the category choice block with:
    • a fixed scale (0% to 100%)
    • hidden category labels (as they’re already shown on the non-neutral bar chart)
    • bar colours from the dataset
    • custom tooltips for the category, choice, and %
  • a “text” mark for the neutral category choice percent labels with:
    • a “filter” transform to remove label values below 5% (for space reasons)
    • label colours from the dataset
  • a nested “layer” block with:
    • a “filter” transform to remove all but a single row
    • a “text” mark with hard-coded positions for the neutral sentiment title label
      • using the sentiment class title colour parameter variable
    • a horizontal “rule” mark for the neutral sentiment title underline
      • using the sentiment class title underline colour parameter variable
Deneb/Vega-Lite JSON Code
{
  "title": {
    "anchor": "start",
    "align": "left",
    "text": "Power BI Sentiment Analysis using Deneb",
    "color": "black",
    "font": "Verdana",
    "fontSize": 28,
    "fontWeight": "bold",
    "fontStyle": "normal",
    "subtitle": "Data Source: ficticious vehicle satisfaction survey data",
    "subtitleColor": "#969696",
    "subtitleFont": "Verdana",
    "subtitleFontSize": 14,
    "subtitleFontWeight": "normal",
    "subtitleFontStyle": "italic",
    "offset": 20
  },
  "data": {"name": "dataset"},
  "transform": [
    {
      "filter": "datum['Count of Responses'] != null"
    },
    {
      "calculate": "pad( datum['Question ID'], 2, '0', 'left') + '-' + datum['Category']",
      "as": "_category_label"
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "Count of Responses",
          "as": "_category_total"
        }
      ],
      "groupby": ["Category"]
    },
    {
      "calculate": "100 * datum['Count of Responses'] / datum['_category_total']",
      "as": "_category_percent"
    },
    {
      "calculate": "datum['_category_percent'] / 100",
      "as": "_category_percent_of_1"
    },
    {
      "calculate": "if( datum['Choice Class Detail'] == 'Negative', datum['_category_percent'], 0 )",
      "as": "_negative_percent"
    },
    {
      "stack": "_category_percent",
      "as": ["_v1", "_v2"],
      "groupby": ["Category"],
      "sort": [
        {
          "field": "Choice Legend Order",
          "order": "ascending"
        }
      ]
    },
    {
      "joinaggregate": [
        {
          "field": "_negative_percent",
          "op": "sum",
          "as": "_offset"
        }
      ],
      "groupby": ["Category"]
    },
    {
      "calculate": "if( datum['Choice Class'] == 'Neutral', datum['_category_percent'], 0 )",
      "as": "_neutral_percent"
    },
    {
      "joinaggregate": [
        {
          "field": "_neutral_percent",
          "op": "sum",
          "as": "_neutral_adjustment_base"
        }
      ],
      "groupby": ["Category"]
    },
    {
      "calculate": "datum['Choice'] == 'Yes' ? 0 : datum['Choice Class Detail'] == 'Positive' ? datum['_neutral_adjustment_base'] : 0",
      "as": "_neutral_adjustment"
    },
    {
      "calculate": "datum['_v1'] - datum['_offset'] - datum['_neutral_adjustment']",
      "as": "_nx"
    },
    {
      "calculate": "datum['_v2'] - datum['_offset'] - datum['_neutral_adjustment']",
      "as": "_nx2"
    }
  ],
  "params": [
    {
      "name": "_sentiment_underline_colour",
      "value": "#C9C9C9"
    },
    {
      "name": "_sentiment_colour",
      "value": "#969696"
    }
  ],
  "vconcat": [
    {
      "name": "LEGEND",
      "height": 10,
      "width": 1200,
      "transform": [
        {
          "calculate": "( datum['Choice Legend Order'] - 1 ) * 70",
          "as": "_legend_shape_x"
        }
      ],
      "layer": [
        {
          "name": "LEGEND_SHAPES",
          "mark": {
            "type": "circle",
            "size": 200
          },
          "encoding": {
            "x": {
              "field": "_legend_shape_x",
              "type": "quantitative",
              "scale": {
                "domainMax": 450
              },
              "axis": null
            },
            "color": {
              "field": "Choice Bar Colour",
              "type": "nominal",
              "scale": null
            }
          }
        },
        {
          "name": "LEGEND_LABELS",
          "mark": {
            "type": "text",
            "align": "left",
            "baseline": "middle",
            "xOffset": 10,
            "yOffset": 1,
            "color": "#969696"
          },
          "encoding": {
            "x": {
              "field": "_legend_shape_x",
              "type": "quantitative"
            },
            "text": {
              "field": "Choice",
              "type": "nominal"
            }
          }
        }
      ]
    },
    {
      "hconcat": [
        {
          "name": "NON-NEUTRAL",
          "layer": [
            {
              "name": "NON-NEUTRAL_BAR",
              "width": 800,
              "height": {"step": 60},
              "transform": [
                {
                  "filter": "datum['Choice Class'] != 'Neutral'"
                }
              ],
              "mark": {
                "type": "bar",
                "tooltip": true
              },
              "encoding": {
                "x": {
                  "field": "_nx",
                  "type": "quantitative",
                  "title": "Percentage",
                  "scale": {
                    "domain": [
                      -100,
                      100
                    ]
                  }
                },
                "x2": {"field": "_nx2"},
                "y": {
                  "field": "_category_label",
                  "type": "nominal",
                  "axis": {
                    "title": null,
                    "labelFontSize": 14,
                    "labelExpr": "substring( datum.value, 3, 100 )",
                    "offset": 4,
                    "ticks": false,
                    "grid": false,
                    "domain": false,
                    "labelAlign": "left",
                    "labelBaseline": "middle",
                    "labelPadding": -10,
                    "labelOffset": 0
                  }
                },
                "color": {
                  "field": "Choice Bar Colour",
                  "type": "nominal",
                  "scale": null
                },
                "tooltip": [
                  {
                    "field": "Category",
                    "type": "nominal"
                  },
                  {
                    "field": "Choice",
                    "type": "nominal"
                  },
                  {
                    "field": "_category_percent_of_1",
                    "type": "quantitative",
                    "format": ".1%",
                    "title": "% of Responses"
                  }
                ]
              }
            },
            {
              "name": "NON-NEUTRAL_LABEL",
              "width": 800,
              "height": {"step": 60},
              "transform": [
                {
                  "filter": "datum['Choice Class'] != 'Neutral'"
                },
                {
                  "filter": "datum['_category_percent'] >= 5"
                }
              ],
              "mark": {
                "type": "text",
                "align": "left",
                "xOffset": 4
              },
              "encoding": {
                "text": {
                  "field": "_category_percent_of_1",
                  "type": "quantitative",
                  "format": ".0%"
                },
                "x": {
                  "field": "_nx",
                  "type": "quantitative"
                },
                "y": {
                  "field": "_category_label",
                  "type": "nominal"
                },
                "color": {
                  "field": "Choice Label Colour",
                  "type": "nominal",
                  "scale": null
                }
              }
            },
            {
              "name": "NON-NETURAL_SINGLE_MARKS",
              "transform": [
                {
                  "filter": "datum['__row__'] == 0"
                }
              ],
              "layer": [
                {
                  "name": "NON-NEUTRAL_VERTICAL_ZERO_RULE",
                  "mark": {
                    "type": "rule",
                    "color": "black"
                  },
                  "encoding": {
                    "x": {"datum": 0}
                  }
                },
                {
                  "name": "NEGATIVE_HORIZONTAL_RULE",
                  "mark": {
                    "type": "rule",
                    "strokeWidth": 4,
                    "color": {
                      "expr": "_sentiment_underline_colour"
                    }
                  },
                  "encoding": {
                    "x": {
                      "datum": -100
                    },
                    "x2": {"datum": -1},
                    "y": {"value": -10}
                  }
                },
                {
                  "name": "POSITIVE_HORIZONTAL_RULE",
                  "mark": {
                    "type": "rule",
                    "strokeWidth": 4,
                    "color": {
                      "expr": "_sentiment_underline_colour"
                    }
                  },
                  "encoding": {
                    "x": {"datum": 1},
                    "x2": {
                      "datum": 100
                    },
                    "y": {"value": -10}
                  }
                },
                {
                  "name": "NEGATIVE_TITLE",
                  "mark": {
                    "type": "text",
                    "font": "Segoe UI",
                    "fontSize": 20,
                    "color": {
                      "expr": "_sentiment_colour"
                    }
                  },
                  "encoding": {
                    "text": {
                      "value": "Negative"
                    },
                    "x": {"datum": -50},
                    "y": {"value": -24}
                  }
                },
                {
                  "name": "POSITIVE_TITLE",
                  "mark": {
                    "type": "text",
                    "font": "Segoe UI",
                    "fontSize": 20,
                    "color": {
                      "expr": "_sentiment_colour"
                    }
                  },
                  "encoding": {
                    "text": {
                      "value": "Positive"
                    },
                    "x": {"datum": 50},
                    "y": {"value": -24}
                  }
                }
              ]
            }
          ]
        },
        {
          "name": "NEUTRAL",
          "layer": [
            {
              "name": "NEUTRAL_BAR",
              "width": 400,
              "height": {"step": 60},
              "transform": [
                {
                  "calculate": "datum['_nx2'] + 2",
                  "as": "_nx2"
                },
                {
                  "filter": "datum['Choice Class'] == 'Neutral' && datum['_nx2'] > 0"
                }
              ],
              "mark": {
                "type": "bar",
                "tooltip": true
              },
              "encoding": {
                "x": {
                  "field": "_category_percent",
                  "type": "quantitative",
                  "title": "Percentage",
                  "sort": {
                    "op": "min",
                    "field": "Choice ID",
                    "order": "ascending"
                  },
                  "scale": {
                    "domain": [0, 100]
                  }
                },
                "y": {
                  "field": "_category_label",
                  "type": "nominal",
                  "axis": null
                },
                "color": {
                  "field": "Choice Bar Colour",
                  "type": "nominal",
                  "scale": null
                },
                "tooltip": [
                  {
                    "field": "Category",
                    "type": "nominal"
                  },
                  {
                    "field": "Choice",
                    "type": "nominal"
                  },
                  {
                    "field": "_category_percent_of_1",
                    "type": "quantitative",
                    "format": ".1%",
                    "title": "% of Responses"
                  }
                ]
              }
            },
            {
              "name": "NEUTRAL_LABEL",
              "width": 800,
              "height": {"step": 60},
              "transform": [
                {
                  "filter": "datum['Choice Class'] == 'Neutral'"
                },
                {
                  "filter": "datum['_category_percent'] >= 5"
                }
              ],
              "mark": {
                "type": "text",
                "align": "left",
                "xOffset": 4
              },
              "encoding": {
                "text": {
                  "field": "_category_percent_of_1",
                  "type": "quantitative",
                  "format": ".0%"
                },
                "x": {
                  "field": "_nx",
                  "type": "quantitative"
                },
                "y": {
                  "field": "_category_label",
                  "type": "nominal",
                  "axis": {
                    "title": null,
                    "labelFontSize": 14,
                    "labelExpr": "substring( datum.value, 3, 100 )",
                    "offset": 4,
                    "ticks": false,
                    "domain": false
                  }
                },
                "color": {
                  "field": "Choice Label Colour",
                  "type": "nominal",
                  "scale": null
                }
              }
            },
            {
              "name": "NETURAL_SINGLE_MARKS",
              "transform": [
                {
                  "filter": "datum['__row__'] == 0"
                }
              ],
              "layer": [
                {
                  "name": "NEUTRAL_HORIZONTAL_RULE",
                  "mark": {
                    "type": "rule",
                    "strokeWidth": 4,
                    "color": {
                      "expr": "_sentiment_underline_colour"
                    }
                  },
                  "encoding": {
                    "x": {"datum": 1},
                    "x2": {
                      "datum": 100
                    },
                    "y": {"value": -10}
                  }
                },
                {
                  "name": "NEUTRAL_TITLE",
                  "mark": {
                    "type": "text",
                    "font": "Segoe UI",
                    "fontSize": 20,
                    "color": {
                      "expr": "_sentiment_colour"
                    }
                  },
                  "encoding": {
                    "text": {
                      "value": "Neutral"
                    },
                    "x": {"datum": 50},
                    "y": {"value": -24}
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

The intent of this example is not to provide a finished visual, but rather to serve as a starting point for further custom visual development.

Also included is the development sample PBIX using data originally sourced from Kaggle; the public job satisfaction survey data (https://www.kaggle.com/datasets/annettecatherinepaul/likert-survey-for-job-satisfaction-psc) was heavily modified to use a fictitious set of vehicle satisfaction questions and filtered to 1000 records.

This example is provided as-is for information purposes only, and its use is solely at the discretion of the end user; no responsibility is assumed by the author.

Greg
Deneb Examples - Sentiment Analysis.pbix (1.5 MB)

2 Likes

marking as solved

1 Like