boris-behav-obs 8.12__py3-none-any.whl → 9.7.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boris/__init__.py +1 -1
- boris/__main__.py +1 -1
- boris/about.py +28 -39
- boris/add_modifier.py +122 -109
- boris/add_modifier_ui.py +239 -135
- boris/advanced_event_filtering.py +81 -45
- boris/analysis_plugins/__init__.py +0 -0
- boris/analysis_plugins/_latency.py +59 -0
- boris/analysis_plugins/irr_cohen_kappa.py +109 -0
- boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
- boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
- boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
- boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
- boris/analysis_plugins/number_of_occurences.py +22 -0
- boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
- boris/analysis_plugins/time_budget.py +61 -0
- boris/behav_coding_map_creator.py +228 -229
- boris/behavior_binary_table.py +33 -50
- boris/behaviors_coding_map.py +17 -18
- boris/boris_cli.py +6 -25
- boris/cmd_arguments.py +12 -1
- boris/coding_pad.py +42 -49
- boris/config.py +141 -65
- boris/config_file.py +58 -67
- boris/connections.py +107 -61
- boris/converters.py +13 -37
- boris/converters_ui.py +187 -110
- boris/cooccurence.py +250 -0
- boris/core.py +2373 -1786
- boris/core_qrc.py +15895 -10743
- boris/core_ui.py +943 -798
- boris/db_functions.py +17 -42
- boris/dev.py +109 -8
- boris/dialog.py +482 -236
- boris/duration_widget.py +9 -14
- boris/edit_event.py +61 -31
- boris/edit_event_ui.py +208 -97
- boris/event_operations.py +408 -293
- boris/events_cursor.py +25 -17
- boris/events_snapshots.py +36 -82
- boris/exclusion_matrix.py +4 -9
- boris/export_events.py +184 -223
- boris/export_observation.py +74 -100
- boris/external_processes.py +123 -98
- boris/geometric_measurement.py +644 -290
- boris/gui_utilities.py +91 -14
- boris/image_overlay.py +4 -4
- boris/import_observations.py +190 -98
- boris/ipc_mpv.py +325 -0
- boris/irr.py +20 -57
- boris/latency.py +31 -24
- boris/measurement_widget.py +14 -18
- boris/media_file.py +17 -19
- boris/menu_options.py +17 -6
- boris/modifier_coding_map_creator.py +1013 -0
- boris/modifiers_coding_map.py +7 -9
- boris/mpv.py +1 -0
- boris/mpv2.py +732 -705
- boris/observation.py +533 -221
- boris/observation_operations.py +1025 -390
- boris/observation_ui.py +572 -362
- boris/observations_list.py +71 -53
- boris/otx_parser.py +74 -68
- boris/param_panel.py +31 -16
- boris/param_panel_ui.py +254 -138
- boris/player_dock_widget.py +90 -60
- boris/plot_data_module.py +25 -33
- boris/plot_events.py +127 -90
- boris/plot_events_rt.py +17 -31
- boris/plot_spectrogram_rt.py +95 -30
- boris/plot_waveform_rt.py +32 -21
- boris/plugins.py +431 -0
- boris/portion/__init__.py +18 -8
- boris/portion/const.py +35 -18
- boris/portion/dict.py +5 -5
- boris/portion/func.py +2 -2
- boris/portion/interval.py +21 -41
- boris/portion/io.py +41 -32
- boris/preferences.py +306 -83
- boris/preferences_ui.py +684 -227
- boris/project.py +448 -293
- boris/project_functions.py +671 -238
- boris/project_import_export.py +213 -222
- boris/project_ui.py +674 -438
- boris/qrc_boris.py +6 -3
- boris/qrc_boris5.py +6 -3
- boris/select_modifiers.py +74 -48
- boris/select_observations.py +20 -198
- boris/select_subj_behav.py +67 -39
- boris/state_events.py +52 -35
- boris/subjects_pad.py +6 -9
- boris/synthetic_time_budget.py +45 -28
- boris/time_budget_functions.py +171 -171
- boris/time_budget_widget.py +84 -114
- boris/transitions.py +41 -47
- boris/utilities.py +627 -236
- boris/version.py +3 -3
- boris/video_equalizer.py +16 -14
- boris/video_equalizer_ui.py +199 -130
- boris/video_operations.py +95 -29
- boris/view_df.py +104 -0
- boris/view_df_ui.py +75 -0
- boris/write_event.py +538 -0
- boris_behav_obs-9.7.6.dist-info/METADATA +139 -0
- boris_behav_obs-9.7.6.dist-info/RECORD +109 -0
- {boris_behav_obs-8.12.dist-info → boris_behav_obs-9.7.6.dist-info}/WHEEL +1 -1
- boris_behav_obs-9.7.6.dist-info/entry_points.txt +2 -0
- boris/README.TXT +0 -22
- boris/add_modifier.ui +0 -323
- boris/converters.ui +0 -289
- boris/core.qrc +0 -36
- boris/core.ui +0 -1556
- boris/edit_event.ui +0 -233
- boris/icons/logo_eye.ico +0 -0
- boris/map_creator.py +0 -850
- boris/observation.ui +0 -814
- boris/param_panel.ui +0 -379
- boris/preferences.ui +0 -537
- boris/project.ui +0 -1069
- boris/project_server.py +0 -236
- boris/vlc.py +0 -10343
- boris/vlc_local.py +0 -90
- boris_behav_obs-8.12.dist-info/LICENSE.TXT +0 -674
- boris_behav_obs-8.12.dist-info/METADATA +0 -128
- boris_behav_obs-8.12.dist-info/RECORD +0 -108
- boris_behav_obs-8.12.dist-info/entry_points.txt +0 -3
- {boris → boris_behav_obs-9.7.6.dist-info/licenses}/LICENSE.TXT +0 -0
- {boris_behav_obs-8.12.dist-info → boris_behav_obs-9.7.6.dist-info}/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
BORIS
|
|
3
3
|
Behavioral Observation Research Interactive Software
|
|
4
|
-
Copyright 2012-
|
|
4
|
+
Copyright 2012-2025 Olivier Friard
|
|
5
5
|
|
|
6
6
|
This program is free software; you can redistribute it and/or modify
|
|
7
7
|
it under the terms of the GNU General Public License as published by
|
|
@@ -19,16 +19,15 @@ Copyright 2012-2023 Olivier Friard
|
|
|
19
19
|
MA 02110-1301, USA.
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
-
from decimal import Decimal as dec
|
|
23
22
|
import pathlib
|
|
24
23
|
import re
|
|
25
24
|
import statistics
|
|
26
25
|
import sys
|
|
27
26
|
|
|
28
27
|
import tablib
|
|
29
|
-
from
|
|
30
|
-
from
|
|
31
|
-
from
|
|
28
|
+
from PySide6.QtCore import Qt
|
|
29
|
+
from PySide6.QtGui import QIcon
|
|
30
|
+
from PySide6.QtWidgets import (
|
|
32
31
|
QDialog,
|
|
33
32
|
QFileDialog,
|
|
34
33
|
QHBoxLayout,
|
|
@@ -78,8 +77,14 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
78
77
|
Dialog for visualizing advanced event filtering results
|
|
79
78
|
"""
|
|
80
79
|
|
|
81
|
-
summary_header =
|
|
82
|
-
|
|
80
|
+
summary_header: tuple = (
|
|
81
|
+
"Observation id",
|
|
82
|
+
"Number of occurences",
|
|
83
|
+
"Total duration (s)",
|
|
84
|
+
"Duration mean (s)",
|
|
85
|
+
"Std Dev",
|
|
86
|
+
)
|
|
87
|
+
details_header: tuple = ("Observation id", "Comment", "Start time", "Stop time", "Duration (s)")
|
|
83
88
|
|
|
84
89
|
def __init__(self, events):
|
|
85
90
|
super().__init__()
|
|
@@ -90,10 +95,11 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
90
95
|
|
|
91
96
|
vbox = QVBoxLayout()
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
vbox.addWidget(
|
|
98
|
+
self.lb_time_interval = QLabel()
|
|
99
|
+
vbox.addWidget(self.lb_time_interval)
|
|
95
100
|
|
|
96
101
|
hbox = QHBoxLayout()
|
|
102
|
+
hbox.addWidget(QLabel("Filter"))
|
|
97
103
|
self.logic = QLineEdit("")
|
|
98
104
|
hbox.addWidget(self.logic)
|
|
99
105
|
self.pb_filter = QPushButton("Filter events", clicked=self.filter)
|
|
@@ -149,7 +155,7 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
149
155
|
hbox.addItem(QSpacerItem(241, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
|
|
150
156
|
self.pb_save = QPushButton("Save results", clicked=self.save_results)
|
|
151
157
|
hbox.addWidget(self.pb_save)
|
|
152
|
-
self.pb_close = QPushButton(
|
|
158
|
+
self.pb_close = QPushButton(cfg.CLOSE, clicked=self.close)
|
|
153
159
|
hbox.addWidget(self.pb_close)
|
|
154
160
|
vbox.addLayout(hbox)
|
|
155
161
|
|
|
@@ -183,7 +189,6 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
183
189
|
add selected logic operaton to lineedit
|
|
184
190
|
"""
|
|
185
191
|
if self.lw3.currentItem():
|
|
186
|
-
|
|
187
192
|
text = ""
|
|
188
193
|
if self.lw3.currentItem().text() == "AND":
|
|
189
194
|
text = " & "
|
|
@@ -201,7 +206,7 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
201
206
|
if not self.logic.text():
|
|
202
207
|
return
|
|
203
208
|
if self.logic.text().count('"') % 2:
|
|
204
|
-
QMessageBox.warning(self, cfg.programName,
|
|
209
|
+
QMessageBox.warning(self, cfg.programName, 'Wrong number of double quotes (")')
|
|
205
210
|
return
|
|
206
211
|
|
|
207
212
|
sb_list = re.findall('"([^"]*)"', self.logic.text())
|
|
@@ -221,25 +226,22 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
221
226
|
if not i.empty:
|
|
222
227
|
self.out.append([obs_id, "", f"{i.lower}", f"{i.upper}", f"{i.upper - i.lower:.3f}"])
|
|
223
228
|
except KeyError:
|
|
224
|
-
self.out.append([obs_id, "subject / behavior not found",
|
|
229
|
+
self.out.append([obs_id, "subject / behavior not found", cfg.NA, cfg.NA, cfg.NA])
|
|
225
230
|
except Exception:
|
|
226
|
-
|
|
227
231
|
error_type, _, _ = util.error_info(sys.exc_info())
|
|
228
|
-
self.out.append([obs_id, f"Error in {self.logic.text()}: {error_type} ",
|
|
232
|
+
self.out.append([obs_id, f"Error in {self.logic.text()}: {error_type} ", cfg.NA, cfg.NA, cfg.NA])
|
|
229
233
|
flag_error = True
|
|
230
234
|
|
|
231
235
|
self.tw.clear()
|
|
232
236
|
|
|
233
237
|
if flag_error or self.rb_details.isChecked():
|
|
234
|
-
|
|
235
|
-
self.lb_results.setText(f"Results ({len(self.out)} event{'s'*(len(self.out) > 1)})")
|
|
238
|
+
self.lb_results.setText(f"Results ({len(self.out)} event{'s' * (len(self.out) > 1)})")
|
|
236
239
|
|
|
237
240
|
self.tw.setRowCount(len(self.out))
|
|
238
241
|
self.tw.setColumnCount(len(self.details_header)) # obs_id, comment, start, stop, duration
|
|
239
242
|
self.tw.setHorizontalHeaderLabels(self.details_header)
|
|
240
243
|
|
|
241
244
|
if not flag_error and self.rb_summary.isChecked():
|
|
242
|
-
|
|
243
245
|
summary = {}
|
|
244
246
|
for row in self.out:
|
|
245
247
|
obs_id, _, start, stop, duration = row
|
|
@@ -249,7 +251,6 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
249
251
|
|
|
250
252
|
self.out = []
|
|
251
253
|
for obs_id in summary:
|
|
252
|
-
|
|
253
254
|
self.out.append(
|
|
254
255
|
[
|
|
255
256
|
obs_id,
|
|
@@ -260,7 +261,7 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
260
261
|
]
|
|
261
262
|
)
|
|
262
263
|
|
|
263
|
-
self.lb_results.setText(f"Results ({len(summary)} observation{'s'*(len(summary) > 1)})")
|
|
264
|
+
self.lb_results.setText(f"Results ({len(summary)} observation{'s' * (len(summary) > 1)})")
|
|
264
265
|
self.tw.setRowCount(len(summary))
|
|
265
266
|
self.tw.setColumnCount(len(self.summary_header)) # obs_id, mean, stdev
|
|
266
267
|
self.tw.setHorizontalHeaderLabels(self.summary_header)
|
|
@@ -297,9 +298,7 @@ class Advanced_event_filtering_dialog(QDialog):
|
|
|
297
298
|
# check if file with new extension already exists
|
|
298
299
|
if pathlib.Path(file_name).is_file():
|
|
299
300
|
if (
|
|
300
|
-
dialog.MessageDialog(
|
|
301
|
-
cfg.programName, f"The file {file_name} already exists.", [cfg.CANCEL, cfg.OVERWRITE]
|
|
302
|
-
)
|
|
301
|
+
dialog.MessageDialog(cfg.programName, f"The file {file_name} already exists.", [cfg.CANCEL, cfg.OVERWRITE])
|
|
303
302
|
== cfg.CANCEL
|
|
304
303
|
):
|
|
305
304
|
return
|
|
@@ -351,22 +350,27 @@ def event_filtering(self):
|
|
|
351
350
|
)
|
|
352
351
|
return
|
|
353
352
|
|
|
354
|
-
max_media_duration_all_obs, _ = observation_operations.media_duration(
|
|
355
|
-
|
|
356
|
-
)
|
|
353
|
+
max_media_duration_all_obs, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], selected_observations)
|
|
354
|
+
|
|
355
|
+
start_interval, end_interval = observation_operations.time_intervals_range(self.pj[cfg.OBSERVATIONS], selected_observations)
|
|
357
356
|
|
|
358
357
|
parameters = select_subj_behav.choose_obs_subj_behav_category(
|
|
359
358
|
self,
|
|
360
359
|
selected_observations,
|
|
361
360
|
start_coding=start_coding,
|
|
362
361
|
end_coding=end_coding,
|
|
362
|
+
# start_interval=start_interval,
|
|
363
|
+
# end_interval=end_interval,
|
|
364
|
+
start_interval=None,
|
|
365
|
+
end_interval=None,
|
|
363
366
|
maxTime=max_media_duration_all_obs,
|
|
364
|
-
|
|
365
|
-
|
|
367
|
+
show_include_modifiers=False,
|
|
368
|
+
show_exclude_non_coded_behaviors=False,
|
|
366
369
|
by_category=False,
|
|
367
370
|
n_observations=len(selected_observations),
|
|
368
371
|
)
|
|
369
|
-
|
|
372
|
+
|
|
373
|
+
if not parameters:
|
|
370
374
|
return
|
|
371
375
|
|
|
372
376
|
if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
|
|
@@ -379,29 +383,58 @@ def event_filtering(self):
|
|
|
379
383
|
|
|
380
384
|
cursor = db_connector.cursor()
|
|
381
385
|
|
|
382
|
-
if parameters[cfg.TIME_INTERVAL]
|
|
386
|
+
if parameters[cfg.TIME_INTERVAL] in (cfg.TIME_EVENTS, cfg.TIME_FULL_OBS):
|
|
383
387
|
cursor.execute("SELECT MIN(start), MAX(stop) FROM aggregated_events")
|
|
384
388
|
min_time, max_time = cursor.fetchone()
|
|
385
389
|
|
|
390
|
+
if parameters[cfg.TIME_INTERVAL] in (cfg.TIME_ARBITRARY_INTERVAL, cfg.TIME_OBS_INTERVAL):
|
|
391
|
+
min_time = float(parameters[cfg.START_TIME])
|
|
392
|
+
max_time = float(parameters[cfg.END_TIME])
|
|
393
|
+
|
|
394
|
+
cursor.execute(
|
|
395
|
+
"UPDATE aggregated_events SET start = ? WHERE start < ? AND stop BETWEEN ? AND ?",
|
|
396
|
+
(
|
|
397
|
+
min_time,
|
|
398
|
+
min_time,
|
|
399
|
+
min_time,
|
|
400
|
+
max_time,
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
cursor.execute(
|
|
404
|
+
"UPDATE aggregated_events SET stop = ? WHERE stop > ? AND start BETWEEN ? AND ?",
|
|
405
|
+
(
|
|
406
|
+
max_time,
|
|
407
|
+
max_time,
|
|
408
|
+
min_time,
|
|
409
|
+
max_time,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
cursor.execute(
|
|
413
|
+
"UPDATE aggregated_events SET start = ?, stop = ? WHERE start < ? AND stop > ?",
|
|
414
|
+
(
|
|
415
|
+
min_time,
|
|
416
|
+
max_time,
|
|
417
|
+
min_time,
|
|
418
|
+
max_time,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
cursor.execute(
|
|
423
|
+
"DELETE FROM aggregated_events WHERE (start < ? AND stop < ?) OR (start > ? AND stop > ?)",
|
|
424
|
+
(
|
|
425
|
+
min_time,
|
|
426
|
+
min_time,
|
|
427
|
+
max_time,
|
|
428
|
+
max_time,
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
386
432
|
# create intervals from DB
|
|
387
433
|
cursor.execute("SELECT observation, subject, behavior, start, stop FROM aggregated_events")
|
|
388
434
|
|
|
389
|
-
events = {}
|
|
435
|
+
events: dict = {}
|
|
390
436
|
for row in cursor.fetchall():
|
|
391
|
-
|
|
392
437
|
obs, subj, behav, start, stop = row
|
|
393
|
-
# check if start and stop are in selected time interval
|
|
394
|
-
if stop < min_time:
|
|
395
|
-
continue
|
|
396
|
-
if start > max_time:
|
|
397
|
-
continue
|
|
398
|
-
"""
|
|
399
|
-
if start < min_time:
|
|
400
|
-
start = float(parameters[cfg.START_TIME])
|
|
401
|
-
if stop > parameters[cfg.END_TIME]:
|
|
402
|
-
stop = float(parameters[cfg.END_TIME])
|
|
403
|
-
"""
|
|
404
|
-
|
|
405
438
|
if obs not in events:
|
|
406
439
|
events[obs] = {}
|
|
407
440
|
|
|
@@ -413,7 +446,10 @@ def event_filtering(self):
|
|
|
413
446
|
events[obs][f"{subj}|{behav}"] = interval_func([start, stop])
|
|
414
447
|
else:
|
|
415
448
|
# append to existing interval
|
|
416
|
-
events[obs][f"{subj}|{behav}"]
|
|
449
|
+
events[obs][f"{subj}|{behav}"] |= interval_func([start, stop])
|
|
417
450
|
|
|
418
451
|
w = Advanced_event_filtering_dialog(events)
|
|
452
|
+
w.lb_time_interval.setText(
|
|
453
|
+
(f"Time interval: {util.smart_time_format(min_time, self.timeFormat)} - {util.smart_time_format(max_time, self.timeFormat)}")
|
|
454
|
+
)
|
|
419
455
|
w.exec_()
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BORIS plugin
|
|
3
|
+
|
|
4
|
+
number of occurences of behaviors
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
|
10
|
+
__version_date__ = "2025-04-10"
|
|
11
|
+
__plugin_name__ = "Behavior latencyxxx"
|
|
12
|
+
__author__ = "Olivier Friard - University of Torino - Italy"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(df: pd.DataFrame):
|
|
19
|
+
"""
|
|
20
|
+
Latency of a behavior after another.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
df["start_time"] = pd.to_datetime(df["Start (s)"])
|
|
24
|
+
df["end_time"] = pd.to_datetime(df["Stop (s)"])
|
|
25
|
+
|
|
26
|
+
latency_by_subject: dict = {}
|
|
27
|
+
|
|
28
|
+
for subject, group in df.groupby("subject"):
|
|
29
|
+
behaviors = group["behavior"].tolist()
|
|
30
|
+
# combinations = []
|
|
31
|
+
# Utiliser itertools pour créer des combinaisons 2 à 2 des comportements
|
|
32
|
+
for comb in itertools.combinations(behaviors, 2):
|
|
33
|
+
# combinations.append(comb)
|
|
34
|
+
|
|
35
|
+
last_A_end_time = None
|
|
36
|
+
|
|
37
|
+
# Liste pour stocker les latences de chaque sujet
|
|
38
|
+
subject_latency = []
|
|
39
|
+
|
|
40
|
+
for index, row in group.iterrows():
|
|
41
|
+
if row["behavior"] == comb[0]:
|
|
42
|
+
# Si on rencontre un comportement A, on réinitialise le temps de fin du comportement A
|
|
43
|
+
last_A_end_time = row["end_time"]
|
|
44
|
+
subject_latency.append(None) # Pas de latence pour A
|
|
45
|
+
elif row["behavior"] == comb[1] and last_A_end_time is not None:
|
|
46
|
+
# Si on rencontre un comportement B et qu'on a déjà vu un A avant
|
|
47
|
+
latency_time = row["start_time"] - last_A_end_time
|
|
48
|
+
subject_latency.append(latency_time)
|
|
49
|
+
else:
|
|
50
|
+
# Si on rencontre un B mais sans A avant
|
|
51
|
+
subject_latency.append(None)
|
|
52
|
+
|
|
53
|
+
# Ajout des latences calculées au DataFrame
|
|
54
|
+
df.loc[group.index, f"latency {comb[1]} after {comb[0]}"] = subject_latency
|
|
55
|
+
|
|
56
|
+
# Calcul de la latence totale ou moyenne par sujet
|
|
57
|
+
latency_by_subject[(subject, comb)] = df.groupby("subject")["latency"].agg(["sum", "mean"])
|
|
58
|
+
|
|
59
|
+
return str(latency_by_subject)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BORIS plugin
|
|
3
|
+
|
|
4
|
+
Inter Rater Reliability (IRR) Unweighted Cohen's Kappa
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from typing import Dict, Tuple
|
|
9
|
+
|
|
10
|
+
from sklearn.metrics import cohen_kappa_score
|
|
11
|
+
from PySide6.QtWidgets import QInputDialog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__version__ = "0.0.3"
|
|
15
|
+
__version_date__ = "2025-09-02"
|
|
16
|
+
__plugin_name__ = "Inter Rater Reliability - Unweighted Cohen's Kappa"
|
|
17
|
+
__author__ = "Olivier Friard - University of Torino - Italy"
|
|
18
|
+
__description__ = """
|
|
19
|
+
This plugin calculates Cohen's Kappa to measure inter-rater reliability between two observers who code categorical behaviors over time intervals.
|
|
20
|
+
Unlike the weighted version, this approach does not take into account the duration of the intervals.
|
|
21
|
+
Each segment of time is treated equally, regardless of how long it lasts.
|
|
22
|
+
This plugin does not take into account the modifiers.
|
|
23
|
+
|
|
24
|
+
How it works:
|
|
25
|
+
|
|
26
|
+
Time segmentation
|
|
27
|
+
The program identifies all the time boundaries (start and end points) used by both observers.
|
|
28
|
+
These boundaries are merged into a common timeline, which is then divided into a set of non-overlapping elementary intervals.
|
|
29
|
+
|
|
30
|
+
Assigning codes
|
|
31
|
+
For each elementary interval, the program determines which behavior was coded by each observer.
|
|
32
|
+
|
|
33
|
+
Comparison of codes
|
|
34
|
+
The program builds two parallel lists of behavior codes, one for each observer.
|
|
35
|
+
Each elementary interval is counted as one unit of observation, no matter how long the interval actually lasts.
|
|
36
|
+
|
|
37
|
+
Cohen's Kappa calculation
|
|
38
|
+
Using these two lists, the program computes Cohen's Kappa using the cohen_kappa_score function of the sklearn package.
|
|
39
|
+
(see https://scikit-learn.org/stable/modules/generated/sklearn.metrics.cohen_kappa_score.html for details)
|
|
40
|
+
This coefficient measures how much the observers agree on their coding, adjusted for the amount of agreement that would be expected by chance.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(df: pd.DataFrame) -> pd.DataFrame:
|
|
46
|
+
"""
|
|
47
|
+
Calculate the Inter Rater Reliability - Unweighted Cohen's Kappa
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Attribute all active codes for each interval
|
|
51
|
+
def get_code(t_start, obs):
|
|
52
|
+
active_codes = [seg[2] for seg in obs if seg[0] <= t_start < seg[1]]
|
|
53
|
+
if not active_codes:
|
|
54
|
+
return ""
|
|
55
|
+
# Sort to ensure deterministic representation (e.g., "A+B" instead of "B+A")
|
|
56
|
+
return "+".join(sorted(active_codes))
|
|
57
|
+
|
|
58
|
+
# ask user for the number of decimal places for rounding (can be negative)
|
|
59
|
+
round_decimals, ok = QInputDialog.getInt(
|
|
60
|
+
None, "Rounding", "Enter the number of decimal places for rounding (can be negative)", value=3, minValue=-5, maxValue=3, step=1
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# round times
|
|
64
|
+
df["Start (s)"] = df["Start (s)"].round(round_decimals)
|
|
65
|
+
df["Stop (s)"] = df["Stop (s)"].round(round_decimals)
|
|
66
|
+
|
|
67
|
+
# Get unique values
|
|
68
|
+
unique_obs_list = df["Observation id"].unique().tolist()
|
|
69
|
+
|
|
70
|
+
# Convert to tuples grouped by observation
|
|
71
|
+
grouped = {
|
|
72
|
+
obs: [
|
|
73
|
+
(row[0], row[1], row[2] + "|" + row[3]) # concatenate subject and behavior with |
|
|
74
|
+
for row in group[["Start (s)", "Stop (s)", "Subject", "Behavior"]].itertuples(index=False, name=None)
|
|
75
|
+
]
|
|
76
|
+
for obs, group in df.groupby("Observation id")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ck_results: Dict[Tuple[str, str], str] = {}
|
|
80
|
+
for idx1, obs_id1 in enumerate(unique_obs_list):
|
|
81
|
+
obs1 = grouped[obs_id1]
|
|
82
|
+
|
|
83
|
+
# Perfect agreement with itself
|
|
84
|
+
ck_results[(obs_id1, obs_id1)] = "1.000"
|
|
85
|
+
|
|
86
|
+
for obs_id2 in unique_obs_list[idx1 + 1 :]:
|
|
87
|
+
obs2 = grouped[obs_id2]
|
|
88
|
+
|
|
89
|
+
# get all the break points
|
|
90
|
+
time_points = sorted(set([t for seg in obs1 for t in seg[:2]] + [t for seg in obs2 for t in seg[:2]]))
|
|
91
|
+
|
|
92
|
+
# elementary intervals
|
|
93
|
+
elementary_intervals = [(time_points[i], time_points[i + 1]) for i in range(len(time_points) - 1)]
|
|
94
|
+
|
|
95
|
+
obs1_codes = [get_code(t[0], obs1) for t in elementary_intervals]
|
|
96
|
+
|
|
97
|
+
obs2_codes = [get_code(t[0], obs2) for t in elementary_intervals]
|
|
98
|
+
|
|
99
|
+
# Cohen's Kappa
|
|
100
|
+
kappa = cohen_kappa_score(obs1_codes, obs2_codes)
|
|
101
|
+
print(f"{obs_id1} - {obs_id2}: Cohen's Kappa : {kappa:.3f}")
|
|
102
|
+
|
|
103
|
+
ck_results[(obs_id1, obs_id2)] = f"{kappa:.3f}"
|
|
104
|
+
ck_results[(obs_id2, obs_id1)] = f"{kappa:.3f}"
|
|
105
|
+
|
|
106
|
+
# DataFrame conversion
|
|
107
|
+
df_results = pd.Series(ck_results).unstack()
|
|
108
|
+
|
|
109
|
+
return df_results
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BORIS plugin
|
|
3
|
+
|
|
4
|
+
Inter Rater Reliability (IRR) Unweighted Cohen's Kappa with modifiers
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from sklearn.metrics import cohen_kappa_score
|
|
10
|
+
from PySide6.QtWidgets import QInputDialog
|
|
11
|
+
|
|
12
|
+
__version__ = "0.0.3"
|
|
13
|
+
__version_date__ = "2025-09-02"
|
|
14
|
+
__plugin_name__ = "Inter Rater Reliability - Unweighted Cohen's Kappa with modifiers"
|
|
15
|
+
__author__ = "Olivier Friard - University of Torino - Italy"
|
|
16
|
+
__description__ = """
|
|
17
|
+
This plugin calculates Cohen's Kappa to measure inter-rater reliability between two observers who code categorical behaviors over time intervals.
|
|
18
|
+
Unlike the weighted version, this approach does not take into account the duration of the intervals.
|
|
19
|
+
Each segment of time is treated equally, regardless of how long it lasts.
|
|
20
|
+
This plugin takes into account the modifiers.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
How it works:
|
|
24
|
+
|
|
25
|
+
Time segmentation
|
|
26
|
+
The program identifies all the time boundaries (start and end points) used by both observers.
|
|
27
|
+
These boundaries are merged into a common timeline, which is then divided into a set of non-overlapping elementary intervals.
|
|
28
|
+
|
|
29
|
+
Assigning codes
|
|
30
|
+
For each elementary interval, the program determines which behavior was coded by each observer.
|
|
31
|
+
|
|
32
|
+
Comparison of codes
|
|
33
|
+
The program builds two parallel lists of behavior codes, one for each observer.
|
|
34
|
+
Each elementary interval is counted as one unit of observation, no matter how long the interval actually lasts.
|
|
35
|
+
|
|
36
|
+
Cohen's Kappa calculation
|
|
37
|
+
Using these two lists, the program computes Cohen's Kappa using the cohen_kappa_score function of the sklearn package.
|
|
38
|
+
(see https://scikit-learn.org/stable/modules/generated/sklearn.metrics.cohen_kappa_score.html for details)
|
|
39
|
+
This coefficient measures how much the observers agree on their coding, adjusted for the amount of agreement that would be expected by chance.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(df: pd.DataFrame):
|
|
45
|
+
"""
|
|
46
|
+
Calculate the Inter Rater Reliability - Unweighted Cohen's Kappa with modifiers
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Attribute all active codes for each interval
|
|
50
|
+
def get_code(t_start, obs):
|
|
51
|
+
active_codes = [seg[2] for seg in obs if seg[0] <= t_start < seg[1]]
|
|
52
|
+
if not active_codes:
|
|
53
|
+
return ""
|
|
54
|
+
# Sort to ensure deterministic representation (e.g., "A+B" instead of "B+A")
|
|
55
|
+
return "+".join(sorted(active_codes))
|
|
56
|
+
|
|
57
|
+
# ask user for the number of decimal places for rounding (can be negative)
|
|
58
|
+
round_decimals, ok = QInputDialog.getInt(
|
|
59
|
+
None, "Rounding", "Enter the number of decimal places for rounding (can be negative)", value=3, minValue=-5, maxValue=3, step=1
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# round times
|
|
63
|
+
df["Start (s)"] = df["Start (s)"].round(round_decimals)
|
|
64
|
+
df["Stop (s)"] = df["Stop (s)"].round(round_decimals)
|
|
65
|
+
|
|
66
|
+
# Get unique values
|
|
67
|
+
unique_obs_list = df["Observation id"].unique().tolist()
|
|
68
|
+
|
|
69
|
+
# Convert to tuples grouped by observation
|
|
70
|
+
grouped: dict = {}
|
|
71
|
+
modifiers: list = []
|
|
72
|
+
for col in df.columns:
|
|
73
|
+
if isinstance(col, tuple):
|
|
74
|
+
modifiers.append(col)
|
|
75
|
+
|
|
76
|
+
for obs, group in df.groupby("Observation id"):
|
|
77
|
+
o: list = []
|
|
78
|
+
for row in group[["Start (s)", "Stop (s)", "Subject", "Behavior"] + modifiers].itertuples(index=False, name=None):
|
|
79
|
+
modif_list = [row[i] for idx, i in enumerate(range(4, 4 + len(modifiers))) if modifiers[idx][0] == row[3]]
|
|
80
|
+
o.append((row[0], row[1], row[2] + "|" + row[3] + "|" + ",".join(modif_list)))
|
|
81
|
+
grouped[obs] = o
|
|
82
|
+
|
|
83
|
+
ck_results: dict = {}
|
|
84
|
+
for idx1, obs_id1 in enumerate(unique_obs_list):
|
|
85
|
+
obs1 = grouped[obs_id1]
|
|
86
|
+
|
|
87
|
+
ck_results[(obs_id1, obs_id1)] = "1.000"
|
|
88
|
+
|
|
89
|
+
for obs_id2 in unique_obs_list[idx1 + 1 :]:
|
|
90
|
+
obs2 = grouped[obs_id2]
|
|
91
|
+
|
|
92
|
+
# get all the break points
|
|
93
|
+
time_points = sorted(set([t for seg in obs1 for t in seg[:2]] + [t for seg in obs2 for t in seg[:2]]))
|
|
94
|
+
|
|
95
|
+
# elementary intervals
|
|
96
|
+
elementary_intervals = [(time_points[i], time_points[i + 1]) for i in range(len(time_points) - 1)]
|
|
97
|
+
|
|
98
|
+
obs1_codes = [get_code(t[0], obs1) for t in elementary_intervals]
|
|
99
|
+
|
|
100
|
+
obs2_codes = [get_code(t[0], obs2) for t in elementary_intervals]
|
|
101
|
+
|
|
102
|
+
# Cohen's Kappa
|
|
103
|
+
kappa = cohen_kappa_score(obs1_codes, obs2_codes)
|
|
104
|
+
print(f"{obs_id1} - {obs_id2}: Cohen's Kappa : {kappa:.3f}")
|
|
105
|
+
|
|
106
|
+
ck_results[(obs_id1, obs_id2)] = f"{kappa:.3f}"
|
|
107
|
+
ck_results[(obs_id2, obs_id1)] = f"{kappa:.3f}"
|
|
108
|
+
|
|
109
|
+
# DataFrame conversion
|
|
110
|
+
df_results = pd.Series(ck_results).unstack()
|
|
111
|
+
|
|
112
|
+
return df_results
|