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.
Files changed (128) hide show
  1. boris/__init__.py +1 -1
  2. boris/__main__.py +1 -1
  3. boris/about.py +28 -39
  4. boris/add_modifier.py +122 -109
  5. boris/add_modifier_ui.py +239 -135
  6. boris/advanced_event_filtering.py +81 -45
  7. boris/analysis_plugins/__init__.py +0 -0
  8. boris/analysis_plugins/_latency.py +59 -0
  9. boris/analysis_plugins/irr_cohen_kappa.py +109 -0
  10. boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
  11. boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
  12. boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
  13. boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
  14. boris/analysis_plugins/number_of_occurences.py +22 -0
  15. boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
  16. boris/analysis_plugins/time_budget.py +61 -0
  17. boris/behav_coding_map_creator.py +228 -229
  18. boris/behavior_binary_table.py +33 -50
  19. boris/behaviors_coding_map.py +17 -18
  20. boris/boris_cli.py +6 -25
  21. boris/cmd_arguments.py +12 -1
  22. boris/coding_pad.py +42 -49
  23. boris/config.py +141 -65
  24. boris/config_file.py +58 -67
  25. boris/connections.py +107 -61
  26. boris/converters.py +13 -37
  27. boris/converters_ui.py +187 -110
  28. boris/cooccurence.py +250 -0
  29. boris/core.py +2373 -1786
  30. boris/core_qrc.py +15895 -10743
  31. boris/core_ui.py +943 -798
  32. boris/db_functions.py +17 -42
  33. boris/dev.py +109 -8
  34. boris/dialog.py +482 -236
  35. boris/duration_widget.py +9 -14
  36. boris/edit_event.py +61 -31
  37. boris/edit_event_ui.py +208 -97
  38. boris/event_operations.py +408 -293
  39. boris/events_cursor.py +25 -17
  40. boris/events_snapshots.py +36 -82
  41. boris/exclusion_matrix.py +4 -9
  42. boris/export_events.py +184 -223
  43. boris/export_observation.py +74 -100
  44. boris/external_processes.py +123 -98
  45. boris/geometric_measurement.py +644 -290
  46. boris/gui_utilities.py +91 -14
  47. boris/image_overlay.py +4 -4
  48. boris/import_observations.py +190 -98
  49. boris/ipc_mpv.py +325 -0
  50. boris/irr.py +20 -57
  51. boris/latency.py +31 -24
  52. boris/measurement_widget.py +14 -18
  53. boris/media_file.py +17 -19
  54. boris/menu_options.py +17 -6
  55. boris/modifier_coding_map_creator.py +1013 -0
  56. boris/modifiers_coding_map.py +7 -9
  57. boris/mpv.py +1 -0
  58. boris/mpv2.py +732 -705
  59. boris/observation.py +533 -221
  60. boris/observation_operations.py +1025 -390
  61. boris/observation_ui.py +572 -362
  62. boris/observations_list.py +71 -53
  63. boris/otx_parser.py +74 -68
  64. boris/param_panel.py +31 -16
  65. boris/param_panel_ui.py +254 -138
  66. boris/player_dock_widget.py +90 -60
  67. boris/plot_data_module.py +25 -33
  68. boris/plot_events.py +127 -90
  69. boris/plot_events_rt.py +17 -31
  70. boris/plot_spectrogram_rt.py +95 -30
  71. boris/plot_waveform_rt.py +32 -21
  72. boris/plugins.py +431 -0
  73. boris/portion/__init__.py +18 -8
  74. boris/portion/const.py +35 -18
  75. boris/portion/dict.py +5 -5
  76. boris/portion/func.py +2 -2
  77. boris/portion/interval.py +21 -41
  78. boris/portion/io.py +41 -32
  79. boris/preferences.py +306 -83
  80. boris/preferences_ui.py +684 -227
  81. boris/project.py +448 -293
  82. boris/project_functions.py +671 -238
  83. boris/project_import_export.py +213 -222
  84. boris/project_ui.py +674 -438
  85. boris/qrc_boris.py +6 -3
  86. boris/qrc_boris5.py +6 -3
  87. boris/select_modifiers.py +74 -48
  88. boris/select_observations.py +20 -198
  89. boris/select_subj_behav.py +67 -39
  90. boris/state_events.py +52 -35
  91. boris/subjects_pad.py +6 -9
  92. boris/synthetic_time_budget.py +45 -28
  93. boris/time_budget_functions.py +171 -171
  94. boris/time_budget_widget.py +84 -114
  95. boris/transitions.py +41 -47
  96. boris/utilities.py +627 -236
  97. boris/version.py +3 -3
  98. boris/video_equalizer.py +16 -14
  99. boris/video_equalizer_ui.py +199 -130
  100. boris/video_operations.py +95 -29
  101. boris/view_df.py +104 -0
  102. boris/view_df_ui.py +75 -0
  103. boris/write_event.py +538 -0
  104. boris_behav_obs-9.7.6.dist-info/METADATA +139 -0
  105. boris_behav_obs-9.7.6.dist-info/RECORD +109 -0
  106. {boris_behav_obs-8.12.dist-info → boris_behav_obs-9.7.6.dist-info}/WHEEL +1 -1
  107. boris_behav_obs-9.7.6.dist-info/entry_points.txt +2 -0
  108. boris/README.TXT +0 -22
  109. boris/add_modifier.ui +0 -323
  110. boris/converters.ui +0 -289
  111. boris/core.qrc +0 -36
  112. boris/core.ui +0 -1556
  113. boris/edit_event.ui +0 -233
  114. boris/icons/logo_eye.ico +0 -0
  115. boris/map_creator.py +0 -850
  116. boris/observation.ui +0 -814
  117. boris/param_panel.ui +0 -379
  118. boris/preferences.ui +0 -537
  119. boris/project.ui +0 -1069
  120. boris/project_server.py +0 -236
  121. boris/vlc.py +0 -10343
  122. boris/vlc_local.py +0 -90
  123. boris_behav_obs-8.12.dist-info/LICENSE.TXT +0 -674
  124. boris_behav_obs-8.12.dist-info/METADATA +0 -128
  125. boris_behav_obs-8.12.dist-info/RECORD +0 -108
  126. boris_behav_obs-8.12.dist-info/entry_points.txt +0 -3
  127. {boris → boris_behav_obs-9.7.6.dist-info/licenses}/LICENSE.TXT +0 -0
  128. {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-2023 Olivier Friard
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 PyQt5.QtCore import Qt
30
- from PyQt5.QtGui import QIcon
31
- from PyQt5.QtWidgets import (
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 = ["Observation id", "Number of occurences", "Total duration (s)", "Duration mean (s)", "Std Dev"]
82
- details_header = ["Observation id", "Comment", "Start time", "Stop time", "Duration (s)"]
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
- lb = QLabel("Filter")
94
- vbox.addWidget(lb)
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("Close", clicked=self.close)
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, f'Wrong number of double quotes (")')
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", "NA", "NA", "NA"])
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} ", "NA", "NA", "NA"])
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
- self.pj[cfg.OBSERVATIONS], selected_observations
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
- flagShowIncludeModifiers=False,
365
- flagShowExcludeBehaviorsWoEvents=False,
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
- if parameters == {}:
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] == cfg.TIME_EVENTS:
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}"] = events[obs][f"{subj}|{behav}"] | interval_func([start, stop])
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