Using R to Analyze Zoom Recordings

UPDATE: 2020-04-14: I added a new function (textConversationAnalysis) that gives some very basic and descriptive conversation metrics from either the video transcript or the chat file.

You can always access the most recent version of the functions by including the statement source(“http://apknight.org/zoomGroupStats.R”) at the top of your code.

Alternatively, you could go to http://apknight.org/zoomGroupStats.R and copy/paste the code into your editor.

and copy/paste the code into your editor.

ORIGINAL POST FOLLOWS:

In response to the shift to so many online meetings, I created a set of R functions to help do research using web-based meetings. In brief, these functions use the output of recorded sessions (e.g., video feed, transcript file, chat file) to do things like sentiment analysis, face analysis, and emotional expression analysis. In the coming week, I will extend these to do basic conversation analysis (e.g., who speaks/chats most, turntaking).

I went overboard in commenting the code so that hopefully others can use them. But, if you’re still having trouble getting them to work, please don’t hesitate to reach out to me.

You can directly access the functions here:
http://apknight.org/zoomGroupStats.R

After reviewing this, you could call these functions using the following statement in R: source(“http://apknight.org/zoomGroupStats.R”)

############################################################
# Author:           Andrew Knight (http://apknight.org)
   
# Last Update:      2020-04-14 13:00 US CDT
# Update Note:      Added the textConversationAnalysis function

# I created this as a way to help people do social science research through web-based meetings (i.e., Zoom).
# It's still a work in progress, but this is a start. If you would like to use it or help build it,
# please reach out!


############################################################
# OVERVIEW OF FUNCTIONS
############################################################
# This script contains functions to use for analyzing recorded Zoom sessions
# This is a work in progress and more are coming.

# processZoomChat           Parses the downloaded chat file from a recorded Zoom session
# processZoomTranscript     Parses the downloaded transcript from a recorded Zoom session
# sentiOut                  Conducts a sentiment analysis on either the Chat or Transcript
# videoFaceAnalysis         Analyzes the video from a Zoom session and outputs face/emotion measures
# textConversationAnalysis  Analyzes either chat or transcript and outputs conversation metrics

# Note you will require the following packages to run these:
# reshape2
# stringr
# paws
# magick
# data.table

# You will also require an aws account with privileges for rekognition and comprehend to use the
# text analysis and video analysis. If you don't know how to do this, please:
# Search online for (a) setting up AWS account; (b) setting up paws. I found the following useful:
# https://github.com/paws-r/paws/blob/master/docs/credentials.md

# If, after you try you are still struggling, I can give guidance on this if useful--just contact me.

############################################################
# processZoomChat Function
############################################################

# Zoom Chat File Processing
# This function parses the data from the chatfile that is downloaded from the Zoom website.
# NOTE: This is the file that accompanies a recording. This is not the file
# that you download directly within the window. It is also not the one that is
# saved locally on your computer. This is the file that you can access after a session
# if you record in the cloud.

# example call:             ch.out = processZoomChat(fname="~/Desktop/chat.txt", sessionStartDateTime="2020-04-01 17:56:34", languageCode="en")

# INPUT ARGUMENTS:
# fname:                    the path to the local file where the chat file (txt) is saved.
# sessionStartDateTime:     the time that the actual session was launched. Format is YYYY-MM-DD HH:MM:SS
# languageCode:             the code for the language (e.g., en)

# OUTPUT:
# message_id:               an incremented numeric identifier for a message in the chat
# message_time:             a timestamp for the message, based on the start of the Zoom session
# user_name:                the name attached to the message
# message:                  the text of the message
# message_language:         the language code for the chat

processZoomChat = function(fname, sessionStartDateTime, languageCode) {

    require(reshape2)
    require(stringr)

    ch = read.delim(fname, sep="\t", stringsAsFactors=F, header=F, col.names=c("message_increment", "user_name", "message"))

    ####################################
    # First thing do to is to create a message_time variable

    # This is user-supplied and could come from the usermeeting report that can be downloaded
    sessionStartDateTime = as.POSIXct(sessionStartDateTime)

    # This is the value embedded in the chat record. It is an HH:MM:SS delta from the start of the session
    # I'm doing a crude parse to just get the total number of seconds that the delta is. This is then
    # used as the increment from the sessionStartDateTime
    ch$inc_hours = as.numeric(substr(ch$message_increment,1,2))
    ch$inc_mins = as.numeric(substr(ch$message_increment,4,5)) 
    ch$inc_secs = as.numeric(substr(ch$message_increment,7,8))     
    ch$inc_total_secs = ch$inc_hours*60*60 + ch$inc_mins*60 + ch$inc_secs
    ch$message_time = sessionStartDateTime + ch$inc_total_secs

    ####################################
    # Chat transcripts do not handle soft returns well (i.e., if the same person uses a soft line break
    # for multiple lines in a single message that is submitted to the system).
    # This is a crude way to identify them based on someone having an invalid message time.
    # For now, will assign that text to the last marked user name in the dataset,
    # pasting the messages together into a single line (separated by a space. )

    # Create a flag to mark erroneous records based on the message time variable. This should be made stronger
    # and cleaner eventually
    ch$flag = is.na(as.integer(substr(ch$message_increment,1,1))) + (nchar(ch$message_increment) != 8)

    # Assign the value in the message_increment variable to the message variable. This is because
    # the parsing of the file is fucked up when there are soft returns in someone's chat message
    ch$message = ifelse(ch$flag > 0, ch$message_increment, ch$message)

    # Go through the records from the bottom up to paste the message on the one it
    # should be part of
    for(i in nrow(ch):1) {
        if(ch[i,"flag"] > 0) {
            ch[(i-1), "message"] = paste(ch[(i-1), "message"], ch[i, "message"], sep=" ")
        }
    }

    # now drop the unnecessary records
    ch = ch[ch$flag == 0, ]

    # get rid of whitespace at the beginning and end
    ch$message = str_trim(ch$message, "both")

    # Add a language variable, which is user-supplied for now
    ch$message_language = languageCode

    # Add a simple numeric incrementing identifier for the messages that people submitted
    ch$message_id = 1:nrow(ch)

    # Get rid of the superfluous colon at the end of the usernames
    ch$user_name = substr(ch$user_name, 1, nchar(ch$user_name)-1)

    # Clean up the ordering of variables that are returned
    ch = ch[,c("message_id", "message_time", "user_name", "message", "message_language")]

    return(ch)
}

############################################################
# processZoomTranscript Function
############################################################

# Zoom Recording Transcript File Processing
# This function parses the data from the transcript file (.vtt) that is downloaded from the Zoom website.
# NOTE: This is the file that accompanies a recording to the cloud.

# example call:             tr.out = processZoomTranscript(fname="~/Desktop/transcript.vtt", recordingStartTime="2020-04-01 17:56:34", languageCode="en")

# INPUT:
# fname:                    the path to the local file where the transcript file (vtt) is saved.
# recordingStartDateTime:   the time that the recording was launched. Format is YYYY-MM-DD HH:MM:SS
# languageCode:             the code for the language (e.g., en)

# Note: I plan to fix at a later point in time the timing issue. Specifically, it is not clear
# where in Zoom's system I can get the actual time that the recording was started. This
# is a problem for linking the transcript file up with the chat file.
# One workaround for now (for research) would be to set recordings to auto-start. This is not ideal, though.
# we should be able to know when the recording was started. It is embedded in the video, so could pull from there.

# OUTPUT:
# utterance_id:             an incremented numeric identifier for a marked speech utterance
# utterance_start_time:     the timestamp for the start of the utterance
# utterance_end_time:       the timestamp for the end of the utterance
# utterance_time_window:    the number of seconds that the utterance took
# user_name:                the name attached to the utterance
# utterance_message:        the text of the utterance
# utterance_language:       the language code for the transcript

processZoomTranscript = function(fname, recordingStartDateTime, languageCode) {
    library(reshape2)
    require(stringr)

    # Parse the transcript file -- vtt is a structured format.
    f = readLines(fname)
   
    # there are three main pieces of data for each marked "utterance" - an id, a window of time, and the text
    utterance_id = as.integer(f[seq(3,length(f), 4)])
    utterance_window = f[seq(4,length(f), 4)]
    utterance_text = f[seq(5,length(f), 4)]

    # Parse the time window into two separate elements
    utterance_start_time = unlist(strsplit(utterance_window, " --> "))[seq(1, length(utterance_window)*2, 2)]
    utterance_end_time = unlist(strsplit(utterance_window, " --> "))[seq(2, length(utterance_window)*2, 2)]

    # Just a little function to do this X 2
    timeCalc = function(startTime, incTime) {
        inc_hours = as.numeric(substr(incTime,1,2))
        inc_mins = as.numeric(substr(incTime,4,5)) 
        inc_secs = as.numeric(substr(incTime,7,12))    
        inc_total_secs = inc_hours*60*60 + inc_mins*60 + inc_secs
        correct_time = startTime + inc_total_secs
    }

    # Now turn these into actual datetime values
    recordingStartDateTime = as.POSIXct(recordingStartDateTime)
    utterance_start_time = timeCalc(recordingStartDateTime, utterance_start_time)
    utterance_end_time = timeCalc(recordingStartDateTime, utterance_end_time)  

    # Create a time window (in seconds) for the utterances -- how long is each in seconds
    utterance_time_window = as.numeric(difftime(utterance_end_time, utterance_start_time, units="secs"))

    # Parse the utterance message itself
    utterance_message = substring(utterance_text, regexpr("[:]", utterance_text)+2)

    # Get the user name that spoke the text
    user_name = substr(utterance_text, 1, regexpr("[:]", utterance_text)-1)

    # Prepare the output file
    res.out = data.frame(utterance_id, utterance_start_time, utterance_end_time, utterance_time_window, user_name, utterance_message, stringsAsFactors=F)

    # Add the language code
    res.out$utterance_language = languageCode

    return(res.out)
}

############################################################
# sentiOut Function
############################################################
# Text-based sentiment analysis function
# This function takes in the output of the chat and transcript functions. It then
# conducts a sentiment analysis on an identified chunk of text
# and returns the values.
# To use this function, you must have an aws account that with privileges for the comprehend service
# However you authenticate for AWS, you should do so before running the function.

# example call:         sent.out = sentiOut(inputData=tr.out, idVar = "utterance_id", textVar = "utterance_message", languageCodeVar = "utterance_language")

# INPUT:
# inputData:            the input data frame
# idVar:                the name of the id variable for the text
# textVar:              the name of the variable with the text in it
# languageCodeVar:      the variable containing the language code for each text chunk

# OUTPUT: This function returns the inputData plus the following variables
# sent_class:           the text-based sentiment classification of the message
# Mixed:                the confidence level for the text being mixed sentiment
# Negative:             the confidence level for the text being negative sentiment
# Neutral:              the confidence level for the text being neutral sentiment
# Positive:             the confidence level for the text being positive sentiment

# Note: This function currently does this in a brute force way. In the future, I will
# build this so that it batches chunks of text to run, rather than looping through.

sentiOut = function(inputData, idVar, textVar, languageCodeVar){
    require(paws)
    require(reshape2)
    # Identify the AWS service comprehend:
    # AS STATED ABOVE--YOU MUST HAVE AN AUTHENTICATED ACCOUNT WITH THE RIGHT PRIVILIGES
    svc = comprehend()

    # Loop through each record of the inputData
    for(i in 1:nrow(inputData)) {

        # Run the sentiment detection function from AWS Comprehend on this chunk of text
        sent = svc$detect_sentiment(Text = inputData[i,textVar], LanguageCode=inputData[i,languageCodeVar])

        # Create a simple
        res.line = cbind(inputData[i,idVar],unlist(sent$SentimentScore), sent$Sentiment)
        if(i == 1) {
            res.out = res.line
        } else {
            res.out = rbind(res.out, res.line)
        }      
    }

    # Now, clean up the output so that it comes as a dataframe
    d.res = data.frame(res.out, stringsAsFactors=F)
    names(d.res) = c(idVar, "sent_value", "sent_class")
    d.res$sent_type = unlist(lapply(strsplit(row.names(d.res), '[.]'), '[[',1))

    d.res.melt = reshape2::melt(d.res, idVars=c(idVar, sent_class, "sent_type"), variable.name="sent_variable", value.name="sent_value")

    d.res.wide = reshape2::dcast(d.res.melt, get(idVar) + sent_class ~ sent_type, value.var="sent_value")
    names(d.res.wide)[1] = idVar

    d.res.wide[,c("Mixed", "Negative", "Neutral", "Positive")] = lapply(d.res.wide[,c("Mixed", "Negative", "Neutral", "Positive")], as.numeric)

    d.mrg = merge(inputData, d.res.wide, by=idVar, all.x=T)
    return(d.mrg)
}


############################################################
# videoFaceAnalysis Function
############################################################
# Video-based sentiment analysis function
# This function takes in a video file and produces measures based on the faces in the
# video. Note that this is a very crude way of doing this. It uses the image detection
# capabilities in AWS rekognition to cut the video up in to a sample of frames, then
# analyzes those frames. rekognition has a video analysis feature that I'll incorporate
# later.

# For best results with recorded Zoom sessions:
# I would recommend going into your settings for recordings and choosing to
# record active speaker, gallery view, and shared screen separately.
# Make sure to choose to record the gallery view so that you get
# all of the faces in a single video feed. You also might want to choose "Optimize the recording..."

# To use this function, you must have an aws account that with privileges for the rekognition service
# However you authenticate for AWS, you should do so before running the function.

# example call:                 vid.out = videoFacesAnalysis(inputData="~/Desktop/sample_video.mp4", recordingStartTime="2020-04-01 17:56:34", sampleWindow=20, facesCollectionID="class15-r")

# INPUT ARGUMENTS:
# inputVideo:                   the input video file
# recordingStartDateTime:       the name of the id variable for the text
# sampleWindow:                 the number of seconds in between each sample of the recording
# facesCollectionID:            Not necessary: Name of an S3 collection if you want to ID specific people

# OUTPUT:
# frame_id                      an identifier for the frame of the video used for this record
# img_timestamp                 the timestamp of the image from the video (see note below re: recording)
# identified_person             the name of the person identified in the frame, if a collection is given
# identification_confidence     the confidence level for the identity (first one)
# face_id                       an identifier for the face in the frame
# age_low                       low boundary for estimated age
# age_high                      high boundary for estimated age
# smile                         boolean - does the face have smile
# eyeglasses                    boolean - does the face have eyeglasses
# sunglasses                    boolean - does the face have sunglasses
# gender                        gender of face
# beard                         boolean - does the face have beard
# mustache                      boolean - does the face have mustache
# eyesopen                      boolean - does the face have eyes open
# mouthopen                     boolean - does the face have mouth open
# confused                      confidence level for the face showing confused
# calm                          confidence level for the face showing calm
# happy                         confidence level for the face showing happy
# disgusted                     confidence level for the face showing disgusted
# angry                         confidence level for the face showing angry
# fear                          confidence level for the face showing fear
# sad                           confidence level for the face showing sad
# surprised                     confidence level for the face showing surprised

# Note: This function currently es things in a brute force way. I'll refine this so that
# things are processed in batch, rather than in so many gross loops.

# Note: Same as with transcripts: I plan to fix at a later point in time the timing issue. Specifically, it is not clear where in Zoom's system I can get the actual time that the recording was started. This
# is a problem for linking the transcript file up with the chat file.
# One workaround for now (for research) would be to set recordings to auto-start. This is not ideal, though.
# we should be able to know when the recording was started. It is embedded in the video, so could pull from there.

videoFaceAnalysis = function(inputVideo, recordingStartDateTime, sampleWindow, facesCollectionID=NA) {
    require(paws)
    require(magick)
    svc = rekognition()

    recordingStartDateTime = as.POSIXct(recordingStartDateTime)

    # This reads in the stills from the video. This would output one image every 60 seconds.
    imagesFromVideo = image_read_video(inputVideo, fps=(1/sampleWindow))

    # Create a directory structure to hold some temporary image files.
    va_temp_dir = paste(dirname(inputVideo),"videoFaceAnalysis_temp", sep="/")
    dir.create(va_temp_dir)

    base_name = strsplit(basename(inputVideo), ".", fixed=T)[[1]][[1]]
    img_temp_dir =  paste(dirname(inputVideo),"videoFaceAnalysis_temp", base_name, sep="/")
    dir.create(img_temp_dir)

    # This now goes through each of the images that was extracted from the video. For each, it writes it to disk and gets the face details that are in the image (if there are any)
    # Note: This is clunky and it would be better to just put them in the Amazon collection straight away. Will do that when have more time.
    df.o = list()
    inf = list()
    for(videoCounter in 1:length(imagesFromVideo)) {

        # This puts the timestamp on this clip
        img_timestamp = recordingStartDateTime + (videoCounter-1)*sampleWindow

        # Write the image to the temporary directory that was created
        image_write(imagesFromVideo[videoCounter], paste(img_temp_dir,"/","img_",videoCounter,".png", sep=""), format="png")

        # Get the information about the file (this is used later for face analysis)
        inf[[videoCounter]] = image_info(imagesFromVideo[videoCounter])    

        # Detect faces in this frame
        df.o[[videoCounter]] = svc$detect_faces(Image=list(Bytes=paste(img_temp_dir, "/", "img_",videoCounter,".png", sep="")), Attributes="ALL")

        # Get the details of any faces detected in this frame
        faces = df.o[[videoCounter]]$FaceDetails

        # If there are no faces in the image, then create a blank results record, with just the image id
        if(length(faces) == 0) {
            res.line = matrix(nrow=1,ncol=23)
            res.line[1,1] = paste(base_name, "-","img_", videoCounter, sep="") 
            res.line[1, 21] = img_timestamp        
        } else {
        # Otherwise, if there are faces in the image, go through each face to get its info 
            # create a matrix to hold the info
            res.line = matrix(nrow=length(faces), ncol=23)

            # Loop through each face and analyze it
            for(face.num in 1:length(faces)) {
                fd = faces[[face.num]]
                res.line[face.num,1] = paste(base_name, "-","img_", videoCounter, sep="")                  
                res.line[face.num,2] = face.num
                res.line[face.num,3] = fd$AgeRange$Low
                res.line[face.num,4] = fd$AgeRange$High
                res.line[face.num,5] = fd$Smile$Value
                res.line[face.num,6] = fd$Eyeglasses$Value
                res.line[face.num,7] = fd$Sunglasses$Value
                res.line[face.num,8] = fd$Gender$Value
                res.line[face.num,9] = fd$Beard$Value
                res.line[face.num,10] = fd$Mustache$Value
                res.line[face.num,11] = fd$EyesOpen$Value      
                res.line[face.num,12] = fd$MouthOpen$Value     

                # Make an emotions table for this image
                for(e in fd$Emotions) {

                    if(e$Type == "CONFUSED") res.line[face.num,13] = e$Confidence
                    else if(e$Type == "CALM") res.line[face.num,14] = e$Confidence
                    else if(e$Type == "HAPPY") res.line[face.num,15] = e$Confidence
                    else if(e$Type == "DISGUSTED") res.line[face.num,16] = e$Confidence
                    else if(e$Type == "ANGRY") res.line[face.num,17] = e$Confidence
                    else if(e$Type == "FEAR") res.line[face.num,18] = e$Confidence
                    else if(e$Type == "SAD") res.line[face.num,19] = e$Confidence
                    else if(e$Type == "SURPRISED") res.line[face.num,20] = e$Confidence    
                }
                res.line[face.num, 21] = img_timestamp

                # if the user specified a face collection, go into it to see if the face has an identity
                # Including the confidence value because it sometimes couldn't tell it was a face
                # at low levels of confidence
                if(!is.na(facesCollectionID) && fd$Confidence > 90) {

                    # Identify the coordinates of the face. Note that AWS returns percentage values of the total image size. This is
                    # why the image info object above is needed
                    box = fd$BoundingBox
                    image_width=inf[[videoCounter]]$width
                    image_height=inf[[videoCounter]]$height
                    x1 = box$Left*image_width
                    y1 = box$Top*image_height
                    x2 = x1 + box$Width*image_width
                    y2 = y1 + box$Height*image_height  

                    # Crop out just this particular face out of the video
                    img.crop = image_crop(imagesFromVideo[videoCounter], paste(box$Width*image_width,"x",box$Height*image_height,"+",x1,"+",y1, sep=""))
                    img.crop = image_write(img.crop, path = NULL, format = "png")
                   
                    # Search in a specified collection to see if we can label the identity of the face is in this crop
                    faceRec = svc$search_faces_by_image(CollectionId=facesCollectionID,Image=list(Bytes=img.crop), FaceMatchThreshold=70)      

                    if(length(faceRec$FaceMatches) > 0) {
                        res.line[face.num, 22] = faceRec$FaceMatches[[1]]$Face$ExternalImageId
                        res.line[face.num, 23] = faceRec$FaceMatches[[1]]$Face$Confidence
                    } else {
                        res.line[face.num, 22] = "IDENTITY NOT RECOGNIZED"
                    }                          
                }
            }
        }
        if(videoCounter == 1) {
            raw.res.out = res.line
        } else {
            raw.res.out = rbind(raw.res.out, res.line)
        }      
    }

    # Do some final formatting on the returned object
    res.out = data.frame(raw.res.out, stringsAsFactors=F)
    col.numeric = c(2:4, 13:20, 23)
    col.boolean = c(5:7,9:12)
    col.names = c("frame_id", "face_id", "age_low", "age_high", "smile", "eyeglasses", "sunglasses", "gender", "beard", "mustache", "eyesopen", "mouthopen", "confused", "calm", "happy", "disgusted", "angry", "fear", "sad", "surprised", "img_timestamp", "identified_person", "identification_confidence")

    res.out[,col.numeric] = lapply(res.out[,col.numeric], as.numeric)
    res.out[,col.boolean] = lapply(res.out[,col.boolean], as.logical)
    res.out[,21] = as.POSIXct(as.numeric(res.out[,21]), origin="1970-01-01")
    names(res.out) = col.names 
    res.out = res.out[, c(1,21,22,23, 2:20)]
    return(res.out)
}


############################################################
# textConversationAnalysis Function
############################################################
# Conversation Analysis Function
# This function takes in the output of one of the other functions (either processZoomChat or processZoomTranscript) and produces # a set of conversation measures. I don't know conversation analysis, so this is just a rough cut of things I was
# curious about. If you are a conversation expert and want to contribute, let me know!
#
# Example Call:     o = textConversationAnalysis(inputData=outputOfOtherFunctions, inputType="chat", sentiment=TRUE, speakerId = "user_name")

# INPUT ARGUMENTS:

# inputData         The output from either the processZoomChat or processZoomTranscript functions
# inputType         either "chat" or "transcript"
# speakerId         The name of the variable in inputData that contains the unique identfier (e.g., "user_name")
# sentiment         Boolean to indicate whether you want to output sentiment analysis metrics, as well.

# OUTPUT:
# this function outputs a list with two items. One is a set of measures aggregated for the overall file (either chat or transcript). The second is a set of measures aggregated to the level of the individual speaker. The function presumes that speakerId is a unique identifier and treats each value as the ID for a speaker.

# Note that any time measures in the output are represented as seconds.  

# Most variable names are self-explanatory, but I'll highlight a few:

#### For the overall transcript output ####

# utterance_gap_x:      This is the average number of seconds between one person's utterance and the next person's utterance
# utterance_gap_sd:     The SD of the utterance gaps
# burstiness_raw:       This is a measure of how concentrated (in time) utterances are. It is not adjusted for # of utterances

#### For the speaker-level transcript output ####

# utterance_gap_x:      This is the average number of seconds (from the last utterance) that pass before this person makes an utterance

#### For the overall chat output ####

# message_gap_x:        This is the average number of seconds between one person's message and the next person's message
# message_gap_sd:       The SD of the message gaps
# burstiness_raw:       This is a measure of how concentrated (in time) chat messages are. It is not adjusted for # of messages

#### For the speaker-level chat output ####

# message_gap_x:        This is the average number of seconds (from the last message) that pass before this person sends a message


############################################################

textConversationAnalysis = function(inputData, inputType, speakerId, sentiment=FALSE) {
    require(data.table)
    ########################################
    # IF THE USER REQUESTED AN ANALYSIS OF A TRANSCRIPT FILE, DO THE FOLLOWING
    ########################################           

    if(inputType=="transcript") {

        ########################################
        # Do the sentiment analysis if it was requested
        ########################################
        if(sentiment==TRUE) {
            inputData = sentiOut(inputData=inputData, idVar="utterance_id", textVar="utterance_message", languageCodeVar="utterance_language")
            tab_denom = nrow(inputData[!is.na(inputData$sent_class), ])
            utterance_positive_pct = nrow(inputData[inputData$sent_class=="POSITIVE", ])/tab_denom
            utterance_neutral_pct = nrow(inputData[inputData$sent_class=="NEUTRAL", ])/tab_denom
            utterance_negative_pct = nrow(inputData[inputData$sent_class=="NEGATIVE", ])/tab_denom
            utterance_mixed_pct = nrow(inputData[inputData$sent_class=="MIXED", ])/tab_denom   

            utterance_mixed_x = mean(inputData$Mixed, na.rm=T)                 
            utterance_neutral_x = mean(inputData$Neutral, na.rm=T)                 
            utterance_negative_x = mean(inputData$Negative, na.rm=T)                   
            utterance_positive_x = mean(inputData$Positive, na.rm=T)                                       

            utterance_mixed_sd = sd(inputData$Mixed, na.rm=T)                  
            utterance_neutral_sd = sd(inputData$Neutral, na.rm=T)                  
            utterance_negative_sd = sd(inputData$Negative, na.rm=T)                
            utterance_positive_sd = sd(inputData$Positive, na.rm=T)    
            sent.cols = cbind(utterance_positive_pct, utterance_positive_x, utterance_positive_sd, utterance_neutral_pct, utterance_neutral_x, utterance_neutral_sd, utterance_negative_pct, utterance_negative_x, utterance_negative_sd, utterance_mixed_pct, utterance_mixed_x, utterance_mixed_sd)      
        }

        ########################################
        # Create a transcript-level output
        ########################################

        # First, get some overall statistics - all time units are in seconds
        total_recorded_time = as.numeric(difftime(max(inputData$utterance_end_time), min(inputData$utterance_start_time), units="secs"))
        utterance_time_window_sum = sum(inputData$utterance_time_window)
        silent_time_sum = total_recorded_time-utterance_time_sum
        utterance_time_window_x = mean(inputData$utterance_time_window, na.rm=T)
        utterance_time_window_sd = sd(inputData$utterance_time_window, na.rm=T)

        num_unique_speakers = length(unique(inputData[,speakerId]))
        num_utterances = nrow(inputData)       

        # Second get the information for burstiness, which is calculated as the CV of
        # the gap between utterances (so concentration of speech)

        # Figure out the gap from one utterance to the next
        inputData$utterance_gap = NA
        for(i in 2:nrow(inputData)) {
            # start time of current utterance - end time of prior utterance (in seconds)
            inputData[i, "utterance_gap"] = as.numeric(difftime(inputData[i, "utterance_start_time"], inputData[(i-1), "utterance_end_time"], units="secs"))
        }

        utterance_gap_x = mean(inputData$utterance_gap, na.rm=T)
        utterance_gap_sd = sd(inputData$utterance_gap, na.rm=T)    

        burstiness_raw = (sd(inputData$utterance_gap, na.rm=T)-mean(inputData$utterance_gap, na.rm=T))/(sd(inputData$utterance_gap, na.rm=T)+mean(inputData$utterance_gap, na.rm=T))

        transcript_out = cbind(total_recorded_time, num_utterances, num_unique_speakers, utterance_time_window_sum, silent_time_sum, utterance_time_window_x, utterance_time_window_sd, utterance_gap_x, utterance_gap_sd, burstiness_raw)
        if(sentiment==TRUE) transcript_out = cbind(transcript_out, sent.cols)      

        ########################################
        # Create an individual-level output
        # Note, the presumption is that user_name is something unique -- hopefully it is!
        ########################################
        dt = data.table(inputData)

        if(sentiment == TRUE) {
            agg.dt = dt[,list(utterance_time_sum = sum(utterance_time_window, na.rm=T), num_utterances = .N, utterance_time_x = mean(utterance_time_window, na.rm=T), utterance_time_sd = sd(utterance_time_window, na.rm=T), utterance_gap_x = mean(utterance_gap, na.rm=T), utterance_gap_sd = sd(utterance_gap, na.rm=T),
                utterance_positive_pct = sum(sent_class=="POSITIVE")/.N, utterance_positive_x = mean(Positive, na.rm=T), utterance_positive_sd = sd(Positive, na.rm=T),
                utterance_negative_pct = sum(sent_class=="NEGATIVE")/.N, utterance_negative_x = mean(Negative, na.rm=T), utterance_negative_sd = sd(Negative, na.rm=T),                        
                utterance_neutral_pct = sum(sent_class=="NEUTRAL")/.N, utterance_neautral_x = mean(Neutral, na.rm=T), utterance_neutral_sd = sd(Neutral, na.rm=T),                                     
                utterance_mixed_pct = sum(sent_class=="MIXED")/.N, utterance_mixed_x = mean(Mixed, na.rm=T), utterance_mixed_sd = sd(Mixed, na.rm=T)               
                ), by=list(get(speakerId))]
            names(agg.dt)[1] = speakerId

        } else {
            agg.dt = dt[,list(utterance_time_sum = sum(utterance_time_window, na.rm=T), num_utterances = .N, utterance_time_x = mean(utterance_time_window, na.rm=T), utterance_time_sd = sd(utterance_time_window, na.rm=T), utterance_gap_x = mean(utterance_gap, na.rm=T), utterance_gap_sd = sd(utterance_gap, na.rm=T)), by=list(get(speakerId))]
            names(agg.dt)[1] = speakerId
        }

        agg.out = data.frame(agg.dt)

        res.out = list("TRANSCRIPT-LEVEL" = transcript_out, "SPEAKER-LEVEL" = agg.out)

    ########################################
    # IF THE USER REQUESTED AN ANALYSIS OF A CHAT FILE, DO THE FOLLOWING
    ########################################       

    } else if(inputType=="chat") {

        ########################################
        # Do the sentiment analysis if it was requested
        ########################################
        if(sentiment==TRUE) {
            inputData = sentiOut(inputData=inputData, idVar="message_id", textVar="message", languageCodeVar="message_language")
            tab_denom = nrow(inputData[!is.na(inputData$sent_class), ])
            message_positive_pct = nrow(inputData[inputData$sent_class=="POSITIVE", ])/tab_denom
            message_neutral_pct = nrow(inputData[inputData$sent_class=="NEUTRAL", ])/tab_denom
            message_negative_pct = nrow(inputData[inputData$sent_class=="NEGATIVE", ])/tab_denom
            message_mixed_pct = nrow(inputData[inputData$sent_class=="MIXED", ])/tab_denom 

            message_mixed_x = mean(inputData$Mixed, na.rm=T)                   
            message_neutral_x = mean(inputData$Neutral, na.rm=T)                   
            message_negative_x = mean(inputData$Negative, na.rm=T)                 
            message_positive_x = mean(inputData$Positive, na.rm=T)                                     

            message_mixed_sd = sd(inputData$Mixed, na.rm=T)                
            message_neutral_sd = sd(inputData$Neutral, na.rm=T)                
            message_negative_sd = sd(inputData$Negative, na.rm=T)                  
            message_positive_sd = sd(inputData$Positive, na.rm=T)      
            sent.cols = cbind(message_positive_pct, message_positive_x, message_positive_sd, message_neutral_pct, message_neutral_x, message_neutral_sd, message_negative_pct, message_negative_x, message_negative_sd, message_mixed_pct, message_mixed_x, message_mixed_sd)  
        }

        ########################################
        # Create a chat-level output
        ########################################
        inputData$message_numchars = nchar(inputData$message)

        # First, get some overall statistics - all time units are in seconds
        total_recorded_time = as.numeric(difftime(max(inputData$message_time), min(inputData$message_time), units="secs"))
        message_numchars_sum = sum(inputData$message_numchars)
        message_numchars_x = mean(inputData$message_numchars)
        message_numchars_sd = sd(inputData$message_numchars)               

        num_unique_messagers = length(unique(inputData[,speakerId]))
        num_messages = nrow(inputData)     

        # Second get the information for burstiness, which is calculated as the CV of
        # the gap between messages (so concentration of speech)

        # Figure out the gap from one message to the next
        inputData$message_gap = NA
        for(i in 2:nrow(inputData)) {
            # start time of current utterance - end time of prior utterance (in seconds)
            inputData[i, "message_gap"] = as.numeric(difftime(inputData[i, "message_time"], inputData[(i-1), "message_time"], units="secs"))
        }

        message_gap_x = mean(inputData$message_gap, na.rm=T)
        message_gap_sd = sd(inputData$message_gap, na.rm=T)    

        burstiness_raw = (sd(inputData$message_gap, na.rm=T)-mean(inputData$message_gap, na.rm=T))/(sd(inputData$message_gap, na.rm=T)+mean(inputData$message_gap, na.rm=T))

        chat_out = cbind(total_recorded_time, num_messages, message_numchars_sum, num_unique_messagers, message_gap_x, message_gap_sd, burstiness_raw)
        if(sentiment==TRUE) chat_out = cbind(chat_out, sent.cols)      

        ########################################
        # Create an individual-level output
        # Note, the presumption is that user_name is something unique -- hopefully it is!
        ########################################       

        dt = data.table(inputData)

        if(sentiment == TRUE) {
            agg.dt = dt[,list(message_numchars_sum = sum(message_numchars, na.rm=T), num_messages = .N, message_numchars_x = mean(message_numchars), message_numchars_sd = sd(message_numchars), message_gap_x = mean(message_gap, na.rm=T), message_gap_sd = sd(message_gap, na.rm=T),
                message_positive_pct = sum(sent_class=="POSITIVE")/.N, message_positive_x = mean(Positive, na.rm=T), message_positive_sd = sd(Positive, na.rm=T),
                message_negative_pct = sum(sent_class=="NEGATIVE")/.N, message_negative_x = mean(Negative, na.rm=T), message_negative_sd = sd(Negative, na.rm=T),                      
                message_neutral_pct = sum(sent_class=="NEUTRAL")/.N, message_neautral_x = mean(Neutral, na.rm=T), message_neutral_sd = sd(Neutral, na.rm=T),                                       
                message_mixed_pct = sum(sent_class=="MIXED")/.N, message_mixed_x = mean(Mixed, na.rm=T), message_mixed_sd = sd(Mixed, na.rm=T)             
                ), by=list(get(speakerId))]
            names(agg.dt)[1] = speakerId

        } else {
            agg.dt = dt[,list(message_numchars_sum = sum(message_numchars, na.rm=T), num_messages = .N, message_numchars_x = mean(message_numchars), message_numchars_sd = sd(message_numchars), message_gap_x = mean(message_gap, na.rm=T), message_gap_sd = sd(message_gap, na.rm=T)             
                ), by=list(get(speakerId))]
            names(agg.dt)[1] = speakerId
        }

        agg.out = data.frame(agg.dt)
        res.out = list("CHAT-LEVEL" = chat_out, "USER-LEVEL" = agg.out)    
    }
}

Using R for Face Detection through AWS Rekognition

Today I experimented a little with the Rekognition service that AWS offers. I started out by experimenting with doing a Python version of this project, following this K-pop Idol Identifier with Rekognition post. It was pretty easy to setup; however, I tend to use R more than Python for data analysis and manipulation.

I found the excellent paws package, which is available through CRAN. The documentation for the paws package is very good, organized in an attractive github site here.

To start, I just duplicated the Python project in R, which was fairly straightforward. Then, I expanded on it a bit to annotate a photo with information about the emotional expressions being displayed by any subjects. The annotated image above shows what the script outputs if it is given a photo of my kids. And, here’s the commented code that walks through what I did.

########################
# Setup the environment with libraries and the key service
########################

# Use the paws library for easy access to aws
# Note: You will need to authenticate. For this project, I have my credentials in an
# AWS configuration; so, paws looks there for them.
# paws provides good information on how to do this:
# https://github.com/paws-r/paws/blob/master/docs/credentials.md
library(paws)

# Use the magick library for image functions.
library(magick)

# This application is going to use the rekognition service from AWS. The paws documentation is here:
# # https://paws-r.github.io/docs/rekognition/
svc <- rekognition()

########################
# First, create a new collection that you will use to store the set of identified faces. This will be
# the library of faces that you use for determining someone's identity
########################

# I am going to create a collection for a set of family photos
svc$create_collection(CollectionId = "family-r")

# I stored a set of faces of my kids in a single directory on my Desktop. Inside
# this directory are multiple photos of each person, with the filename set as personname_##.png. This
# means that there are several photos per kid, which should help with classification.

# Get the list of files
path = "~/Desktop/family"
file.names = dir(path, full.names=F)

# Loop through the files in the specified folder, add and index them in the collection
for(f in file.names) {
    imgFile = paste(path,f,sep="/")
    # This gets the name of the kid, which is embedded in the filename and separated from the number with an underscore
    imgName = unlist(strsplit(f,split="_"))[[1]]
    # Add the photos and the name to the collection that I created
    svc$index_faces(CollectionId="family-r", Image=list(Bytes=imgFile), ExternalImageId=imgName, DetectionAttributes=list())
}

########################
# Second, take a single photo that has multiple kids in it. Label each kid with his name and the
# emotions that are expressed in the photo.
########################

# Get information about a group photo
grp.photo = "~/Desktop/all_three_small.png"

# Read the photo using magick
img = image_read(grp.photo)

# Get basic informatino about the photo that will be useful for annotating
inf = image_info(img)

# Detect the faces in the image and pull all attributes associated with faces
o = svc$detect_faces(Image=list(Bytes=grp.photo), Attributes="ALL")

# Just get the face details
all_faces = o$FaceDetails
length(all_faces)

# Loop through the faces, one by one. For each face, draw a rectangle around it, add the kid's name, and emotions

# Duplicate the original image to have something to annotate and output
new.img = img

for(face in all_faces) {

    # Prepare a label that collapses across the emotions data provided by rekognition. Give the type of
    # emotion and the confidence that AWS has in its expression.
    emo.label = ""
    for(emo in face$Emotions) {
        emo.label = paste(emo.label,emo$Type, " = ", round(emo$Confidence, 2), "\n", sep="")
    }

    # Identify the coordinates of the face. Note that AWS returns percentage values of the total image size. This is
    # why the image info object above is needed
    box = face$BoundingBox
    image_width=inf$width
    image_height=inf$height
    x1 = box$Left*image_width
    y1 = box$Top*image_height
    x2 = x1 + box$Width*image_width
    y2 = y1 + box$Height*image_height  

    # Create a subset image in memory that is just cropped around the focal face
    img.crop = image_crop(img, paste(box$Width*image_width,"x",box$Height*image_height,"+",x1,"+",y1, sep=""))
    img.crop = image_write(img.crop, path = NULL, format = "png")

    # Search in a specified collection to see if we can label the identity of the face is in this crop
    o = svc$search_faces_by_image(CollectionId="family-r",Image=list(Bytes=img.crop), FaceMatchThreshold=70)

    # Create a graphics device version of the larger photo that we can annotate
    new.img = image_draw(new.img)

    # If the face matches something in the collection, then add the name to the image
    if(length(o$FaceMatches) > 0) {
        faceName = o$FaceMatches[[1]]$Face$ExternalImageId
        faceConfidence = round(o$FaceMatches[[1]]$Face$Confidence,3)
        print(paste("Detected: ",faceName, sep=""))
        # Annotate with the name of the person
        text(x=x1+(box$Width*image_width)/2, y=y1,faceName, adj=0.5, cex=3, col="green")
    }

    # Draw a rectangle around the face
    rect(x1,y1,x2,y2, border="red", lty="dashed", lwd=5)   

    # Annotate the photo with the emotions information
    text(x=x1+(box$Width*image_width)/2, y=y1+50,emo.label, pos=1, cex=1.5, col="red")     

    dev.off()
}

# Write the image out to file
image_write(new.img, path="~/Desktop/annotated_image.png", format="png")

Start-Up Teams: A Multidimensional Conceptualization, Integrative Review of Past Research, and Future Research Agenda

Knight, A. P., Greer, L. L., & de Jong, B. (2020). Start-up teams: A multidimensional conceptualization, integrative review of past research, and future research agenda. Academy of Management Annals, 14, 231-266.

Abstract. Academic interest in start-up teams has grown dramatically over the past 40 years, with researchers from a wide variety of disciplines actively studying the topic. Although this widespread interest is encouraging, a review of the literature reveals a lack of consensus in how researchers conceptualize and operationally define start-up teams. A lack of consensus on the core phenomenon—a foundational part of a strong paradigm—has stifled the systematic advancement of knowledge about start-up teams, which has downstream implications for the viability of this field of research. To advance the development of a stronger paradigm, we present a multidimensional conceptualization of start-up teams that is derived from points of consensus in existing definitions. Our multidimensional conceptualization accounts for the fact that, although all are under the umbrella of the concept of “start-up team,” start-up teams vary in a set of key ingredients—ownership of equity, autonomy of strategic decision-making, and entitativity. This conceptualization serves as a framework for reviewing and beginning to integrate past research on start-up teams. It also serves as a framework for guiding and informing an integrated program of future research on start-up teams. By introducing a multidimensional conceptualization of start-up teams, we highlight the value of considering the defining ingredients of start-up teams for furthering a stronger paradigm.

On the relation between felt trust and actual trust: Examining pathways to and implications of leader trust meta-accuracy

Campagna, R. L., Dirks, K. T., Knight, A. P., Crossley, C., & Robinson, S. L. (In Press). On the relation between felt trust and actual trust: Examining pathways to and implications of leader trust meta-accuracy. Journal of Applied Psychology.

Abstract. Research has long emphasized that being trusted is a central concern for leaders (Dirks & Ferrin, 2002), but an interesting and important question left unexplored is whether leaders feel trusted by each employee, and whether their felt trust is accurate. Across two field studies, we examined the factors that shape the accuracy of leaders’ felt trust—or, their trust meta-accuracy—and the implications of trust meta- accuracy for the degree of relationship conflict between leaders and their employees. By integrating research on trust and interpersonal perception, we developed and tested hypotheses based on two theoretical mechanisms—an external signaling mechanism and an internal presumed reciprocity mechanism—that theory suggests shape leaders’ trust meta-accuracy. In contrast to the existing literature on felt trust, our results reveal that leader trust meta-accuracy is shaped by an internal mechanism and the presumed reciprocity of trust relationships. We further find that whether trust meta-accuracy is associated with positive relational outcomes for leaders depends upon the level of an employee’s actual trust in the leader. Our research contributes to burgeoning interest in felt trust by elucidating the mechanisms underlying trust meta-accuracy and suggesting practical directions for leaders who seek to accurately understand how much their employees trust them.

Spring 2020 Courses

People Metrics
Open to BSBA, MBA, and Specialized Masters students

Metrics are at the core of people analytics. The purpose of this course is to introduce you to the foundations of assessing behavior in organizations using novel measurement approaches and large datasets. Through classroom discussions and real-world applications, this course will enable you to add value to organizations through the development, use, and interpretation of innovative people metrics. Specifically, after taking this course, you will be able to:

  • Develop a clear and logical conceptual measurement model. A conceptual measurement model is the foundation of creating novel and useful new approaches for assessing intrapersonal characteristics (e.g., personality) and interpersonal behavior (e.g., knowledge sharing, teamwork).
  • Identify novel sources of data for innovative people metrics. Organizations are awash in the traces of individual behavior and social interactions. Decoding how data that already exist in an organization can be used to understand behavior is an essential skill for adding value in the field of people analytics.
  • Apply a rigorous process for validating new people metrics. Developing a measurement model and finding sources of data are necessary, but insufficient for adding value through people metrics. New measures must be validated.

Fall 2019 Courses

Leadership Development
2nd Year Full-Time MBA OB Core Course

This course builds upon the material from the 1st Year OB Core (OB 5620, Foundations for Leadership Effectiveness) and, importantly, from your time so far at Olin and during your summer work experiences. The focus of the course is on the attributes, behaviors, and tendencies of effective leadership. There are two primary objectives:

  • Gain new insights into your own beliefs and expectations regarding what constitutes effective leadership in organizations. You will accomplish this through a mixture of classroom discussion, case analysis, and self-assessment.
  • Learn about your own strengths and weaknesses in leading others. You will accomplish this in the classroom through controlled experiential exercises, which will be the basis for feedback from your peers. You will also reflect in depth on your strengths using feedback provided by people you have encountered in your life and career through a structured exercise.

Summer 2019 Courses

Foundations of Impactful Teamwork
Required Course for 1st Year MBA Students

Working effectively in and leading teams are essential competencies in modern organizations, both large and small. The purpose of this course is to lay a foundation of knowledge and skills that will enable you to differentiate yourself as an effective leader and member of impactful teams. The specific learning objectives for this course include:

  • Be able to skillfully launch and lead goal-directed project teams that meet or exceed stakeholders’ expectations for task performance, provide a positive working experience for team members, and enable team members to grow as a unit and as individuals.
  • Be able to diagnose common interpersonal challenges that arise in teams composed of diverse individuals who are working under pressure in unfamiliar environments; and, apply evidence-based practices for leading teams through these challenges.
  • Refine your awareness of your personal strengths and weaknesses as a leader and develop a plan for honing your leadership identity and interpersonal skills during your MBA program.
  • Augment your resourcefulness when navigating diverse local environments and unfamiliar cultures and societies.

On the emergence of collective psychological ownership in new creative teams

Gray, S. M., Knight, A. P., & Baer, M. (2020). On the emergence of collective psychological ownership in new creative teams. Organization Science, 31, 141-164.

Abstract. We develop and test a theoretical model that explains how collective psychological ownership—shared feelings of joint possession over something—emerges within new creative teams that were launched to advance one person’s (i.e., a creative lead’s) preconceived idea. Our model proposes that such teams face a unique challenge—an initial asymmetry in feelings of psychological ownership for the idea between the creative lead who conceived the idea and new team members who are beginning to work on the idea. We suggest that the creative lead can resolve this asymmetry and foster the emergence of collective psychological ownership by enacting two interpersonal behaviors—help seeking and territorial marking. These behaviors build collective ownership by facilitating the unifying, centripetal force of team identification and preventing the divisive, centrifugal force of team ownership conflict. Our model also proposes that collective ownership positively relates to the early success of new creative teams. The results of a quantitative study of 79 creative teams participating in an entrepreneurship competition provided general support for our predictions, but also suggested refinements as to how a creative lead’s behavior influences team dynamics. The findings of a subsequent qualitative investigation of 27 teams participating in a university startup launch course shed additional light on how collective ownership emerges in new creative teams launched to advance one person’s idea.

Using R to Analyze Twitter

The code below will give you a start on processing text data from Twitter. There are some basic examples of how to pull down tweets for selected users and compare/contrast the sentiment of their posts.

#####################
# This script illustrates how to pull data from
# twitter and use default settings for English
# language sentiment analysis
#####################
library(twitteR)
library(rtweet)
library(syuzhet)
library(ngram)
library(reshape2)
require(dplyr)
library(timeDate)
library(ggplot2)

#####################
# This is just a crude string cleaning function for the purposes
# of illustration.
#####################

clean.string <- function(string){
    # Lowercase
    temp <- tolower(string)
    # Remove everything that is not a number or letter (may want to keep more
    # stuff in your actual analyses).
    temp <- stringr::str_replace_all(temp,"[^a-zA-Z\\s]", " ")
    # Shrink down to just one white space
    temp <- stringr::str_replace_all(temp,"[\\s]+", " ")
    return(temp)
}

#####################
# this function returns a crude sentiment analysis of the tweets from a set of
# users' timelines. You must provide a vector of users.
#####################

twit.sentiment <- function(users, n.tweets=200, include.retweet=FALSE) {
    sent.vars = c("anger", "anticipation", "disgust", "fear", "joy", "sadness", "surprise", "trust", "negative", "positive")   
    d.vars = c("user_id", "screen_name", "created_at", "retweet_count", "favorite_count", "followers_count", "friends_count", "text")
    d = data.frame(get_timelines(users, n=n.tweets, parse=TRUE))

    # do a very light text cleaning
    d$text_clean = unlist(lapply(d$text, clean.string))

    # count the clean words
    d$n_words = unlist(lapply(d$text_clean, wordcount))

    # Do the sentiment analysis using nrc. In a real production sentiment analysis, you would want
    # to consider several different dictionaries. Check out the following page for a walkthrough of
    # some of the different lexicons you might consider:
    # https://cran.r-project.org/web/packages/syuzhet/vignettes/syuzhet-vignette.html
    d[,sent.vars] = bind_rows(lapply(d$text_clean, get_nrc_sentiment))
    head(d)

    # Get a percentage of pos/neg by number of words in the email
    d$neg_pct = d$negative/d$n_words
    d$pos_pct = d$positive/d$n_words

    if(include.retweet) {
        d.sub = d[,c(d.vars, sent.vars)]       
    } else {
        d.sub = d[!(d$is_retweet),c(d.vars, sent.vars)]    
    }
    return(d.sub)
}

#####################
# Explore the dictionaries, showing how different
# words are coded
#####################

nrc = get_sentiment_dictionary(dictionary = "nrc", language = "english")
syuzhet = get_sentiment_dictionary(dictionary = "syuzhet", language = "english")

nrc[nrc$word == "horrible", ]
syuzhet[syuzhet$word == "horrible", ]

nrc[nrc$word == "disastrous", ]
syuzhet[syuzhet$word == "disastrous", ]

#####################
# Exploring sentiment analysis
#####################

v1 = "Man, I am having the best day today. The sun is out and it is a beautiful day."
v2 = "So grateful to be part of this supportive community. This is an amazing place to work."
v3 = "What a horrible day. Not only is it storming, but I fell in the mud and broke my phone."
v4 = "Awful bosses and terrible co-workers. This is a ridiculously bad place to work."

v5 = "I am not having the best day today. The sun is not out and it is not a beautiful day."
v6 = "Some days are better than others. This is the latter."
v7 = "So, got my final back. Um, yeah. The professor sure knows how to give the gift of a great day."
v8 = "Great idea Olin...Make all the students swipe their cards just to get onto the 4th floor. Beautiful building that we can't access."

get_nrc_sentiment(clean.string(v1))
get_nrc_sentiment(clean.string(v2))
get_nrc_sentiment(clean.string(v3))
get_nrc_sentiment(clean.string(v4))
get_nrc_sentiment(clean.string(v5))
get_nrc_sentiment(clean.string(v6))
get_nrc_sentiment(clean.string(v7))
get_nrc_sentiment(clean.string(v8))

#####################
# The first thing you need to do is create an app for your twitter account
# you can find instructions here:
# https://developer.twitter.com/en/docs/basics/apps/overview.html

# Once you've created an app, then add the following information to this script
#####################
# twitter_consumer_key = "YOUR INFO HERE"
# twitter_consumer_secret = "YOUR INFO HERE"
# twitter_access_token = "YOUR INFO HERE"
# twitter_access_secret = "YOUR INFO HERE"

setup_twitter_oauth(twitter_consumer_key, twitter_consumer_secret, twitter_access_token, twitter_access_secret)

#####################
# Sample sentiment analysis on accounts where
# we have strong priors about their sentiment
#####################

sad_happy = c("sosadtoday", "angrymemorys", "gohappiest", "kindnessgirl")
d.sh = twit.sentiment(users=sad_happy, n.tweets=200, include.retweet=F)
boxplot(positive~screen_name, data=d.sh, cex.axis=.7, las=2, main="positive")
boxplot(negative~screen_name, data=d.sh, cex.axis=.7, las=2, main="negative")

#####################
# Illustrating the potential for looking at specific users and
# comparing / contrasting individual employees' sentiment
#####################

OlinPeeps = c("DeanTaylorWashU", "sjmalter", "LamarPierce1", "OrgStratProf")
BSchoolDeans = c("DeanTaylorWashU", "scottderue")
BSchools = c("OlinBusiness", "Wharton")

d.olin = twit.sentiment(users=OlinPeeps, n.tweets=300, include.retweet=F)
d.deans = twit.sentiment(users=BSchoolDeans, n.tweets=300, include.retweet=F)
d.schools = twit.sentiment(users=BSchools, n.tweets=300, include.retweet=F)

boxplot(positive~screen_name, data=d.olin, cex.axis=.7, las=2, main="positive")
boxplot(negative~screen_name, data=d.olin, cex.axis=.7, las=2, main="negative")

boxplot(positive~screen_name, data=d.deans, cex.axis=.7, las=2, main="positive")
boxplot(negative~screen_name, data=d.deans, cex.axis=.7, las=2, main="negative")

boxplot(positive~screen_name, data=d.schools, cex.axis=.7, las=2, main="positive")
boxplot(negative~screen_name, data=d.schools, cex.axis=.7, las=2, main="negative")

#####################
# Illustrating the potential for looking at trends over time
#####################
olin.all = c("DeanTaylorWashU", "sjmalter", "LamarPierce1", "OrgStratProf", "sethcarnahan", "peterboumgarden",
    "jrobmartin", "milbourn_todd", "danbentle", "wustlbusiness", "drpatsportsbiz", "analisaortiz", "krwools")

d.lrg = twit.sentiment(users=olin.all, n.tweets=300, include.retweet=F)

d.lrg$date = as.Date(d.lrg$created_at)
d.lrg$year = as.numeric(strftime(d.lrg$date, format="%Y"))
d.lrg$month = as.numeric(strftime(d.lrg$date, format="%m"))
d.lrg$woy = as.numeric(strftime(d.lrg$date, format="%V"))

o = aggregate(d.lrg[,c("positive", "negative")], by=list(d.lrg$year, d.lrg$month), mean)
names(o)[1:2] = c("year", "month")

plot(o[o$year == 2018, "month"], o[o$year == 2018, "positive"], type="l", ylim=c(0,3), col="dark green", lwd=3, ylab="sentiment", xlab="month")
lines(o[o$year == 2017, "month"], o[o$year == 2017, "positive"], type="l", col="dark green", lwd=3, lty=2)

lines(o[o$year == 2018, "month"], o[o$year == 2018, "negative"], type="l", col="dark red", lwd=3)
lines(o[o$year == 2017, "month"], o[o$year == 2017, "negative"], type="l", col="dark red", lwd=3, lty=2)

boxplot(positive~screen_name, data=d.lrg, cex.axis=.7, las=2, main="positive")
boxplot(negative~screen_name, data=d.lrg, cex.axis=.7, las=2, main="negative")

d.lrg$name = as.factor(d.lrg$screen_name)

p <- ggplot(d.lrg, aes(x=name, y=positive)) + geom_violin()
p <- ggplot(d.lrg, aes(x=name, y=negative)) + geom_violin()

d.lrg[d.lrg$negative > 7, ]

Integrating R and PollEverywhere

I wrote a short function to extract poll results from PollEverywhere. I frequently use PollEverywhere in classes and executive education programs, but have always found pulling the results clunky.

PollEverywhere does have an api, but it doesn’t seem like something that (at least publicly available) is a big focus of attention. Nonetheless, it is possible to use basic http authentication to get poll results. Here’s the function that will do that.

## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ##
## This function extracts responses to poll everywhere polls
## This is currently setup just to do a simple multiple choice
## poll. This could be extended to other types of polls and response sets
## More information here: https://api.polleverywhere.com/
## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ## -- ##

pe.responses = function(ids, un, pw) {
    require(httr)
    require(jsonlite)
    ## The id of the poll is at the end of the URL if you open one of the poll
    # in your browser
    ## I could not find a way with the api to list all of the polls for
    ## a given account. This would be ideal, but it's not there as far as I can see

    url.root = "https://www.polleverywhere.com/multiple_choice_polls"

    for(id in ids) {
        # This will just pull the attributes for this particular poll
        url = paste(url.root,id,sep="/")
        # I am only interested in the option set right here, but you could
        # get more information about the poll from this
        ops = fromJSON(content(GET(url, authenticate(un,pw)), "text"))$options
        names(ops)[1] = "response_id"

        # This will pull the responses to this particular poll
        url = paste(url.root,id,"results",sep="/")
        responses = fromJSON(content(GET(url, authenticate(un,pw)), "text"))
        responses$poll_id = id

        # Add the keyword response in addition to the value
        mrg = merge(responses, ops[, c("response_id", "value", "keyword")], by="value", all.x=T)

        if(id == ids[1]) {
            res.out = mrg
        } else {
            res.out = rbind(res.out, mrg)
        }
    }
    res.out$response_num = as.numeric(res.out$keyword)

    # Format the date/time
    res.out$response_date = unlist(lapply(strsplit(res.out$created_at, "T"), function(x) x[1]))
    res.out$response_time = unlist(lapply(strsplit(res.out$created_at, "T"), function(x) x[2]))
    res.out$response_time = substr(res.out$response_time,1, 8)
    res.out$response_date_time = paste(res.out$response_date, res.out$response_time, sep=" ")
    res.out$response_date_time = as.POSIXlt(res.out$response_date_time)
    return(res.out)
}

## Enter your polleverywhere credentials here
# un = "{Your Username Here}"
# pw = "{Your Password Here}"

## Here's a set of poll ids that I was interested in getting the results for
# ids = vector of pollids that you want to extract the results for

o = pe.responses(ids, un, pw)