31 KiB
dev.metalisp.survey Documentation
Assessment
User Requests
GET http://localhost:8080/survey/1057733929973421737730
GET http://localhost:8080/survey/252366393230629784388
Class Definition
@startuml
skinparam dpi 300
package ml-survey/assessment {
abstract class Assessment {
# results: List
# responses: List
+ displayer: Displayer
+ calculator: Calculator
+ calc-results(): List
+ results-html(): List
}
class SusAssessment extends Assessment {}
class VisawiAssessment extends Assessment {}
abstract class Displayer {
+ generate-html(): List
}
class SusDisplayer extends Displayer {}
class VisawiDisplayer extends Displayer {}
SusAssessment *-- SusDisplayer
VisawiAssessment *-- VisawiDisplayer
abstract class Calculator {
+ calc-results(): List
+ group-stats(): List
}
class SusCalculator extends Calculator {}
class VisawiCalculator extends Calculator {}
SusAssessment *-- SusCalculator
VisawiAssessment *-- VisawiCalculator
}
@enduml
The only functions which should be exported from the ml-survey/assessment
package are:
- assessment-results-html
- Enables the caller to get the HTML for the right object.
- parse-assessments
- Returns a list of assessment objects created based on a PLIST of responses.
(defpackage :ml-survey/assessment
(:use #:cl)
(:import-from #:ml-sbt/section
#:with-section
#:with-title-bar)
(:import-from #:ml-sbt/tbl
#:render-tbl
#:render-kwlist-tbl
#:render-nested-plist-tbl)
(:import-from #:ml-survey/stats
#:calculate-statistics
#:mean)
(:export #:assessment-results-html
#:parse-assessments))
(in-package :ml-survey/assessment)
A assessment helps us to process the questionnaire responses and provides users with calculated results. To handle questionnaire responses we need to define a specific assessment for a specific questionnaire because questionnaires often needs specific statistical calculations. For instance, the calculations for a SUS is different from the calculations for a VISAWI.
(defclass assessment ()
((results :initarg :results
:reader assessment-results
:type list
:documentation "Complete output of an assessment.")
(responses :initarg :responses
:reader assessment-responses
:type list
:documentation "Data sent by the user using a HTML form.")
(displayer :initarg :displayer
:accessor assessment-displayer
:type displayer)
(calculator :initarg :calculator
:accessor assessment-calculator
:type calculator))
(:documentation "Provides mechanism to handle data related to assessments. An assessment includes the calculated results for a specific questionnaire."))
(defmethod assessment-calc-results ((a assessment))
"Calculate results from responses."
(let* ((responses (assessment-responses a))
(calculator (assessment-calculator a))
(results (calculator-calc-results calculator responses)))
(make-instance (class-name (class-of a)) :results results)))
(defmethod assessment-results-html ((a assessment))
"Render HTML to show results."
(let* ((results (assessment-results (assessment-calc-results a)))
(group-stats (calculator-group-stats (assessment-calculator a) results)))
(displayer-generate-html (assessment-displayer a) results group-stats)))
Helper Functions
(defun string-to-keyword (str)
"Converts string to keyword symbol.
STR is a string.
Returns keyword symbol."
(intern (string-upcase str) :keyword))
(defun string-integer (str)
"Converts string to integer.
STR is a string.
Returns integer."
(parse-integer (remove-if (complement #'digit-char-p) str)))
Questionnaire Responses
Response
(defparameter %response-example-data%
'(:TYPE "likert" :NAME "visawi" :TIMESTAMP "2024-08-18 12:03:52" :POST-DATA
(("group-simplicity-1-r" . "2-stimme-nicht-zu")
("group-simplicity-2" . "2-stimme-nicht-zu")
("group-simplicity-3" . "3-stimme-eher-nicht-zu")
("group-simplicity-4-r" . "6-stimme-zu")
("group-simplicity-5" . "4-neutral")
("group-diversity-1-r" . "2-stimme-nicht-zu")
("group-diversity-2" . "6-stimme-zu")
("group-diversity-3-r" . "6-stimme-zu")
("group-diversity-4" . "5-stimme-eher-zu")
("group-diversity-5" . "1-stimme-gar-nicht-zu")
("group-colorfulness-1" . "4-neutral")
("group-colorfulness-2-r" . "4-neutral")
("group-colorfulness-3-r" . "6-stimme-zu")
("group-colorfulness-4" . "2-stimme-nicht-zu")
("group-craftsmanship-1" . "5-stimme-eher-zu")
("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
("group-craftsmanship-3" . "2-stimme-nicht-zu")
("group-craftsmanship-4-r" . "6-stimme-zu"))))
Entry
(defparameter %entry-example-data%
'("group-craftsmanship-4-r" . "6-stimme-zu"))
(defun response-entry-extract-category (key-str)
"Extract category name from response separated by hyphens.
KEY-STR is a string.
Returns string."
(nth 1 (uiop:split-string key-str :separator "-")))
(defun response-entry-negative-p (key-str)
"Check if entry is a negtive question.
KEY-STR is a string.
Returns t or nil."
(let ((identifier (first (last (uiop:split-string key-str :separator "-")))))
(if (string= identifier "r")
t
nil)))
(defun response-entry-process (value-fn entry)
"Process entry and return category and value as (:CAT (VAL)).
VALUE-FN: Function which operates on the value.
ENTRY: Entry from a response."
(destructuring-bind (key . value) entry
(let* ((category (string-to-keyword (response-entry-extract-category key)))
(negative-p (response-entry-negative-p key))
(value (funcall value-fn (string-integer value) negative-p)))
(list category (list value)))))
Interfaces
The following data structure is an example which put into parse-assessment
(defparameter %categorized-responses-example%
'(:VISAWI
(("2024-10-10 10:42:01"
("group-simplicity-1-r" . "3-stimme-eher-nicht-zu")
("group-simplicity-2" . "5-stimme-eher-zu")
("group-simplicity-3" . "6-stimme-zu")
("group-simplicity-4-r" . "2-stimme-nicht-zu")
("group-simplicity-5" . "6-stimme-zu")
("group-diversity-1-r" . "1-stimme-gar-nicht-zu")
("group-diversity-2" . "5-stimme-eher-zu")
("group-diversity-3-r" . "5-stimme-eher-zu")
("group-diversity-4" . "1-stimme-gar-nicht-zu")
("group-diversity-5" . "6-stimme-zu")
("group-colorfulness-1" . "4-neutral")
("group-colorfulness-2-r" . "2-stimme-nicht-zu")
("group-colorfulness-3-r" . "5-stimme-eher-zu")
("group-colorfulness-4" . "5-stimme-eher-zu")
("group-craftsmanship-1" . "5-stimme-eher-zu")
("group-craftsmanship-2-r" . "3-stimme-eher-nicht-zu")
("group-craftsmanship-3" . "1-stimme-gar-nicht-zu")
("group-craftsmanship-4-r" . "4-neutral"))
("2024-10-10 10:41:34"
("group-simplicity-1-r" . "1-stimme-gar-nicht-zu")
("group-simplicity-2" . "3-stimme-eher-nicht-zu")
("group-simplicity-3" . "3-stimme-eher-nicht-zu")
("group-simplicity-4-r" . "7-stimme-voll-zu")
("group-simplicity-5" . "4-neutral")
("group-diversity-1-r" . "3-stimme-eher-nicht-zu")
("group-diversity-2" . "4-neutral")
("group-diversity-3-r" . "5-stimme-eher-zu")
("group-diversity-4" . "6-stimme-zu")
("group-diversity-5" . "2-stimme-nicht-zu")
("group-colorfulness-1" . "6-stimme-zu")
("group-colorfulness-2-r" . "2-stimme-nicht-zu")
("group-colorfulness-3-r" . "6-stimme-zu")
("group-colorfulness-4" . "3-stimme-eher-nicht-zu")
("group-craftsmanship-1" . "3-stimme-eher-nicht-zu")
("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
("group-craftsmanship-3" . "2-stimme-nicht-zu")
("group-craftsmanship-4-r" . "3-stimme-eher-nicht-zu")))
:SUS
(("2024-07-14 21:17:21" ("group-sus-1" . "1-strongly-disagree")
("group-sus-2" . "2-disagree") ("group-sus-3" . "4-agree")
("group-sus-4" . "3-neither-agree-nor-disagree")
("group-sus-5" . "1-strongly-disagree")
("group-sus-6" . "5-strongly-agree")
("group-sus-7" . "1-strongly-disagree") ("group-sus-8" . "2-disagree")
("group-sus-9" . "4-agree")
("group-sus-10" . "3-neither-agree-nor-disagree")))))
(defun create-assessment (type responses)
(ecase type
(:visawi (make-instance 'visawi-assessment :responses responses))
(:sus (make-instance 'sus-assessment :responses responses))))
(defun parse-assessments (categorized-responses)
(loop for (type data) on categorized-responses by #'cddr
collect (create-assessment type data)))
Calculator
(defclass calculator () ())
(defgeneric calculator-calc-results (calculator responses))
(defgeneric calculator-group-stats (calculator results))
SUS
(defun sus-recode-values (values)
(let ((counter 0))
(mapcar (lambda (x)
(setq counter (1+ counter))
(if (evenp counter)
(- 5 x)
(1- x)))
values)))
(defun sus-values-with-score (values)
(let ((recoded-values (sus-recode-values values)))
(reverse (cons (* (apply #'+ recoded-values) 2.5) (reverse values)))))
(defclass sus-calculator (calculator) ())
(defmethod calculator-calc-results ((calc sus-calculator) responses)
(loop :for response :in responses
:for timestamp = (first response)
:for extracted-values = (mapcar #'string-integer
(mapcar #'cdr
(cdr response)))
:collect (cons timestamp (sus-values-with-score extracted-values))))
(defmethod calculator-group-stats ((calc sus-calculator) results)
nil)
VISAWI
Aggregate Values Per Category
The following list shows an example of an response entry from the VISAWI questionnaire:
(defparameter %example-visawi-response-entry%
'(("group-simplicity-1-r" . "1-stimme-gar-nicht-zu")
("group-simplicity-2" . "3-stimme-eher-nicht-zu")
("group-simplicity-3" . "3-stimme-eher-nicht-zu")
("group-simplicity-4-r" . "7-stimme-voll-zu")
("group-simplicity-5" . "4-neutral")
("group-diversity-1-r" . "3-stimme-eher-nicht-zu")
("group-diversity-2" . "4-neutral")
("group-diversity-3-r" . "5-stimme-eher-zu")
("group-diversity-4" . "6-stimme-zu")
("group-diversity-5" . "2-stimme-nicht-zu")
("group-colorfulness-1" . "6-stimme-zu")
("group-colorfulness-2-r" . "2-stimme-nicht-zu")
("group-colorfulness-3-r" . "6-stimme-zu")
("group-colorfulness-4" . "3-stimme-eher-nicht-zu")
("group-craftsmanship-1" . "3-stimme-eher-nicht-zu")
("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
("group-craftsmanship-3" . "2-stimme-nicht-zu")
("group-craftsmanship-4-r" . "3-stimme-eher-nicht-zu")))
This list of key/value entries is then processed by visawi-entry-process
to
extract the category and the corresponding value. For the VISAWI we need to
recode the value if it is related to a negative question.
(defun visawi-value-matching (value)
(case value
(1 7)
(2 6)
(3 5)
(4 4)
(5 3)
(6 2)
(7 1)))
(defun visawi-recode-value (value negative-p)
"Recode response score from negative question.
VALUE is a integer.
NEGATIVE-P is a Predicate.
Returns an integer."
(if negative-p
(visawi-value-matching value)
value))
(defun visawi-entry-process (entry)
(response-entry-process #'visawi-recode-value entry))
(mapcar #'visawi-entry-process %example-visawi-response-entry%)
((:SIMPLICITY (7)) (:SIMPLICITY (3)) (:SIMPLICITY (3)) (:SIMPLICITY (1)) (:SIMPLICITY (4)) (:DIVERSITY (5)) (:DIVERSITY (4)) (:DIVERSITY (3)) (:DIVERSITY (6)) (:DIVERSITY (2)) (:COLORFULNESS (6)) (:COLORFULNESS (6)) (:COLORFULNESS (2)) (:COLORFULNESS (3)) (:CRAFTSMANSHIP (3)) (:CRAFTSMANSHIP (7)) (:CRAFTSMANSHIP (2)) (:CRAFTSMANSHIP (5)))
After processing the raw response entries we get a plist with the value as list which is import for the next step where we want to merge the values per categories.
(defun merge-values-into-category (acc new-value)
(let ((cat-name (first new-value))
(value (second new-value)))
(setf (getf acc cat-name)
(append (getf acc cat-name) value))
acc))
(defun aggregate-values-per-category (entry-fn responses)
(reduce #'merge-values-into-category
(mapcar entry-fn responses)
:initial-value '()))
The result is a plist with aggregated values for every category.
(defparameter %return-aggregate-values-per-category%
'(:CRAFTSMANSHIP (3 7 2 5)
:COLORFULNESS (6 6 2 3)
:DIVERSITY (5 4 3 6 2)
:SIMPLICITY (7 3 3 1 4)))
Calculate Mean Score Per Category
(defun mean-score-per-category (entry-fn responses)
(let* ((values-per-category (aggregate-values-per-category entry-fn responses))
(mean-scores (loop for (category values) on values-per-category by #'cddr
collect category
collect (* 1.0 (mean values))))
(overall-mean (* 1.0 (mean (loop for (nil score) on mean-scores by #'cddr
collect score)))))
(append mean-scores (list :overall overall-mean))))
Calc Results
(defclass visawi-calculator (calculator) ())
(defmethod calculator-calc-results ((calc visawi-calculator) responses)
(loop :for response :in responses
:for timestamp = (first response)
:collect (cons :timestamp
(cons timestamp (mean-score-per-category #'visawi-entry-process
(rest response))))))
(defparameter %example-calculator-calc-results%
'((:TIMESTAMP "2024-10-10 10:42:01" :CRAFTSMANSHIP 3.75 :COLORFULNESS 4.5 :DIVERSITY 4.4 :SIMPLICITY 5.6 :OVERALL 4.5625)
(:TIMESTAMP "2024-10-10 10:41:34" :CRAFTSMANSHIP 4.25 :COLORFULNESS 4.25 :DIVERSITY 4.0 :SIMPLICITY 3.6 :OVERALL 4.025)))
%EXAMPLE-CALCULATOR-CALC-RESULTS%
(defmethod calculator-group-stats ((calc visawi-calculator) results)
(if (> (length results) 1)
(let* ((transposed-data (preprocess-and-transpose results))
(dimensions '("simplicity" "diversity" "colorfulness" "craftsmanship" "mean"))
(results (mapcar #'calculate-statistics transposed-data)))
(mapcar #'cons dimensions results))
nil))
(defmethod calculator-group-stats ((calc visawi-calculator) results)
(if (> (length results) 1)
(flet ((merge-entry (acc entry)
(loop :for (key value) :on entry :by #'cddr
:do (push value (getf acc key)))
acc))
(let ((merged-values (reduce #'merge-entry
(mapcar #'cddr results)
:initial-value '())))
(reverse (loop :for (key value) :on merged-values :by #'cddr
:collect (list key (calculate-statistics value))))))
nil))
((:CRAFTSMANSHIP (:MEDIAN 4.0 :MEAN 4.0 :SD 0.35355338 :MIN 3.75 :MAX 4.25))
(:COLORFULNESS (:MEDIAN 4.375 :MEAN 4.375 :SD 0.17677669 :MIN 4.25 :MAX 4.5))
(:DIVERSITY (:MEDIAN 4.2 :MEAN 4.2 :SD 0.2828428 :MIN 4.0 :MAX 4.4))
(:SIMPLICITY (:MEDIAN 4.6 :MEAN 4.6 :SD 1.4142135 :MIN 3.6 :MAX 5.6))
(:OVERALL
(:MEDIAN 4.29375 :MEAN 4.29375 :SD 0.38006982 :MIN 4.025 :MAX 4.5625)))
Displayer
(defclass displayer () ())
(defgeneric displayer-generate-html (displayer results &optional group-stats))
(defclass sus-displayer (displayer) ())
(defmethod displayer-generate-html ((disp sus-displayer) results &optional group-stats)
(with-section (with-title-bar "SUS Results")
(render-tbl '("Timestamp" "Q1" "Q2" "Q3" "Q4" "Q5" "Q6" "Q7" "Q8" "Q9" "Q10" "Score")
results)))
(defclass visawi-displayer (displayer) ())
(defmethod displayer-generate-html ((disp visawi-displayer) results &optional group-stats)
(with-section (with-title-bar "VISAWI Results")
(with-section (with-title-bar "Individual Analysis")
(render-kwlist-tbl results))
(when group-stats
(with-section (with-title-bar "Group Analysis")
(render-nested-plist-tbl group-stats)))))
SUS Instance
(defclass sus-assessment (assessment) ())
(defmethod initialize-instance :after ((a sus-assessment) &key)
(setf (assessment-calculator a) (make-instance 'sus-calculator)
(assessment-displayer a) (make-instance 'sus-displayer)))
VISAWI Instance
(defclass visawi-assessment (assessment) ())
(defmethod initialize-instance :after ((a visawi-assessment) &key)
(setf (assessment-calculator a) (make-instance 'visawi-calculator)
(assessment-displayer a) (make-instance 'visawi-displayer)))