From 3a6bd4f195ac5261f56a52443547308b51803762 Mon Sep 17 00:00:00 2001 From: GuilleDylan Date: Tue, 16 Dec 2025 16:58:57 +0100 Subject: [PATCH 1/7] Parallel segmentation test --- src/pywib/__init__.py | 5 ++-- src/pywib/core/keyboard.py | 2 +- src/pywib/utils/__init__.py | 4 ++- src/pywib/utils/keyboard.py | 1 - src/pywib/utils/segmentation.py | 43 +++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/pywib/__init__.py b/src/pywib/__init__.py index e7c2df9..1311469 100644 --- a/src/pywib/__init__.py +++ b/src/pywib/__init__.py @@ -7,7 +7,7 @@ from .constants import * from .utils import (validate_dataframe, validate_dataframe_keyboard, - extract_traces_by_session, visualize_trace, compute_space_time_diff, video_from_trace) + extract_traces_by_session, extract_traces_by_session_parallel, visualize_trace, compute_space_time_diff, video_from_trace) from .core import (velocity, acceleration, jerkiness, path, auc_ratio, execution_time, movement_time, pauses_metrics, velocity_metrics, acceleration_metrics, jerkiness_metrics, number_of_clicks, @@ -30,7 +30,8 @@ "visualize_trace", "compute_space_time_diff", "extract_traces_by_session", - "video_from_trace" + "video_from_trace", + 'extract_traces_by_session_parallel', # Movement functions "velocity", diff --git a/src/pywib/core/keyboard.py b/src/pywib/core/keyboard.py index a2f1529..4a8ec42 100644 --- a/src/pywib/core/keyboard.py +++ b/src/pywib/core/keyboard.py @@ -8,7 +8,7 @@ import numpy as np from pywib.utils.segmentation import extract_keystroke_traces_by_session from pywib.utils.validation import validate_dataframe_keyboard -from pywib.constants import EventTypes, ColumnNames, KeyCodeEvents +from pywib.constants import EventTypes, ColumnNames from pywib.utils.keyboard import (backspace_usage_df, backspace_usage_traces, typing_durations_df, typing_durations_traces, typing_speed_df, typing_speed_traces) def typing_durations(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = True) -> list: diff --git a/src/pywib/utils/__init__.py b/src/pywib/utils/__init__.py index 426a200..5f83531 100644 --- a/src/pywib/utils/__init__.py +++ b/src/pywib/utils/__init__.py @@ -3,7 +3,8 @@ """ from .validation import validate_dataframe, validate_dataframe_keyboard -from .segmentation import extract_traces_by_session, extract_mouse_click_traces_by_ession, extract_mouse_click_traces_by_session_with_intial_pause +from .segmentation import (extract_traces_by_session, extract_mouse_click_traces_by_ession, + extract_mouse_click_traces_by_session_with_intial_pause, extract_traces_by_session_parallel) from .visualization import visualize_trace, video_from_trace from .utils import compute_space_time_diff, compute_metrics_from_traces from .movement import (acceleration_traces, velocity_traces, velocity_df, @@ -28,5 +29,6 @@ 'auc_ratio_df', 'extract_mouse_click_traces_by_ession', 'extract_mouse_click_traces_by_session_with_intial_pause', + 'extract_traces_by_session_parallel' 'video_from_trace' ] \ No newline at end of file diff --git a/src/pywib/utils/keyboard.py b/src/pywib/utils/keyboard.py index b1ac9fb..fa24c7a 100644 --- a/src/pywib/utils/keyboard.py +++ b/src/pywib/utils/keyboard.py @@ -1,6 +1,5 @@ import pandas as pd -import numpy as np from pywib.constants import ColumnNames, EventTypes, KeyCodeEvents from pywib.utils.validation import validate_dataframe_keyboard diff --git a/src/pywib/utils/segmentation.py b/src/pywib/utils/segmentation.py index d936414..599f8ad 100644 --- a/src/pywib/utils/segmentation.py +++ b/src/pywib/utils/segmentation.py @@ -1,3 +1,5 @@ +from typing import List, Optional, Tuple +import concurrent.futures import pandas as pd from ..constants import EventTypes, ColumnNames from ..utils.validation import validate_dataframe, validate_dataframe_keyboard @@ -42,6 +44,47 @@ def extract_traces_by_session(dt: pd.DataFrame) -> dict: traces_by_session[session_id] = traces return traces_by_session +def extract_traces_by_session_parallel(dt: pd.DataFrame, n_jobs: Optional[int] = None) -> dict: + """ + Extracts traces from the DataFrame, grouped by (sessionId, sceneId). + Each trace is considered as a sequence of consecutive ON_MOUSE_MOVE events + between two non-move events. + Parameters: + dt (pd.DataFrame): DataFrame containing 'sessionId', 'sceneId', 'eventType', and 'timeStamp' columns. + n_jobs (Optional[int]): The number of threads to use for parallel processing. + Returns: + dict: a dictionary with keys as (sessionId) and values as lists of DataFrames. + """ + validate_dataframe(dt) + dt = dt.sort_values(by=ColumnNames.TIME_STAMP).reset_index(drop=True) + traces_by_session : dict = {} + groups = list(dt.groupby(ColumnNames.SESSION_ID)) + # For small number of groups or forced sequential, keep single-threaded + if n_jobs == 1 or len(groups) < 2: + for session_id, group in groups: + _, traces = _extract_traces_for_session((session_id, group)) + traces_by_session[session_id] = traces + return traces_by_session + + # ThreadPoolExecutor chosen to avoid Windows multiprocessing spawn/pickle overhead. + max_workers = n_jobs if n_jobs is not None else None + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as exc: + for session_id, traces in exc.map(_extract_traces_for_session, groups): + traces_by_session[session_id] = traces + + return traces_by_session + +def _extract_traces_for_session(item: Tuple[str, pd.DataFrame]) -> Tuple[str, List[pd.DataFrame]]: + session_id, group = item + is_move = group[ColumnNames.EVENT_TYPE].isin([EventTypes.EVENT_ON_MOUSE_MOVE, EventTypes.EVENT_ON_TOUCH_MOVE]) + group_id = (~is_move).cumsum() + traces: List[pd.DataFrame] = [] + for _, sub_group in group[is_move].groupby(group_id[is_move]): + if len(sub_group) > 1: + traces.append(sub_group) + return session_id, traces + + def extract_keystroke_traces_by_session(dt: pd.DataFrame) -> dict[str, list[pd.DataFrame]]: """ Extracts keystroke traces from the DataFrame, grouped by (sessionId, sceneId). From f0a0cd5f34626799402ba99fcf921e086e49bb33 Mon Sep 17 00:00:00 2001 From: GuilleDylan Date: Mon, 19 Jan 2026 22:09:53 +0100 Subject: [PATCH 2/7] Workflow logic and documentation --- .github/workflows/deply_docs.yml | 3 + .github/workflows/publish.yml | 2 +- docs/source/introduction.rst | 35 ++++++++++ docs/source/references.bib | 113 +++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deply_docs.yml b/.github/workflows/deply_docs.yml index 79c3d44..f95f91f 100644 --- a/.github/workflows/deply_docs.yml +++ b/.github/workflows/deply_docs.yml @@ -3,6 +3,9 @@ name: Deploy Documentation on: push: branches: [ main ] + paths: + - 'docs/**' + - '.github/workflows/deply_docs.yml' permissions: contents: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3d11041..795c44d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,7 @@ jobs: run: hatch build - name: Publish to TestPyPI - if: github.ref == 'refs/heads/test' + if: github.ref == 'refs/heads/main' env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index f8640f6..213c052 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -7,7 +7,36 @@ This project aims to analyze user interaction with web applications by processin It provides tools to compute various interaction related metrics (like velocity, acceleration, auc, etc.) and other useful functionalities to facilitate the analysis of user behavior, such as stroke visualization or video generation of user sessions. +Rationale +============= +... +The analyisis of mouse interaction has been widely used in HCI to infer in several aspects of the users interaction with the system. +This mouse dynamics have been proven useful for analysing bheavioral patterns :cite:p:`Katerina2018-ch,Cepeda2018-kn`, +cognitive and physicial conditions affecting the user :cite:p:`Seelye2015-yxm, Khan2008-is, Rhim2023-uz` +or even for user identification :cite:p:`Karim2020-ss` and authentication :cite:p:`Monrose-2000-oc`. + +One could enumerate hundreads of research works in this field that have analyzed mouse interaction data to extract meaningful insights about user behavior. +However, there is a lack of dedicated tools and libraries to facilitate this analysis, which is a gap that **pywib** aims to address. + +As of 2026, there are no other Python libraries specifically designed for analyzing web interaction behavior in HCI research. +While there are libraries for this same purpose in other programming languages, such as R's `mousemove` :cite:p:`Wulff2025-bt`, +they may not be as accessible to researchers who primarily use Python for data analysis and machine learning tasks, limiting as well +the integration with other Python-based tools and libraries commonly used in HCI research or the automation of analysis pipelines using Python based APIs. + +Validity of Metrics +-------------------- +One of the main problems when dealing with a library that aims to cover computation of, at most, the most common metrics in HCI research is the validity of such ones. +For this reason, **pywib** has been developed taking into account the most relevant metrics used in research works, that have been proven to be representative of user behavior in different contexts. +This does not mean that the developer team will not expand the library with new metrics in the future, if there is a given need for them, but rather that the initial set of metrics that have been included are those that could be initialy proven to be mathematically and experimentally valid. + +Context Specific Metrics +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +It is important to note that not all metrics are equally valid in all contexts. +For example, metrics that are valid for analyzing mouse movements in a desktop web application may not be valid for analyzing touch interactions on mobile devices. +Therefore, it is crucial to consider the context in which the metrics will be applied and to validate them accordingly. + +Moreover, the setup of an experiment itself can influence the validity of certain metrics :cite:p:`Schoemann2019-vv,Kuric2024-wc`, which is why **pywib** encourages users to validate the metrics they compute in their specific context and experiment setup. Installation ------------- @@ -32,3 +61,9 @@ Small Example v = velocity(df_all_sessions) v_metrics = velocity_metrics(None, v) + +References +============= + +.. bibliography:: references.bib + :style: apa \ No newline at end of file diff --git a/docs/source/references.bib b/docs/source/references.bib index f7ccb6a..b3c242f 100644 --- a/docs/source/references.bib +++ b/docs/source/references.bib @@ -391,3 +391,116 @@ @ARTICLE{Vizer2009-sg @phdthesis{Dijkstra_2013, title={The diagnosis of self-efficacy using mouse and keyboard input}, author={Dijkstra, Maarten}, year={2013}, school={Utrecht University} } + +@ARTICLE{Kuric2024-wc, + title = "Is mouse dynamics information credible for user behavior + research? An empirical investigation", + author = "Kuric, Eduard and Demcak, Peter and Krajcovic, Matus and Nemcek, + Peter", + journal = "Comput. Stand. Interfaces", + publisher = "Elsevier BV", + volume = 90, + number = 103849, + pages = "103849", + month = aug, + year = 2024, + copyright = "http://creativecommons.org/licenses/by/4.0/", + language = "en" +} + +@ARTICLE{Wulff2025-bt, + title = "Movement tracking of psychological processes: A tutorial using + mousetrap", + author = "Wulff, Dirk U and Kieslich, Pascal J and Henninger, Felix and + Haslbeck, Jonas M B and Schulte-Mecklenbeck, Michael", + abstract = "Movement tracking is a novel process-tracing method that + promises unique access to the temporal dynamics of psychological + processes. The method involves high-resolution tracking of a + hand or handheld device (e.g., a computer mouse) while it is + used to make a choice. In contrast to other process-tracing + methods, which mostly focus on information acquisition, movement + tracking focuses on the processes of information integration and + preference formation. In this article, we present a tutorial on + movement tracking of psychological processes with the mousetrap + R package. We address all steps of the research process, from + design to interpretation, with a particular focus on data + processing and analysis and featuring both established and novel + approaches. Using a representative working example, we + demonstrate how the various steps of movement-tracking analysis + can be implemented with mousetrap and provide thorough + explanations of their theoretical background and interpretation. + Finally, we present a list of recommendations to assist + researchers in addressing their own research questions using + movement tracking of psychological processes.", + journal = "Behav. Res. Methods", + publisher = "Springer Science and Business Media LLC", + volume = 57, + number = 11, + pages = "307", + month = oct, + year = 2025, + keywords = "Cognitive processes; Decision making; Movement tracking; Process + tracing", + copyright = "https://creativecommons.org/licenses/by/4.0", + language = "en" +} + +@ARTICLE{Schoemann2019-vv, + title = "Validating mouse-tracking: How design factors influence action + dynamics in intertemporal decision making", + author = "Schoemann, Martin and L{\"u}ken, Malte and Grage, Tobias and + Kieslich, Pascal J and Scherbaum, Stefan", + abstract = "Mouse-tracking is an increasingly popular process-tracing + method. It builds on the assumption that the continuity of + cognitive processing leaks into the continuity of mouse + movements. Because this assumption is the prerequisite for + meaningful reverse inference, it is an important question + whether the assumed interaction between continuous processing + and movement might be influenced by the methodological setup of + the measurement. Here we studied the impacts of three commonly + occurring methodological variations on the quality of + mouse-tracking measures, and hence, on the reported cognitive + effects. We used a mouse-tracking version of a classical + intertemporal choice task that had previously been used to + examine the dynamics of temporal discounting and the date-delay + effect (Dshemuchadse, Scherbaum, \& Goschke, 2013). The data + from this previous study also served as a benchmark condition in + our experimental design. Between studies, we varied the starting + procedure. Within the new study, we varied the response + procedure and the stimulus position. The starting procedure had + the strongest influence on common mouse-tracking measures, and + therefore on the cognitive effects. The effects of the response + procedure and the stimulus position were weaker and less + pronounced. The results suggest that the methodological setup + crucially influences the interaction between continuous + processing and mouse movement. We conclude that the + methodological setup is of high importance for the validity of + mouse-tracking as a process-tracing method. Finally, we discuss + the need for standardized mouse-tracking setups, for which we + provide recommendations, and present two promising lines of + research toward obtaining an evidence-based gold standard of + mouse-tracking.", + journal = "Behav. Res. Methods", + publisher = "Springer Science and Business Media LLC", + volume = 51, + number = 5, + pages = "2356--2377", + month = oct, + year = 2019, + keywords = "Action dynamics; Boundary conditions; Intertemporal choice; + Mouse-tracking; Process-tracing", + language = "en" +} + +@ARTICLE{Karim2020-ss, + title = "A study on mouse movement features to identify user", + author = "Karim, Masud and Hasanuzzaman, Md", + journal = "Sci. Res. J.", + publisher = "Scientific Research Journal SCIRJ", + volume = 08, + number = 04, + pages = "77--82", + month = apr, + year = 2020 +} + From 8eaf16cf1c1ccf400bd45849a309c8a0be24419b Mon Sep 17 00:00:00 2001 From: GuilleDylan Date: Wed, 4 Feb 2026 21:05:37 +0100 Subject: [PATCH 3/7] Async Velocity Method --- src/pywib/__init__.py | 3 ++- src/pywib/core/movement.py | 8 ++++++-- src/pywib/utils/__init__.py | 10 ++++++---- src/pywib/utils/movement.py | 24 ++++++++++++++++++++++++ src/pywib/utils/segmentation.py | 31 +------------------------------ 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/pywib/__init__.py b/src/pywib/__init__.py index 5dde321..b2b7ab8 100644 --- a/src/pywib/__init__.py +++ b/src/pywib/__init__.py @@ -7,7 +7,8 @@ from .constants import * from .utils import (validate_dataframe, validate_dataframe_keyboard, - extract_traces_by_session, visualize_trace, compute_space_time_diff, video_from_trace, validate_duplicate_timestamps) + extract_traces_by_session, visualize_trace, compute_space_time_diff, video_from_trace, + validate_duplicate_timestamps) from .core import (velocity, acceleration, jerkiness, path, auc_ratio, execution_time, movement_time, pauses_metrics, velocity_metrics, acceleration_metrics, jerkiness_metrics, number_of_clicks, diff --git a/src/pywib/core/movement.py b/src/pywib/core/movement.py index fd8908b..79c994f 100644 --- a/src/pywib/core/movement.py +++ b/src/pywib/core/movement.py @@ -5,10 +5,10 @@ acceleration_df, jerkiness_df, jerkiness_traces, _path, validate_dataframe, compute_space_time_diff, compute_metrics_from_traces, extract_traces_by_session, - auc_ratio_traces, auc_ratio_df) + auc_ratio_traces, auc_ratio_df, velocity_traces_parallel) from pywib.constants import ColumnNames -def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False) -> dict[str, list[pd.DataFrame]]: +def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False, parallel:bool = False, n_jobs: int = 2) -> dict[str, list[pd.DataFrame]]: """ Function to calculate velocity for either a single DataFrame or a traces dictionary. @@ -34,6 +34,10 @@ def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = No validate_dataframe(df) traces = extract_traces_by_session(df) + if parallel: + # Compute velocity for each trace in parallel + return velocity_traces_parallel(traces, n_jobs=n_jobs) + # Compute velocity for each trace return velocity_traces(traces) diff --git a/src/pywib/utils/__init__.py b/src/pywib/utils/__init__.py index 65c2381..1fdbbd0 100644 --- a/src/pywib/utils/__init__.py +++ b/src/pywib/utils/__init__.py @@ -3,12 +3,13 @@ """ from .validation import validate_dataframe, validate_dataframe_keyboard, validate_duplicate_timestamps -from .segmentation import extract_traces_by_session, extract_mouse_click_traces_by_ession, extract_mouse_click_traces_by_session_with_intial_pause +from .segmentation import (extract_traces_by_session, extract_mouse_click_traces_by_session, + extract_mouse_click_traces_by_session_with_intial_pause) from .visualization import visualize_trace, video_from_trace from .utils import compute_space_time_diff, compute_metrics_from_traces from .movement import (acceleration_traces, velocity_traces, velocity_df, acceleration_df, jerkiness_df, jerkiness_traces, _path, - auc_ratio_traces, auc_ratio_df) + auc_ratio_traces, auc_ratio_df, velocity_traces_parallel) __all__ = [ 'validate_dataframe', @@ -26,8 +27,9 @@ 'compute_metrics_from_traces', 'auc_ratio_traces', 'auc_ratio_df', - 'extract_mouse_click_traces_by_ession', + 'extract_mouse_click_traces_by_session', 'extract_mouse_click_traces_by_session_with_intial_pause', 'video_from_trace', - 'validate_duplicate_timestamps' + 'validate_duplicate_timestamps', + 'velocity_traces_parallel' ] \ No newline at end of file diff --git a/src/pywib/utils/movement.py b/src/pywib/utils/movement.py index 758578a..f96d3c1 100644 --- a/src/pywib/utils/movement.py +++ b/src/pywib/utils/movement.py @@ -37,6 +37,30 @@ def velocity_traces(traces: dict[str, list[pd.DataFrame]]) -> dict[str, list[pd. traces[session_id] = session_traces return traces +def velocity_traces_parallel(traces: dict[str, list[pd.DataFrame]], n_jobs: int = 2) -> dict[str, list[pd.DataFrame]]: + """ + Calculate velocity for a dictionary of traces (each a list of DataFrames) in parallel. + + Parameters: + traces (dict[str, list[pd.DataFrame]]): Mapping of sessionId to list of DataFrames. + n_jobs (int): Number of parallel jobs. + + Returns: + dict[str, list[pd.DataFrame]]: Same structure, but with velocity computed in each DataFrame. + """ + from joblib import Parallel, delayed + + def compute_velocity_for_trace(df): + validate_dataframe(df) + return velocity_df(df) + + for session_id, session_traces in traces.items(): + session_traces = Parallel(n_jobs=n_jobs)( + delayed(compute_velocity_for_trace)(df) for df in session_traces + ) + traces[session_id] = session_traces + return traces + import pandas as pd def acceleration_df(df: pd.DataFrame) -> pd.DataFrame: diff --git a/src/pywib/utils/segmentation.py b/src/pywib/utils/segmentation.py index 599f8ad..41d2ff4 100644 --- a/src/pywib/utils/segmentation.py +++ b/src/pywib/utils/segmentation.py @@ -44,35 +44,6 @@ def extract_traces_by_session(dt: pd.DataFrame) -> dict: traces_by_session[session_id] = traces return traces_by_session -def extract_traces_by_session_parallel(dt: pd.DataFrame, n_jobs: Optional[int] = None) -> dict: - """ - Extracts traces from the DataFrame, grouped by (sessionId, sceneId). - Each trace is considered as a sequence of consecutive ON_MOUSE_MOVE events - between two non-move events. - Parameters: - dt (pd.DataFrame): DataFrame containing 'sessionId', 'sceneId', 'eventType', and 'timeStamp' columns. - n_jobs (Optional[int]): The number of threads to use for parallel processing. - Returns: - dict: a dictionary with keys as (sessionId) and values as lists of DataFrames. - """ - validate_dataframe(dt) - dt = dt.sort_values(by=ColumnNames.TIME_STAMP).reset_index(drop=True) - traces_by_session : dict = {} - groups = list(dt.groupby(ColumnNames.SESSION_ID)) - # For small number of groups or forced sequential, keep single-threaded - if n_jobs == 1 or len(groups) < 2: - for session_id, group in groups: - _, traces = _extract_traces_for_session((session_id, group)) - traces_by_session[session_id] = traces - return traces_by_session - - # ThreadPoolExecutor chosen to avoid Windows multiprocessing spawn/pickle overhead. - max_workers = n_jobs if n_jobs is not None else None - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as exc: - for session_id, traces in exc.map(_extract_traces_for_session, groups): - traces_by_session[session_id] = traces - - return traces_by_session def _extract_traces_for_session(item: Tuple[str, pd.DataFrame]) -> Tuple[str, List[pd.DataFrame]]: session_id, group = item @@ -108,7 +79,7 @@ def extract_keystroke_traces_by_session(dt: pd.DataFrame) -> dict[str, list[pd.D keystroke_traces_by_session[session_id] = keystroke_traces return keystroke_traces_by_session -def extract_mouse_click_traces_by_ession(dt: pd.DataFrame) -> dict: +def extract_mouse_click_traces_by_session(dt: pd.DataFrame) -> dict: """ Extracts those traces with event movements that end with ON_MOUSE_CLICK or ON_TOUCH_TAP events, grouped by sessionId. From f7302aff62513d042eeaaab3c76c938f3286e7cd Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Fri, 6 Feb 2026 16:59:12 +0100 Subject: [PATCH 4/7] Update config files Add AuthorS and docs URL --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e38bb5..05ecc68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ [project.urls] Homepage = "https://github.com/HumanCommunicationInteraction/pywib" "Bug Tracker" = "https://github.com/HumanCommunicationInteraction/pywib/issues" -Documentation = "https://pywib.readthedocs.io/" +Documentation = "https://humancommunicationinteraction.github.io/pywib/" Repository = "https://github.com/HumanCommunicationInteraction/pywib.git" [tool.hatch.build.targets.wheel] diff --git a/setup.py b/setup.py index 51d4fc9..09cbaa3 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ long_description_content_type="text/markdown", url="https://github.com/HumanCommunicationInteraction/pywib", packages=find_packages(), - author='Guillermo Dylan Carvajal Aza', + author='Guillermo Dylan Carvajal Aza & Alejandro Varela Álvarez', author_email='carvajalguillermo@uniovi.es', keywords=['HCI', 'Web Interaction', 'Analyzer'], classifiers=[ From 2860475d362d0182c393b8b7bac648667bb84760 Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Mon, 23 Feb 2026 14:17:38 +0100 Subject: [PATCH 5/7] Fix timing metrics --- docs/source/timing.rst | 10 ++++ src/pywib/constants.py | 2 + src/pywib/core/timing.py | 94 +++++++---------------------- src/pywib/utils/timing.py | 112 +++++++++++++++++++++++++++++++++++ src/pywib/utils/validator.py | 23 ------- 5 files changed, 146 insertions(+), 95 deletions(-) create mode 100644 src/pywib/utils/timing.py delete mode 100644 src/pywib/utils/validator.py diff --git a/docs/source/timing.rst b/docs/source/timing.rst index fed3cba..cb4b0e1 100644 --- a/docs/source/timing.rst +++ b/docs/source/timing.rst @@ -19,3 +19,13 @@ This metric focuses on the active movement periods during a session, excluding a It is mainly useful when focusing on the time of the actual interaction, rather than for filtering based on total session duration. Function :py:func:`~pywib.movement_time` calculates the total movement time for each session by summing the time intervals between consecutive interaction points where movement occurs. + +Number of pauses +---------------- +Function :py:func:`~pywib.num_pauses` computes the number of pauses by each session, giving as a result. +This function maintains the principle of having two separate behaviours depending on the segmentation. +If the DataFrame is not segmented by traces, then all events are taken into account to compute pauses, even clicks, scrolls, keystrokes, etc. +But if the DataFrame is segmented, then it will only computed pauses given during the times of movement. +This is crucial to understand what our data represents, as we may want to consider those pauses that happen right after a click or a key press instead of only those given at movement time. + +The metrics for this pauses can be obtained using the method :py:func:`~pywib.pauses_metrics`, which will return... TODO \ No newline at end of file diff --git a/src/pywib/constants.py b/src/pywib/constants.py index f9764bd..25836a7 100644 --- a/src/pywib/constants.py +++ b/src/pywib/constants.py @@ -75,6 +75,8 @@ class ColumnNames: ACCELERATION = 'acceleration' JERKINESS = 'jerkiness' AUC_RATIO = 'auc_ratio' + NUMBER_OF_PAUSES = 'num_pauses' + MEAN_PAUSE_PER_TRACE = 'mean_pauses_per_trace' class KeyCodeEvents: """ Key code event constants for keyboard interactions.""" diff --git a/src/pywib/core/timing.py b/src/pywib/core/timing.py index 0a3adb4..9abab84 100644 --- a/src/pywib/core/timing.py +++ b/src/pywib/core/timing.py @@ -1,8 +1,9 @@ import pandas as pd -from ..utils.validation import validate_dataframe -from ..utils.segmentation import extract_traces_by_session -from ..utils.utils import compute_space_time_diff -from ..constants import ColumnNames +from pywib.utils.validation import validate_dataframe +from pywib.utils.segmentation import extract_traces_by_session +from pywib.utils.timing import num_pauses_df, num_pauses_traces, pauses_metrics_df, pauses_metrics_per_trace +from pywib.utils.utils import compute_space_time_diff +from pywib.constants import ColumnNames def execution_time(df: pd.DataFrame) -> dict: """ @@ -49,7 +50,7 @@ def movement_time(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = None return movement_time_per_session -def num_pauses(df: pd.DataFrame, threshold: float = 100, computeTraces: bool = True) -> tuple[dict, dict]: +def num_pauses(df: pd.DataFrame, traces:dict[str, list[pd.DataFrame]] = None, threshold: float = 100, computeTraces: bool = True) -> dict[str, dict]: """ Calculate the number of pauses in the DataFrame. @@ -59,46 +60,20 @@ def num_pauses(df: pd.DataFrame, threshold: float = 100, computeTraces: bool = T computeTraces (bool): Whether to compute traces by sessionId, by default True. If False, df is assumed to be already segmented by sessionId. Returns: - tuple (tuple[dict, dict]): A tuple containing two dictionaries with the number of pauses per session and the mean number of pauses per trace, with the sessionId as keys. + dict: A dictionary containing the number of pauses and the mean pause pere trace. + With :py:attr:`~pywib.constants.ColumnNames.NUMBER_OF_PAUSES` and :py:attr:`~pywib.constants.ColumnNames.MEAN_PAUSE_PER_TRACE` as keys. """ - validate_dataframe(df) - if computeTraces: - df = extract_traces_by_session(df) + if traces: + return num_pauses_traces(traces, threshold) + else: + return num_pauses_traces(extract_traces_by_session(df), threshold) else: - df_pauses = _num_pauses_trace(df, threshold) - total_pauses_session = df_pauses.shape[0] - metrics = {} - metrics[df[ColumnNames.SESSION_ID].iloc[0]] = { - "num_pauses": total_pauses_session, - "mean_pauses_per_trace": total_pauses_session - } - return metrics - - metrics_per_session = {} - for session_id, session_traces in df.items(): - total_pauses_session = 0 - for trace in session_traces: - df_pauses = _num_pauses_trace(trace, threshold) - total_pauses_session += df_pauses.shape[0] - metrics_per_session[session_id] = { - "num_pauses": total_pauses_session, - "mean_pauses_per_trace": total_pauses_session / len(session_traces) if len(session_traces) > 0 else 0 - } - return metrics_per_session - -def _num_pauses_trace(df: pd.DataFrame, threshold: float) -> pd.DataFrame: - """ - Helper function to calculate pauses in a single trace. - """ - df = df.sort_values(by=ColumnNames.TIME_STAMP).reset_index(drop=True) - df[ColumnNames.DT] = df[ColumnNames.TIME_STAMP].diff().fillna(0) - pauses = df[df[ColumnNames.DT] > threshold] - return pauses + return num_pauses_df(df, threshold) -def pauses_metrics(df: pd.DataFrame, threshold: float = 100, traces: dict[str, list[pd.DataFrame]] = None) -> dict: +def pauses_metrics(df: pd.DataFrame, threshold: float = 100, traces: dict[str, list[pd.DataFrame]] = None, per_traces = True) -> dict: """ Calculate pause metrics for the given DataFrame. @@ -111,37 +86,12 @@ def pauses_metrics(df: pd.DataFrame, threshold: float = 100, traces: dict[str, l dict: A dictionary with sessionId as keys and a dictionary of pause metrics as values. """ - if traces is None: - validate_dataframe(df) - traces = extract_traces_by_session(df) + if per_traces: + if traces is None: + validate_dataframe(df) + traces = extract_traces_by_session(df) + return pauses_metrics_per_trace(traces, threshold) + else: + return pauses_metrics_df(df, threshold) - pause_metrics_per_session = {} - number_pauses_session, number_pauses_trace = num_pauses(traces, threshold, computeTraces=False) - for session_id, session_traces in traces.items(): - total_pauses = 0 - total_pause_duration = 0.0 - pause_durations = [] - - for trace in session_traces: - trace = compute_space_time_diff(trace) - pauses = trace[trace[ColumnNames.DT] > threshold] - total_pauses += pauses.shape[0] - total_pause_duration += pauses[ColumnNames.DT].sum() - pause_durations.extend(pauses[ColumnNames.DT].tolist()) - - # Compute pause metrics for the session - if total_pauses > 0: - mean_pause_duration = total_pause_duration / total_pauses - else: - mean_pause_duration = 0 - - pause_metrics_per_session[session_id] = { - "total_pauses": total_pauses, - "mean_pause_duration": mean_pause_duration, - "pause_durations": pause_durations, - "mean_pauses_per_trace": number_pauses_trace.get(session_id, 0), - "max_pause": max(pause_durations) if pause_durations else 0, - "min_pause": min(pause_durations) if pause_durations else 0, - } - - return pause_metrics_per_session \ No newline at end of file + \ No newline at end of file diff --git a/src/pywib/utils/timing.py b/src/pywib/utils/timing.py new file mode 100644 index 0000000..2014c47 --- /dev/null +++ b/src/pywib/utils/timing.py @@ -0,0 +1,112 @@ +import pandas as pd + +from pywib.constants import ColumnNames +from pywib.utils.utils import compute_space_time_diff +from pywib.utils.validation import validate_dataframe + +def num_pauses_df(df: pd.DataFrame, threshold: float = 100) -> dict[str, dict]: + """ + Helper function for computing the number of pauses in a dataframe without segmentation. + + Parameters: + df (pd.DataFrame): The dataframe from which to extract the pauses + threshold (float): The minimum time "distance" between two events to consider it a pause. The default is 100ms. + Returns: + dict: A dictionary containing the number of pauses and the mean pause pere trace. + With :py:data:`~pywib.constants.ColumnNames.NUMBER_OF_PAUSES`, :py:data:`~pywib.constants.ColumnNames.MEAN_PAUSE_PER_TRACE` as keys. + """ + validate_dataframe(df) + df_pauses = _num_pauses_trace(df, threshold) + total_pauses_session = df_pauses.shape[0] + metrics = {} + metrics[df[ColumnNames.SESSION_ID].iloc[0]] = { + ColumnNames.NUMBER_OF_PAUSES: total_pauses_session, + ColumnNames.MEAN_PAUSE_PER_TRACE: total_pauses_session # TODO tiene sentido? + } + return metrics + +def num_pauses_traces(traces: dict[str, list[pd.DataFrame]], threshold: float = 100) -> dict[str, dict]: + metrics_per_session = {} + for session_id, session_traces in traces.items(): + total_pauses_session = 0 + for trace in session_traces: + validate_dataframe(trace) + df_pauses = _num_pauses_trace(trace, threshold) + total_pauses_session += df_pauses.shape[0] + metrics_per_session[session_id] = { + ColumnNames.NUMBER_OF_PAUSES: total_pauses_session, + ColumnNames.MEAN_PAUSE_PER_TRACE: total_pauses_session / len(session_traces) if len(session_traces) > 0 else 0 + } + return metrics_per_session + +def _num_pauses_trace(df: pd.DataFrame, threshold: float) -> pd.DataFrame: + """ + Helper function to calculate pauses in a single trace. + """ + df = df.sort_values(by=ColumnNames.TIME_STAMP).reset_index(drop=True) + df[ColumnNames.DT] = df[ColumnNames.TIME_STAMP].diff().fillna(0) + pauses = df[df[ColumnNames.DT] > threshold] + return pauses + +def pauses_metrics_df(df: pd.DataFrame, threshold: float = 100): + pause_metrics_per_session = {} + for session_id in df[ColumnNames.SESSION_ID].unique(): + total_pause_duration = 0.0 + total_pauses = 0 + pause_durations = [] + trace = df.loc[df[ColumnNames.SESSION_ID] == session_id] + + trace = compute_space_time_diff(trace) + pauses = trace[trace[ColumnNames.DT] > threshold] + total_pause_duration += pauses[ColumnNames.DT].sum() + total_pauses += pauses.shape[0] + pause_durations.extend(pauses[ColumnNames.DT].tolist()) + + # Compute pause metrics for the session + if total_pauses > 0: + mean_pause_duration = total_pause_duration / total_pauses + else: + mean_pause_duration = 0 + + pause_metrics_per_session[session_id] = { + "total_pauses": total_pauses, + "mean_pause_duration": mean_pause_duration, + "pause_durations": pause_durations, + "max_pause": max(pause_durations) if pause_durations else 0, + "min_pause": min(pause_durations) if pause_durations else 0, + } + + return pause_metrics_per_session + +def pauses_metrics_per_trace(traces: dict[str, list[pd.DataFrame]], threshold: float = 100): + pause_metrics_per_session = {} + for session_id, session_traces in traces.items(): + total_pause_duration = 0.0 + total_pauses = 0 + pause_durations = [] + + for trace in session_traces: + trace = compute_space_time_diff(trace) + pauses = trace[trace[ColumnNames.DT] > threshold] + total_pause_duration += pauses[ColumnNames.DT].sum() + total_pauses += pauses.shape[0] + pause_durations.extend(pauses[ColumnNames.DT].tolist()) + + # Compute pause metrics for the session + if total_pauses > 0: + mean_pause_duration = total_pause_duration / total_pauses + mean_pauses_per_trace = total_pauses / len(session_traces) + else: + mean_pause_duration = 0 + mean_pauses_per_trace = 0 + + pause_metrics_per_session[session_id] = { + "total_pauses": total_pauses, + "mean_pause_duration": mean_pause_duration, + "pause_durations": pause_durations, + "mean_pauses_per_trace": mean_pauses_per_trace, + "max_pause": max(pause_durations) if pause_durations else 0, + "min_pause": min(pause_durations) if pause_durations else 0, + } + + return pause_metrics_per_session \ No newline at end of file diff --git a/src/pywib/utils/validator.py b/src/pywib/utils/validator.py deleted file mode 100644 index a66a0ba..0000000 --- a/src/pywib/utils/validator.py +++ /dev/null @@ -1,23 +0,0 @@ -import pandas as pd - -required_columns = [ - "id", "eventType", "timeStamp", "x", "y" -] - -keyboard_columns = [ - "keyValueEvent", "keyCodeEvent" -] - -def validate_dataframe(df: pd.DataFrame): - - for col in required_columns: - if col not in df.columns: - raise ValueError(f"Missing required column: {col}") - -def validate_dataframe_keyboard(df: pd.DataFrame): - - columns_to_check = required_columns + keyboard_columns - - for col in columns_to_check: - if col not in df.columns: - raise ValueError(f"Missing required column: {col}") \ No newline at end of file From 46554a242bdae62e76fa5b918d55146516497417 Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Thu, 5 Mar 2026 14:13:21 +0100 Subject: [PATCH 6/7] Remove unused imports --- src/pywib/core/movement.py | 2 +- src/pywib/utils/movement.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pywib/core/movement.py b/src/pywib/core/movement.py index 79c994f..fdf176f 100644 --- a/src/pywib/core/movement.py +++ b/src/pywib/core/movement.py @@ -5,7 +5,7 @@ acceleration_df, jerkiness_df, jerkiness_traces, _path, validate_dataframe, compute_space_time_diff, compute_metrics_from_traces, extract_traces_by_session, - auc_ratio_traces, auc_ratio_df, velocity_traces_parallel) + auc_ratio_traces, auc_ratio_df) from pywib.constants import ColumnNames def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False, parallel:bool = False, n_jobs: int = 2) -> dict[str, list[pd.DataFrame]]: diff --git a/src/pywib/utils/movement.py b/src/pywib/utils/movement.py index f96d3c1..44775b3 100644 --- a/src/pywib/utils/movement.py +++ b/src/pywib/utils/movement.py @@ -61,8 +61,6 @@ def compute_velocity_for_trace(df): traces[session_id] = session_traces return traces -import pandas as pd - def acceleration_df(df: pd.DataFrame) -> pd.DataFrame: """ Calculate acceleration for a single DataFrame. From a9b6748df079a8a924ee655dbd6c3e1d8a79403c Mon Sep 17 00:00:00 2001 From: Guillermo Dylan Carvajal Aza Date: Thu, 5 Mar 2026 14:17:27 +0100 Subject: [PATCH 7/7] Undo publish edit --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4b7c1e1..fa6e566 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,7 @@ jobs: run: hatch build - name: Publish to TestPyPI - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/test' env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}